mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 15:58:19 +00:00
Reuse scoped content and progress operations
This commit is contained in:
1 parent
28bc6e87a1
commit
cfdc698775
16 files changed
+1596
-132
No files matched your search
@@ -0,0 +1,279 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({
|
||||
PluginDialogModal: class PluginDialogModal {},
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
vi.mock("@/features/LiveSyncCommands.ts", () => ({
|
||||
LiveSyncCommands: class LiveSyncCommands {
|
||||
core!: { services: unknown; localDatabase: unknown };
|
||||
get localDatabase() {
|
||||
return this.core.localDatabase;
|
||||
}
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: {
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("octagonal-wheels/concurrency/scopedIteration", { spy: true });
|
||||
|
||||
import { LOG_LEVEL_VERBOSE, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import * as iterationModule from "octagonal-wheels/concurrency/scopedIteration";
|
||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { ConfigSync } from "./CmdConfigSync";
|
||||
|
||||
const term = "device";
|
||||
const prefix = `ix:${term}/`;
|
||||
const keyOf = (path: string) => prefix + path;
|
||||
type Row = { _id: string; path: string };
|
||||
const row = (name: string, path = name): Row => ({ _id: keyOf(name), path });
|
||||
|
||||
function setup(files: string[] = [], rows: Row[] = []) {
|
||||
const events: string[] = [];
|
||||
const findEntries = vi.fn(async function* () {
|
||||
yield* rows;
|
||||
});
|
||||
const scanInternalFiles = vi.fn(async () => files as FilePath[]);
|
||||
const storeCustomisationFileV2 = vi.fn(async (path: string, _term: string) => {
|
||||
events.push(`store:${path}`);
|
||||
});
|
||||
const deleteConfigOnDatabase = vi.fn(async (key: string) => {
|
||||
events.push(`delete:${key}`);
|
||||
});
|
||||
const updatePluginList = vi.fn(async (_showMessage: boolean) => {});
|
||||
const log = vi.fn();
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(configSync, {
|
||||
core: {
|
||||
settings: { usePluginSyncV2: true },
|
||||
localDatabase: { findEntries },
|
||||
services: { setting: { getDeviceAndVaultName: () => term } },
|
||||
},
|
||||
scanInternalFiles,
|
||||
isTargetPath: (path: string) => path !== "ignored",
|
||||
filenameWithUnifiedKey: (path: string, _term: string) => keyOf(path),
|
||||
unifiedKeyPrefixOfTerminal: (_term: string) => prefix,
|
||||
storeCustomisationFileV2,
|
||||
deleteConfigOnDatabase,
|
||||
updatePluginList,
|
||||
_log: log,
|
||||
});
|
||||
return {
|
||||
configSync,
|
||||
events,
|
||||
findEntries,
|
||||
scanInternalFiles,
|
||||
storeCustomisationFileV2,
|
||||
deleteConfigOnDatabase,
|
||||
updatePluginList,
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ConfigSync V2 scan behaviour", () => {
|
||||
it("finishes existing database entries before storing extra files and retains target filtering", async () => {
|
||||
const fixture = setup(
|
||||
["matched", "extra", "ignored"],
|
||||
[row("matched"), row("stale"), row("encoded", "encoded%path")]
|
||||
);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.events).toEqual(["store:matched", `delete:${keyOf("stale")}`, "store:extra"]);
|
||||
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||
["matched", term],
|
||||
["extra", term],
|
||||
]);
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledExactlyOnceWith(keyOf("stale"));
|
||||
expect(fixture.findEntries).toHaveBeenCalledExactlyOnceWith(prefix, `${prefix}\u{10ffff}`, {
|
||||
include_docs: true,
|
||||
});
|
||||
expect(fixture.updatePluginList).toHaveBeenCalledExactlyOnceWith(false);
|
||||
});
|
||||
|
||||
it("finishes database enumeration before dispatching database work", async () => {
|
||||
const fixture = setup();
|
||||
const enumerationPaused = promiseWithResolvers<void>();
|
||||
const continueEnumeration = promiseWithResolvers<void>();
|
||||
fixture.findEntries.mockImplementation(async function* () {
|
||||
yield row("stale");
|
||||
enumerationPaused.resolve();
|
||||
await continueEnumeration.promise;
|
||||
});
|
||||
|
||||
const scan = fixture.configSync.scanAllConfigFiles(false);
|
||||
try {
|
||||
await enumerationPaused.promise;
|
||||
expect(fixture.deleteConfigOnDatabase).not.toHaveBeenCalled();
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
continueEnumeration.resolve();
|
||||
await scan;
|
||||
}
|
||||
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledExactlyOnceWith(keyOf("stale"));
|
||||
});
|
||||
|
||||
it("limits actual database operations to ten and completes that batch before the extra batch", async () => {
|
||||
const fixture = setup(
|
||||
["extra"],
|
||||
Array.from({ length: 11 }, (_, index) => row(String(index)))
|
||||
);
|
||||
const gates = Array.from({ length: 11 }, () => promiseWithResolvers<void>());
|
||||
const tenStarted = promiseWithResolvers<void>();
|
||||
const lastStarted = promiseWithResolvers<void>();
|
||||
let active = 0;
|
||||
let maximum = 0;
|
||||
let started = 0;
|
||||
fixture.deleteConfigOnDatabase.mockImplementation(async (key) => {
|
||||
const index = Number(key.slice(prefix.length));
|
||||
active++;
|
||||
maximum = Math.max(maximum, active);
|
||||
started++;
|
||||
if (started === 10) tenStarted.resolve();
|
||||
if (index === 10) lastStarted.resolve();
|
||||
try {
|
||||
await gates[index].promise;
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
});
|
||||
const scan = fixture.configSync.scanAllConfigFiles(false);
|
||||
try {
|
||||
await tenStarted.promise;
|
||||
expect(active).toBe(10);
|
||||
expect(started).toBe(10);
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
gates[0].resolve();
|
||||
await lastStarted.promise;
|
||||
expect(active).toBe(10);
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
gates.forEach((gate) => gate.resolve());
|
||||
await scan;
|
||||
}
|
||||
expect(maximum).toBe(10);
|
||||
expect(active).toBe(0);
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(11);
|
||||
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledExactlyOnceWith("extra", term);
|
||||
});
|
||||
|
||||
it("logs a database processing error and continues through the extra batch", async () => {
|
||||
const fixture = setup(["extra"], [row("bad"), row("other")]);
|
||||
const error = new Error("delete");
|
||||
fixture.deleteConfigOnDatabase.mockRejectedValueOnce(error);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.log).toHaveBeenCalledWith(`scanAllConfigFiles - Error: ${keyOf("bad")}`, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.storeCustomisationFileV2).toHaveBeenCalledExactlyOnceWith("extra", term);
|
||||
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("retains a matched local file for the extra batch when its first store fails", async () => {
|
||||
const fixture = setup(["matched"], [row("matched")]);
|
||||
const error = new Error("first store");
|
||||
fixture.storeCustomisationFileV2.mockRejectedValueOnce(error);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||
["matched", term],
|
||||
["matched", term],
|
||||
]);
|
||||
expect(fixture.log).toHaveBeenCalledWith(`scanAllConfigFiles - Error: ${keyOf("matched")}`, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.events).toEqual(["store:matched"]);
|
||||
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("logs an extra-file processing error and continues with the remaining files", async () => {
|
||||
const fixture = setup(["bad", "other"]);
|
||||
const error = new Error("store");
|
||||
fixture.storeCustomisationFileV2.mockRejectedValueOnce(error);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(fixture.storeCustomisationFileV2.mock.calls).toEqual([
|
||||
["bad", term],
|
||||
["other", term],
|
||||
]);
|
||||
expect(fixture.log).toHaveBeenCalledWith("scanAllConfigFiles - Error: bad", LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.log).toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.updatePluginList).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfigSync scoped iteration composition", () => {
|
||||
it("uses bounded child scopes for both batches with the same shared values", async () => {
|
||||
const fixture = setup(["matched", "extra"], [row("matched"), row("stale")]);
|
||||
await fixture.configSync.scanAllConfigFiles(false);
|
||||
|
||||
expect(iterationModule.forEachInScope).toHaveBeenCalledTimes(2);
|
||||
const [database, extra] = vi.mocked(iterationModule.forEachInScope).mock.calls;
|
||||
expect(database[0]).toBe(extra[0]);
|
||||
expect(database[0].value).toEqual({ term, localFileMap: new Map([[keyOf("extra"), "extra"]]) });
|
||||
expect(database[2]).toEqual({ concurrency: 10 });
|
||||
expect(extra[2]).toEqual({ concurrency: 10 });
|
||||
expect(fixture.events).toEqual(["store:matched", `delete:${keyOf("stale")}`, "store:extra"]);
|
||||
});
|
||||
|
||||
it.each(["database", "extra"])("propagates a %s runner failure outside per-item recovery", async (batch) => {
|
||||
const fixture = setup(["extra"], [row("stale")]);
|
||||
const error = new Error("iteration");
|
||||
const actual = await vi.importActual<typeof import("octagonal-wheels/concurrency/scopedIteration")>(
|
||||
"octagonal-wheels/concurrency/scopedIteration"
|
||||
);
|
||||
if (batch === "extra") {
|
||||
vi.mocked(iterationModule.forEachInScope).mockImplementationOnce(actual.forEachInScope);
|
||||
}
|
||||
vi.mocked(iterationModule.forEachInScope).mockRejectedValueOnce(error);
|
||||
await expect(fixture.configSync.scanAllConfigFiles(false)).rejects.toBe(error);
|
||||
|
||||
expect(fixture.deleteConfigOnDatabase).toHaveBeenCalledTimes(batch === "extra" ? 1 : 0);
|
||||
expect(fixture.storeCustomisationFileV2).not.toHaveBeenCalled();
|
||||
expect(fixture.log).not.toHaveBeenCalledWith(error, LOG_LEVEL_VERBOSE);
|
||||
expect(fixture.updatePluginList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user