mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
test(dataflow): add integration test fixtures for orchestrator
Phase 0 W0 of the v10 refactor: introduce a buildOrchestrator() helper that boots a real DataflowOrchestrator with mock App/Vault/ MetadataCache, plus an InMemoryStorage double and a localforage jest mock so the cache layer can construct under jsdom. This is the foundation that subsequent stability work (lifecycle hazard fix, worker timeout, cache invariants, migration tombstones, critical-path integration tests) depends on. Smoke tests verify fixture construction, vault event plumbing, and clean dispose. 12/12 smoke tests pass; existing suite unchanged.
This commit is contained in:
parent
ee821b0625
commit
dd3fe89a9f
5 changed files with 958 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ module.exports = {
|
|||
moduleNameMapper: {
|
||||
"^obsidian$": "<rootDir>/src/__mocks__/obsidian.ts",
|
||||
"^moment$": "<rootDir>/src/__mocks__/moment.js",
|
||||
"^localforage$": "<rootDir>/src/__mocks__/localforage.ts",
|
||||
"^@codemirror/state$": "<rootDir>/src/__mocks__/codemirror-state.ts",
|
||||
"^@codemirror/view$": "<rootDir>/src/__mocks__/codemirror-view.ts",
|
||||
"^@codemirror/language$":
|
||||
|
|
|
|||
120
src/__mocks__/localforage.ts
Normal file
120
src/__mocks__/localforage.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* In-memory mock for `localforage` used in jest tests.
|
||||
*
|
||||
* The real localforage requires a browser IndexedDB or localStorage runtime
|
||||
* which jsdom does not reliably provide. This mock implements the minimum
|
||||
* surface area used by `src/cache/local-storage-cache.ts`:
|
||||
*
|
||||
* localforage.createInstance({name})
|
||||
* localforage.dropInstance({name})
|
||||
* localforage.INDEXEDDB / LOCALSTORAGE / WEBSQL (driver constants)
|
||||
*
|
||||
* Each instance backs its data in a Map keyed by `${name}::${key}`. State is
|
||||
* shared across instances with the same name (matching real localforage
|
||||
* semantics) and persists for the lifetime of the test process.
|
||||
*
|
||||
* To reset state between tests, call `(localforage as any).__resetAll()`.
|
||||
*/
|
||||
|
||||
const stores = new Map<string, Map<string, any>>();
|
||||
|
||||
function getStore(name: string): Map<string, any> {
|
||||
let store = stores.get(name);
|
||||
if (!store) {
|
||||
store = new Map();
|
||||
stores.set(name, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
interface MockInstanceOptions {
|
||||
name: string;
|
||||
driver?: any;
|
||||
description?: string;
|
||||
storeName?: string;
|
||||
}
|
||||
|
||||
function createInstance(options: MockInstanceOptions) {
|
||||
const name = options.name;
|
||||
const store = getStore(name);
|
||||
|
||||
return {
|
||||
__name: name,
|
||||
async setItem<T>(key: string, value: T): Promise<T> {
|
||||
store.set(key, value);
|
||||
return value;
|
||||
},
|
||||
async getItem<T>(key: string): Promise<T | null> {
|
||||
return store.has(key) ? (store.get(key) as T) : null;
|
||||
},
|
||||
async removeItem(key: string): Promise<void> {
|
||||
store.delete(key);
|
||||
},
|
||||
async clear(): Promise<void> {
|
||||
store.clear();
|
||||
},
|
||||
async length(): Promise<number> {
|
||||
return store.size;
|
||||
},
|
||||
async keys(): Promise<string[]> {
|
||||
return [...store.keys()];
|
||||
},
|
||||
async iterate<T>(
|
||||
fn: (value: T, key: string, iterationNumber: number) => any,
|
||||
): Promise<void> {
|
||||
let i = 0;
|
||||
for (const [k, v] of store.entries()) {
|
||||
const result = fn(v as T, k, ++i);
|
||||
if (result !== undefined) return;
|
||||
}
|
||||
},
|
||||
// localforage instances expose ready() returning a resolved promise.
|
||||
async ready(): Promise<void> {},
|
||||
// Driver introspection no-ops.
|
||||
driver(): string {
|
||||
return "MOCK";
|
||||
},
|
||||
setDriver(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
},
|
||||
// Configuration is a no-op in the mock.
|
||||
config(): boolean {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function dropInstance(options: { name: string }): Promise<void> {
|
||||
stores.delete(options.name);
|
||||
}
|
||||
|
||||
const localforage = {
|
||||
createInstance,
|
||||
dropInstance,
|
||||
// Driver constants — values don't matter, only that they're truthy strings.
|
||||
INDEXEDDB: "asyncStorage",
|
||||
LOCALSTORAGE: "localStorageWrapper",
|
||||
WEBSQL: "webSQLStorage",
|
||||
// Module-level shortcuts (rarely used by our code, but localforage exports them).
|
||||
async setItem<T>(key: string, value: T): Promise<T> {
|
||||
return createInstance({ name: "__default__" }).setItem(key, value);
|
||||
},
|
||||
async getItem<T>(key: string): Promise<T | null> {
|
||||
return createInstance({ name: "__default__" }).getItem<T>(key);
|
||||
},
|
||||
async removeItem(key: string): Promise<void> {
|
||||
return createInstance({ name: "__default__" }).removeItem(key);
|
||||
},
|
||||
async clear(): Promise<void> {
|
||||
return createInstance({ name: "__default__" }).clear();
|
||||
},
|
||||
// Test helper: clear all instances. NOT part of real localforage.
|
||||
__resetAll(): void {
|
||||
stores.clear();
|
||||
},
|
||||
__instanceCount(): number {
|
||||
return stores.size;
|
||||
},
|
||||
};
|
||||
|
||||
export default localforage;
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Smoke test for the buildOrchestrator fixture itself.
|
||||
*
|
||||
* This is W0 in the v10 Phase 0 plan: prove the fixture constructs a real
|
||||
* DataflowOrchestrator with mock dependencies, exposes the documented API,
|
||||
* and disposes cleanly. Subsequent integration tests (W5) build on top.
|
||||
*
|
||||
* This is intentionally NOT a test of orchestrator behavior — just plumbing.
|
||||
*/
|
||||
|
||||
import { buildOrchestrator } from "./buildOrchestrator";
|
||||
import { InMemoryStorage } from "./inMemoryStorage";
|
||||
|
||||
describe("buildOrchestrator fixture (W0 smoke)", () => {
|
||||
it("constructs an orchestrator with no files", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
try {
|
||||
expect(fx.orchestrator).toBeDefined();
|
||||
expect(fx.plugin).toBeDefined();
|
||||
expect(fx.plugin.dataflowOrchestrator).toBe(fx.orchestrator);
|
||||
expect(fx.app.appId).toMatch(/^test-app-/);
|
||||
expect(fx.vault.getMarkdownFiles()).toHaveLength(0);
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds vault files from the files option", async () => {
|
||||
const fx = await buildOrchestrator({
|
||||
files: {
|
||||
"notes/a.md": "- [ ] task one #x",
|
||||
"notes/b.md": "- [x] task two",
|
||||
"notes/c.txt": "not markdown",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const md = fx.vault.getMarkdownFiles();
|
||||
expect(md.map((f) => f.path).sort()).toEqual([
|
||||
"notes/a.md",
|
||||
"notes/b.md",
|
||||
]);
|
||||
expect(await fx.vault.read({ path: "notes/a.md" })).toBe(
|
||||
"- [ ] task one #x",
|
||||
);
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("merges settings overrides onto DEFAULT_SETTINGS", async () => {
|
||||
const fx = await buildOrchestrator({
|
||||
settings: { preferMetadataFormat: "dataview" },
|
||||
});
|
||||
try {
|
||||
expect(fx.plugin.settings.preferMetadataFormat).toBe("dataview");
|
||||
// Other defaults still present
|
||||
expect(fx.plugin.settings.taskStatuses).toBeDefined();
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("writeFile triggers vault modify event", async () => {
|
||||
const fx = await buildOrchestrator({
|
||||
files: { "a.md": "- [ ] one" },
|
||||
});
|
||||
try {
|
||||
const events: string[] = [];
|
||||
fx.vault.on("modify", (file: any) => {
|
||||
events.push(`modify:${file.path}`);
|
||||
});
|
||||
await fx.writeFile("a.md", "- [x] one");
|
||||
expect(events).toEqual(["modify:a.md"]);
|
||||
expect(await fx.vault.read({ path: "a.md" })).toBe("- [x] one");
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("createFile triggers vault create event", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
try {
|
||||
const events: string[] = [];
|
||||
fx.vault.on("create", (file: any) => {
|
||||
events.push(`create:${file.path}`);
|
||||
});
|
||||
await fx.createFile("new.md", "- [ ] new");
|
||||
expect(events).toEqual(["create:new.md"]);
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("deleteFile triggers vault delete event", async () => {
|
||||
const fx = await buildOrchestrator({ files: { "doomed.md": "x" } });
|
||||
try {
|
||||
const events: string[] = [];
|
||||
fx.vault.on("delete", (file: any) => {
|
||||
events.push(`delete:${file.path}`);
|
||||
});
|
||||
await fx.deleteFile("doomed.md");
|
||||
expect(events).toEqual(["delete:doomed.md"]);
|
||||
expect(fx.vault.getFileByPath("doomed.md")).toBeNull();
|
||||
} finally {
|
||||
await fx.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispose calls orchestrator.cleanup() and does not throw", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
// Spy on orchestrator.cleanup to make sure dispose actually invokes it.
|
||||
const spy = jest.spyOn(fx.orchestrator, "cleanup");
|
||||
await fx.dispose();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(fx.plugin.dataflowOrchestrator).toBeUndefined();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("can call dispose without files or initialization", async () => {
|
||||
const fx = await buildOrchestrator();
|
||||
await expect(fx.dispose()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryStorage (W0)", () => {
|
||||
it("stores and retrieves raw records", async () => {
|
||||
const s = new InMemoryStorage();
|
||||
await s.storeRaw("a.md", [], "content", 100);
|
||||
const r = await s.loadRaw("a.md");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r?.mtime).toBe(100);
|
||||
expect(r?.data).toEqual([]);
|
||||
});
|
||||
|
||||
it("clearNamespace only clears matching prefix", async () => {
|
||||
const s = new InMemoryStorage();
|
||||
await s.storeRaw("a.md", []);
|
||||
await s.storeAugmented("a.md", []);
|
||||
await s.storeProject("a.md", { enhancedMetadata: {} });
|
||||
const before = await s.getStats();
|
||||
expect(before.byNamespace.raw).toBe(1);
|
||||
expect(before.byNamespace.augmented).toBe(1);
|
||||
expect(before.byNamespace.project).toBe(1);
|
||||
|
||||
await s.clearNamespace("raw");
|
||||
const after = await s.getStats();
|
||||
expect(after.byNamespace.raw).toBe(0);
|
||||
expect(after.byNamespace.augmented).toBe(1);
|
||||
expect(after.byNamespace.project).toBe(1);
|
||||
});
|
||||
|
||||
it("version mismatch on read invalidates the record", async () => {
|
||||
const s = new InMemoryStorage("1.0.0");
|
||||
await s.storeRaw("a.md", []);
|
||||
expect(await s.loadRaw("a.md")).not.toBeNull();
|
||||
s.__setVersion("2.0.0");
|
||||
expect(await s.loadRaw("a.md")).toBeNull();
|
||||
});
|
||||
|
||||
it("meta save/load round-trips", async () => {
|
||||
const s = new InMemoryStorage();
|
||||
await s.saveMeta("k", { foo: 1 });
|
||||
expect(await s.loadMeta("k")).toEqual({ foo: 1 });
|
||||
});
|
||||
});
|
||||
419
src/__tests__/integration/_fixtures/buildOrchestrator.ts
Normal file
419
src/__tests__/integration/_fixtures/buildOrchestrator.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* Integration test fixture for booting a real DataflowOrchestrator
|
||||
* with mock Obsidian app/vault/metadataCache and a fake plugin instance.
|
||||
*
|
||||
* Used by Phase 0 stability tests (lifecycle, settings change, worker fallback,
|
||||
* cache invariants, migration tombstones). The fixture is intentionally narrow:
|
||||
* provide files and settings, get back an orchestrator + dispose.
|
||||
*
|
||||
* Construction is lazy — workers do not spawn until the orchestrator's
|
||||
* `initialize()` is called or a query/process method runs. This means a fixture
|
||||
* that only tests cleanup paths can avoid pulling worker construction into the
|
||||
* test environment.
|
||||
*/
|
||||
|
||||
import { DataflowOrchestrator } from "@/dataflow/Orchestrator";
|
||||
import { DEFAULT_SETTINGS } from "@/common/setting-definition";
|
||||
import type { TaskProgressBarSettings } from "@/common/setting-definition";
|
||||
|
||||
export interface VaultFile {
|
||||
path: string;
|
||||
content: string;
|
||||
mtime?: number;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export interface BuildOrchestratorOptions {
|
||||
/** Map of path → file content (or full VaultFile records). */
|
||||
files?: Record<string, string> | VaultFile[];
|
||||
/** Partial settings overrides merged onto DEFAULT_SETTINGS. */
|
||||
settings?: Partial<TaskProgressBarSettings>;
|
||||
/** Plugin manifest version. Defaults to "test-1.0.0". */
|
||||
version?: string;
|
||||
/** Whether to call orchestrator.initialize() after construction. Default false. */
|
||||
initialize?: boolean;
|
||||
}
|
||||
|
||||
export interface OrchestratorFixture {
|
||||
orchestrator: DataflowOrchestrator;
|
||||
plugin: FakePlugin;
|
||||
app: FakeApp;
|
||||
vault: FakeVault;
|
||||
metadataCache: FakeMetadataCache;
|
||||
/**
|
||||
* Update the contents of a file and trigger a vault `modify` event.
|
||||
* Returns a promise that resolves once vault listeners have been notified.
|
||||
*/
|
||||
writeFile(path: string, content: string): Promise<void>;
|
||||
/** Delete a file and trigger a vault `delete` event. */
|
||||
deleteFile(path: string): Promise<void>;
|
||||
/** Create a new file and trigger a vault `create` event. */
|
||||
createFile(path: string, content: string): Promise<void>;
|
||||
/**
|
||||
* Tear down the orchestrator. Awaits all async cleanup. Tests that need
|
||||
* to assert no leaked timers should wrap the test in jest.useFakeTimers()
|
||||
* and check `jest.getTimerCount() === 0` AFTER dispose returns.
|
||||
*/
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal fakes — only what Orchestrator and its sub-components actually call.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class FakeTFile {
|
||||
path: string;
|
||||
name: string;
|
||||
basename: string;
|
||||
extension: string;
|
||||
stat: { mtime: number; ctime: number; size: number };
|
||||
parent: any = null;
|
||||
|
||||
constructor(path: string, content: string, mtime: number = Date.now()) {
|
||||
this.path = path;
|
||||
const segs = path.split("/");
|
||||
this.name = segs[segs.length - 1];
|
||||
const dot = this.name.lastIndexOf(".");
|
||||
this.basename = dot >= 0 ? this.name.substring(0, dot) : this.name;
|
||||
this.extension = dot >= 0 ? this.name.substring(dot + 1) : "";
|
||||
this.stat = { mtime, ctime: mtime, size: content.length };
|
||||
}
|
||||
}
|
||||
|
||||
class EventBus {
|
||||
private handlers = new Map<string, Set<(...args: any[]) => void>>();
|
||||
|
||||
on(name: string, handler: (...args: any[]) => void) {
|
||||
if (!this.handlers.has(name)) this.handlers.set(name, new Set());
|
||||
this.handlers.get(name)!.add(handler);
|
||||
return { name, handler } as any;
|
||||
}
|
||||
|
||||
off(name: string, handler: (...args: any[]) => void) {
|
||||
this.handlers.get(name)?.delete(handler);
|
||||
}
|
||||
|
||||
offref(ref: any) {
|
||||
if (ref && ref.name && ref.handler) this.off(ref.name, ref.handler);
|
||||
}
|
||||
|
||||
trigger(name: string, ...args: any[]) {
|
||||
const set = this.handlers.get(name);
|
||||
if (!set) return;
|
||||
for (const h of [...set]) {
|
||||
try {
|
||||
h(...args);
|
||||
} catch (e) {
|
||||
// Don't let one listener break the others; surface in console for debugging.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[buildOrchestrator] listener for ${name} threw:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
listenerCount(): number {
|
||||
let n = 0;
|
||||
for (const s of this.handlers.values()) n += s.size;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeVault {
|
||||
private fileMap = new Map<string, FakeTFile>();
|
||||
private contentMap = new Map<string, string>();
|
||||
private bus = new EventBus();
|
||||
configDir = ".obsidian";
|
||||
|
||||
constructor(initialFiles?: Record<string, string> | VaultFile[]) {
|
||||
if (Array.isArray(initialFiles)) {
|
||||
for (const f of initialFiles) this.__seed(f.path, f.content, f.mtime);
|
||||
} else if (initialFiles) {
|
||||
for (const [path, content] of Object.entries(initialFiles)) {
|
||||
this.__seed(path, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
__seed(path: string, content: string, mtime?: number): FakeTFile {
|
||||
const file = new FakeTFile(path, content, mtime ?? Date.now());
|
||||
this.fileMap.set(path, file);
|
||||
this.contentMap.set(path, content);
|
||||
return file;
|
||||
}
|
||||
|
||||
getMarkdownFiles(): FakeTFile[] {
|
||||
return [...this.fileMap.values()].filter((f) => f.extension === "md");
|
||||
}
|
||||
|
||||
getFiles(): FakeTFile[] {
|
||||
return [...this.fileMap.values()];
|
||||
}
|
||||
|
||||
getAbstractFileByPath(path: string): FakeTFile | null {
|
||||
return this.fileMap.get(path) ?? null;
|
||||
}
|
||||
|
||||
getFileByPath(path: string): FakeTFile | null {
|
||||
return this.fileMap.get(path) ?? null;
|
||||
}
|
||||
|
||||
async read(file: FakeTFile | { path: string }): Promise<string> {
|
||||
return this.contentMap.get(file.path) ?? "";
|
||||
}
|
||||
|
||||
async cachedRead(file: FakeTFile | { path: string }): Promise<string> {
|
||||
return this.contentMap.get(file.path) ?? "";
|
||||
}
|
||||
|
||||
async modify(file: FakeTFile | { path: string }, content: string): Promise<void> {
|
||||
this.contentMap.set(file.path, content);
|
||||
const real = this.fileMap.get(file.path);
|
||||
if (real) {
|
||||
real.stat.mtime = Date.now();
|
||||
real.stat.size = content.length;
|
||||
this.bus.trigger("modify", real);
|
||||
}
|
||||
}
|
||||
|
||||
async create(path: string, content: string): Promise<FakeTFile> {
|
||||
const f = this.__seed(path, content);
|
||||
this.bus.trigger("create", f);
|
||||
return f;
|
||||
}
|
||||
|
||||
async delete(file: FakeTFile | { path: string }): Promise<void> {
|
||||
const real = this.fileMap.get(file.path);
|
||||
this.fileMap.delete(file.path);
|
||||
this.contentMap.delete(file.path);
|
||||
if (real) this.bus.trigger("delete", real);
|
||||
}
|
||||
|
||||
async rename(file: FakeTFile, newPath: string): Promise<void> {
|
||||
const oldPath = file.path;
|
||||
const content = this.contentMap.get(oldPath) ?? "";
|
||||
this.fileMap.delete(oldPath);
|
||||
this.contentMap.delete(oldPath);
|
||||
const renamed = this.__seed(newPath, content);
|
||||
this.bus.trigger("rename", renamed, oldPath);
|
||||
}
|
||||
|
||||
on(name: string, handler: (...args: any[]) => void) {
|
||||
return this.bus.on(name, handler);
|
||||
}
|
||||
|
||||
off(name: string, handler: (...args: any[]) => void) {
|
||||
this.bus.off(name, handler);
|
||||
}
|
||||
|
||||
offref(ref: any) {
|
||||
this.bus.offref(ref);
|
||||
}
|
||||
|
||||
trigger(name: string, ...args: any[]) {
|
||||
this.bus.trigger(name, ...args);
|
||||
}
|
||||
|
||||
getConfig(key: string): any {
|
||||
if (key === "tabSize") return 4;
|
||||
if (key === "useTab") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
__listenerCount(): number {
|
||||
return this.bus.listenerCount();
|
||||
}
|
||||
|
||||
__bus(): EventBus {
|
||||
return this.bus;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeMetadataCache {
|
||||
private bus = new EventBus();
|
||||
private cache = new Map<string, any>();
|
||||
|
||||
getFileCache(file: { path: string }): any {
|
||||
return this.cache.get(file.path) ?? null;
|
||||
}
|
||||
|
||||
getCache(path: string): any {
|
||||
return this.cache.get(path) ?? null;
|
||||
}
|
||||
|
||||
__set(path: string, value: any) {
|
||||
this.cache.set(path, value);
|
||||
}
|
||||
|
||||
on(name: string, handler: (...args: any[]) => void) {
|
||||
return this.bus.on(name, handler);
|
||||
}
|
||||
|
||||
off(name: string, handler: (...args: any[]) => void) {
|
||||
this.bus.off(name, handler);
|
||||
}
|
||||
|
||||
offref(ref: any) {
|
||||
this.bus.offref(ref);
|
||||
}
|
||||
|
||||
trigger(name: string, ...args: any[]) {
|
||||
this.bus.trigger(name, ...args);
|
||||
}
|
||||
|
||||
__listenerCount(): number {
|
||||
return this.bus.listenerCount();
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeApp {
|
||||
appId = "test-app-" + Math.random().toString(36).slice(2);
|
||||
vault: FakeVault;
|
||||
metadataCache: FakeMetadataCache;
|
||||
workspace: {
|
||||
on: (n: string, h: (...args: any[]) => void) => any;
|
||||
off: (n: string, h: (...args: any[]) => void) => void;
|
||||
offref: (ref: any) => void;
|
||||
trigger: (n: string, ...args: any[]) => void;
|
||||
onLayoutReady: (cb: () => void) => void;
|
||||
getActiveFile: () => any;
|
||||
getLeaf: () => any;
|
||||
__bus: EventBus;
|
||||
__listenerCount: () => number;
|
||||
};
|
||||
fileManager = {
|
||||
generateMarkdownLink: () => "[[link]]",
|
||||
};
|
||||
plugins = {
|
||||
enabledPlugins: new Set<string>(),
|
||||
plugins: {} as Record<string, any>,
|
||||
};
|
||||
|
||||
constructor(vault: FakeVault, metadataCache: FakeMetadataCache) {
|
||||
this.vault = vault;
|
||||
this.metadataCache = metadataCache;
|
||||
const bus = new EventBus();
|
||||
this.workspace = {
|
||||
on: (n, h) => bus.on(n, h),
|
||||
off: (n, h) => bus.off(n, h),
|
||||
offref: (ref) => bus.offref(ref),
|
||||
trigger: (n, ...args) => bus.trigger(n, ...args),
|
||||
onLayoutReady: (cb) => cb(),
|
||||
getActiveFile: () => null,
|
||||
getLeaf: () => ({ openFile: () => {} }),
|
||||
__bus: bus,
|
||||
__listenerCount: () => bus.listenerCount(),
|
||||
};
|
||||
}
|
||||
|
||||
__totalListenerCount(): number {
|
||||
return (
|
||||
this.workspace.__listenerCount() +
|
||||
this.vault.__listenerCount() +
|
||||
this.metadataCache.__listenerCount()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FakePlugin {
|
||||
app: FakeApp;
|
||||
settings: TaskProgressBarSettings;
|
||||
manifest: { id: string; name: string; version: string };
|
||||
dataflowOrchestrator?: DataflowOrchestrator;
|
||||
getIcsManager(): undefined;
|
||||
saveSettings(): Promise<void>;
|
||||
registerEvent(ref: any): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function buildOrchestrator(
|
||||
opts: BuildOrchestratorOptions = {},
|
||||
): Promise<OrchestratorFixture> {
|
||||
const vault = new FakeVault(opts.files);
|
||||
const metadataCache = new FakeMetadataCache();
|
||||
const app = new FakeApp(vault, metadataCache);
|
||||
|
||||
const settings: TaskProgressBarSettings = {
|
||||
...(DEFAULT_SETTINGS as TaskProgressBarSettings),
|
||||
...(opts.settings ?? {}),
|
||||
};
|
||||
|
||||
const plugin: FakePlugin = {
|
||||
app,
|
||||
settings,
|
||||
manifest: {
|
||||
id: "task-genius-test",
|
||||
name: "Task Genius (test)",
|
||||
version: opts.version ?? "test-1.0.0",
|
||||
},
|
||||
getIcsManager: () => undefined,
|
||||
saveSettings: async () => {},
|
||||
registerEvent: () => {},
|
||||
};
|
||||
|
||||
// Construct the real Orchestrator. Heavy sub-components (workers, ICS source
|
||||
// retry loop, file source) are constructed lazily by their managers and only
|
||||
// fully boot when `initialize()` is called or a parse path runs.
|
||||
const orchestrator = new DataflowOrchestrator(
|
||||
app as any,
|
||||
vault as any,
|
||||
metadataCache as any,
|
||||
plugin,
|
||||
);
|
||||
plugin.dataflowOrchestrator = orchestrator;
|
||||
|
||||
if (opts.initialize) {
|
||||
await orchestrator.initialize();
|
||||
}
|
||||
|
||||
const fx: OrchestratorFixture = {
|
||||
orchestrator,
|
||||
plugin,
|
||||
app,
|
||||
vault,
|
||||
metadataCache,
|
||||
|
||||
async writeFile(path, content) {
|
||||
const existing = vault.getFileByPath(path);
|
||||
if (existing) {
|
||||
await vault.modify(existing, content);
|
||||
} else {
|
||||
await vault.create(path, content);
|
||||
}
|
||||
// Yield to microtasks so any awaiting subscribers progress.
|
||||
await Promise.resolve();
|
||||
},
|
||||
|
||||
async deleteFile(path) {
|
||||
const f = vault.getFileByPath(path);
|
||||
if (f) await vault.delete(f);
|
||||
await Promise.resolve();
|
||||
},
|
||||
|
||||
async createFile(path, content) {
|
||||
await vault.create(path, content);
|
||||
await Promise.resolve();
|
||||
},
|
||||
|
||||
async dispose() {
|
||||
try {
|
||||
await orchestrator.cleanup();
|
||||
} catch (e) {
|
||||
// Surface unexpected cleanup errors so tests can react.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[buildOrchestrator.dispose] cleanup threw:", e);
|
||||
throw e;
|
||||
} finally {
|
||||
plugin.dataflowOrchestrator = undefined;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return fx;
|
||||
}
|
||||
253
src/__tests__/integration/_fixtures/inMemoryStorage.ts
Normal file
253
src/__tests__/integration/_fixtures/inMemoryStorage.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* In-memory Storage test double.
|
||||
*
|
||||
* Implements the public surface of `src/dataflow/persistence/Storage.ts` that the
|
||||
* Orchestrator, Repository, and integration tests actually call. Backed by a Map,
|
||||
* so cache tests don't need to touch IndexedDB / localStorage / localforage.
|
||||
*
|
||||
* Storage in production code is constructed internally by Repository and is not
|
||||
* injectable. This double exists for tests that exercise the cache layer directly
|
||||
* (e.g. cache invariants, scope-map verification) and for any future refactor
|
||||
* that makes Storage injectable. Keep the surface narrow — add methods only when
|
||||
* a test needs them.
|
||||
*/
|
||||
|
||||
import type { Task, TaskCache } from "../../../types/task";
|
||||
import type {
|
||||
RawRecord,
|
||||
ProjectRecord,
|
||||
AugmentedRecord,
|
||||
ConsolidatedRecord,
|
||||
} from "../../../dataflow/persistence/Storage";
|
||||
|
||||
type Namespace = "raw" | "project" | "augmented" | "consolidated";
|
||||
|
||||
const PREFIX: Record<Namespace, string> = {
|
||||
raw: "tasks.raw:",
|
||||
project: "project.data:",
|
||||
augmented: "tasks.augmented:",
|
||||
consolidated: "consolidated:",
|
||||
};
|
||||
|
||||
const META_PREFIX = "meta:";
|
||||
|
||||
export class InMemoryStorage {
|
||||
private map = new Map<string, any>();
|
||||
private currentVersion: string;
|
||||
private schemaVersion: number = 1;
|
||||
|
||||
constructor(version: string = "1.0.0") {
|
||||
this.currentVersion = version;
|
||||
}
|
||||
|
||||
// --- raw ---
|
||||
|
||||
async loadRaw(path: string): Promise<RawRecord | null> {
|
||||
const rec = this.map.get(PREFIX.raw + path) as RawRecord | undefined;
|
||||
if (!rec) return null;
|
||||
if (!this.versionOk(rec)) {
|
||||
this.map.delete(PREFIX.raw + path);
|
||||
return null;
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
async storeRaw(
|
||||
path: string,
|
||||
tasks: Task[],
|
||||
fileContent?: string,
|
||||
mtime?: number,
|
||||
): Promise<void> {
|
||||
const rec: RawRecord = {
|
||||
hash: this.hash(fileContent || tasks),
|
||||
time: Date.now(),
|
||||
version: this.currentVersion,
|
||||
schema: this.schemaVersion,
|
||||
data: tasks,
|
||||
mtime,
|
||||
};
|
||||
this.map.set(PREFIX.raw + path, rec);
|
||||
}
|
||||
|
||||
isRawValid(
|
||||
_path: string,
|
||||
record: RawRecord,
|
||||
fileContent?: string,
|
||||
mtime?: number,
|
||||
): boolean {
|
||||
if (!this.versionOk(record)) return false;
|
||||
if (mtime !== undefined && record.mtime !== undefined && record.mtime !== mtime)
|
||||
return false;
|
||||
if (fileContent && record.hash !== this.hash(fileContent)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- project ---
|
||||
|
||||
async loadProject(path: string): Promise<ProjectRecord | null> {
|
||||
const rec = this.map.get(PREFIX.project + path) as ProjectRecord | undefined;
|
||||
if (!rec) return null;
|
||||
if (!this.versionOk(rec)) {
|
||||
this.map.delete(PREFIX.project + path);
|
||||
return null;
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
async storeProject(
|
||||
path: string,
|
||||
data: { tgProject?: any; enhancedMetadata: Record<string, any> },
|
||||
): Promise<void> {
|
||||
const rec: ProjectRecord = {
|
||||
hash: this.hash(data),
|
||||
time: Date.now(),
|
||||
version: this.currentVersion,
|
||||
schema: this.schemaVersion,
|
||||
data,
|
||||
};
|
||||
this.map.set(PREFIX.project + path, rec);
|
||||
}
|
||||
|
||||
// --- augmented ---
|
||||
|
||||
async loadAugmented(path: string): Promise<AugmentedRecord | null> {
|
||||
const rec = this.map.get(PREFIX.augmented + path) as AugmentedRecord | undefined;
|
||||
if (!rec) return null;
|
||||
if (!this.versionOk(rec)) {
|
||||
this.map.delete(PREFIX.augmented + path);
|
||||
return null;
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
async storeAugmented(path: string, tasks: Task[]): Promise<void> {
|
||||
const rec: AugmentedRecord = {
|
||||
hash: this.hash(tasks),
|
||||
time: Date.now(),
|
||||
version: this.currentVersion,
|
||||
schema: this.schemaVersion,
|
||||
data: tasks,
|
||||
};
|
||||
this.map.set(PREFIX.augmented + path, rec);
|
||||
}
|
||||
|
||||
// --- consolidated ---
|
||||
|
||||
async loadConsolidated(): Promise<ConsolidatedRecord | null> {
|
||||
const rec = this.map.get(PREFIX.consolidated + "taskIndex") as
|
||||
| ConsolidatedRecord
|
||||
| undefined;
|
||||
if (!rec) return null;
|
||||
if (!this.versionOk(rec)) {
|
||||
this.map.delete(PREFIX.consolidated + "taskIndex");
|
||||
return null;
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
async storeConsolidated(taskCache: TaskCache): Promise<void> {
|
||||
const rec: ConsolidatedRecord = {
|
||||
time: Date.now(),
|
||||
version: this.currentVersion,
|
||||
schema: this.schemaVersion,
|
||||
data: taskCache,
|
||||
};
|
||||
this.map.set(PREFIX.consolidated + "taskIndex", rec);
|
||||
}
|
||||
|
||||
// --- meta ---
|
||||
|
||||
async saveMeta<T = any>(key: string, value: T): Promise<void> {
|
||||
this.map.set(META_PREFIX + key, value);
|
||||
}
|
||||
|
||||
async loadMeta<T = any>(key: string): Promise<T | null> {
|
||||
return (this.map.get(META_PREFIX + key) as T) ?? null;
|
||||
}
|
||||
|
||||
// --- listing / lifecycle ---
|
||||
|
||||
async listRawPaths(): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const k of this.map.keys()) {
|
||||
if (k.startsWith(PREFIX.raw)) out.push(k.substring(PREFIX.raw.length));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async listAugmentedPaths(): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const k of this.map.keys()) {
|
||||
if (k.startsWith(PREFIX.augmented))
|
||||
out.push(k.substring(PREFIX.augmented.length));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async clearFile(path: string): Promise<void> {
|
||||
this.map.delete(PREFIX.raw + path);
|
||||
this.map.delete(PREFIX.project + path);
|
||||
this.map.delete(PREFIX.augmented + path);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.map.clear();
|
||||
}
|
||||
|
||||
async clearNamespace(namespace: Namespace): Promise<void> {
|
||||
const prefix = PREFIX[namespace];
|
||||
const toDelete: string[] = [];
|
||||
for (const k of this.map.keys()) {
|
||||
if (k.startsWith(prefix)) toDelete.push(k);
|
||||
}
|
||||
for (const k of toDelete) this.map.delete(k);
|
||||
}
|
||||
|
||||
async getStats(): Promise<{
|
||||
totalKeys: number;
|
||||
byNamespace: Record<string, number>;
|
||||
}> {
|
||||
const byNamespace: Record<string, number> = {
|
||||
raw: 0,
|
||||
project: 0,
|
||||
augmented: 0,
|
||||
consolidated: 0,
|
||||
meta: 0,
|
||||
};
|
||||
for (const k of this.map.keys()) {
|
||||
if (k.startsWith(PREFIX.raw)) byNamespace.raw++;
|
||||
else if (k.startsWith(PREFIX.project)) byNamespace.project++;
|
||||
else if (k.startsWith(PREFIX.augmented)) byNamespace.augmented++;
|
||||
else if (k.startsWith(PREFIX.consolidated)) byNamespace.consolidated++;
|
||||
else if (k.startsWith(META_PREFIX)) byNamespace.meta++;
|
||||
}
|
||||
return { totalKeys: this.map.size, byNamespace };
|
||||
}
|
||||
|
||||
// --- test helpers (not on real Storage) ---
|
||||
|
||||
/** Direct access to underlying map for invariant checks. */
|
||||
__inspect(): ReadonlyMap<string, any> {
|
||||
return this.map;
|
||||
}
|
||||
|
||||
__setVersion(version: string): void {
|
||||
this.currentVersion = version;
|
||||
}
|
||||
|
||||
// --- private ---
|
||||
|
||||
private versionOk(rec: { version?: string; schema?: number }): boolean {
|
||||
return rec.version === this.currentVersion && rec.schema === this.schemaVersion;
|
||||
}
|
||||
|
||||
private hash(content: any): string {
|
||||
const str = JSON.stringify(content);
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (h << 5) - h + str.charCodeAt(i);
|
||||
h = h & h;
|
||||
}
|
||||
return Math.abs(h).toString(16);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue