murashit_codex-panel/tests/features/threads/catalog/thread-catalog.test.ts
murashit 11c233b8fe refactor(runtime): replace executable-bound runtime atomically
Own executable- and vault-bound resources in one disposable runtime.
On executable changes, detach stable view shells, dispose all bound work,
and publish a fully attached replacement runtime.
2026-07-20 17:27:57 +09:00

155 lines
6.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { ObservedPaginatedResultListener, ObservedResultListener } from "../../../../src/app-server/query/observed-result";
import { applyThreadListMutation, type ThreadListMutation } from "../../../../src/app-server/query/thread-list-mutation";
import type { Thread } from "../../../../src/domain/threads/model";
import { createThreadCatalog } from "../../../../src/features/threads/catalog/thread-catalog";
describe("ThreadCatalog", () => {
it("projects rename and archive events through the shared query owner", () => {
const store = catalogStore({
active: [thread("active"), thread("other")],
archived: [thread("archived", true)],
});
const onEventApplied = vi.fn();
const catalog = createThreadCatalog({ store, onEventApplied });
catalog.apply({ type: "thread-renamed", threadId: "active", name: "Renamed" });
catalog.apply({ type: "thread-archived", threadId: "active" });
expect(catalog.activeSnapshot()).toEqual([thread("other")]);
expect(catalog.archivedSnapshot()).toEqual([{ ...thread("active"), name: "Renamed", archived: true }, thread("archived", true)]);
expect(onEventApplied).toHaveBeenCalledTimes(2);
});
it("does not invent an archived record when an archive event lacks a source snapshot", () => {
const store = catalogStore();
const catalog = createThreadCatalog({ store });
catalog.apply({ type: "thread-archived", threadId: "unknown" });
expect(store.appliedMutations).toEqual([
{ kind: "remove", list: "active", threadId: "unknown" },
{ kind: "refresh", list: "archived" },
]);
});
it("moves known restored and unarchived threads between lists", () => {
const store = catalogStore({
active: [thread("active")],
archived: [thread("restored", true), thread("unarchived", true)],
});
const catalog = createThreadCatalog({ store });
catalog.apply({ type: "thread-restored", thread: thread("restored", true) });
catalog.apply({ type: "thread-unarchived", threadId: "unarchived" });
expect(catalog.activeSnapshot()).toEqual([thread("unarchived"), thread("restored"), thread("active")]);
expect(catalog.archivedSnapshot()).toEqual([]);
});
it("patches exact rename and delete facts without inventing unknown thread records", () => {
const store = catalogStore({
active: [thread("active"), thread("kept")],
archived: [thread("deleted", true)],
});
const catalog = createThreadCatalog({ store });
catalog.apply({ type: "thread-renamed", threadId: "missing", name: "Not invented" });
catalog.apply({ type: "thread-deleted", threadId: "deleted" });
expect(catalog.activeSnapshot()).toEqual([thread("active"), thread("kept")]);
expect(catalog.archivedSnapshot()).toEqual([]);
});
it("delegates reads, pagination, and observation without a second projection store", async () => {
const store = catalogStore({ active: [thread("active")], archived: [thread("archived", true)] });
const catalog = createThreadCatalog({ store });
const activeObserver = vi.fn();
const archivedObserver = vi.fn();
expect(await catalog.searchActive()).toEqual([thread("active")]);
expect(await catalog.loadActive()).toEqual([thread("active")]);
expect(await catalog.refreshActive()).toEqual([thread("active")]);
expect(catalog.hasMoreActive()).toBe(false);
expect(await catalog.loadMoreActive()).toEqual([thread("active")]);
expect(await catalog.refreshArchived()).toEqual([thread("archived", true)]);
const unsubscribeActive = catalog.observeActive(activeObserver);
const unsubscribeArchived = catalog.observeArchived(archivedObserver);
expect(activeObserver).toHaveBeenCalledWith({
value: [thread("active")],
error: null,
isFetching: false,
hasMore: false,
isFetchingNextPage: false,
});
expect(archivedObserver).toHaveBeenCalledWith({ value: [thread("archived", true)], error: null, isFetching: false });
unsubscribeActive();
unsubscribeArchived();
});
});
interface CatalogStoreOptions {
readonly active?: readonly Thread[] | null;
readonly archived?: readonly Thread[] | null;
}
function catalogStore(options: CatalogStoreOptions = {}) {
let active = options.active ?? null;
let archived = options.archived ?? null;
const activeObservers = new Set<ObservedPaginatedResultListener<readonly Thread[]>>();
const archivedObservers = new Set<ObservedResultListener<readonly Thread[]>>();
const appliedMutations: ThreadListMutation[] = [];
const applyMutations = (mutations: readonly ThreadListMutation[]): void => {
appliedMutations.push(...mutations);
for (const mutation of mutations) {
if (mutation.list === "active") {
active = applyThreadListMutation(active, mutation);
} else {
archived = applyThreadListMutation(archived, mutation);
}
}
};
return {
appliedMutations,
activeThreadsSnapshot: () => active,
recentActiveThreadsSnapshot: () => active,
archivedThreadsSnapshot: () => archived,
fetchActiveThreadSearchInventory: async () => active ?? [],
fetchActiveThreads: async () => active ?? [],
hasMoreActiveThreads: () => false,
loadMoreActiveThreads: async () => active ?? [],
refreshActiveThreads: async () => active ?? [],
refreshArchivedThreads: async () => archived ?? [],
applyThreadListMutations: applyMutations,
observeActiveThreadsResult: (
observer: ObservedPaginatedResultListener<readonly Thread[]>,
observeOptions: { emitCurrent?: boolean } = {},
) => {
activeObservers.add(observer);
if (observeOptions.emitCurrent ?? true) {
observer({ value: active, error: null, isFetching: false, hasMore: false, isFetchingNextPage: false });
}
return () => activeObservers.delete(observer);
},
observeArchivedThreadsResult: (observer: ObservedResultListener<readonly Thread[]>, observeOptions: { emitCurrent?: boolean } = {}) => {
archivedObservers.add(observer);
if (observeOptions.emitCurrent ?? true) observer({ value: archived, error: null, isFetching: false });
return () => archivedObservers.delete(observer);
},
};
}
function thread(id: string, archived = false, overrides: Partial<Thread> = {}): Thread {
return {
id,
preview: id,
createdAt: 1,
updatedAt: 1,
name: null,
archived,
provenance: { kind: "interactive" },
...overrides,
};
}