Files
openchamber/packages/ui/src/stores/useProjectContextStore.test.ts
T

459 lines
16 KiB
TypeScript
Raw Normal View History

import { beforeEach, describe, expect, mock, test } from 'bun:test';
interface NotePayload {
id: string;
body: string;
createdAt: number;
updatedAt: number;
source: 'manual' | 'selection' | 'agent';
pinned: boolean;
}
interface ContextPayload {
notes: NotePayload[];
todos: { id: string; text: string; completed: boolean; createdAt: number }[];
plans: { id: string; file: string; title: string; createdAt: number; pinned: boolean }[];
}
const emptyPayload = (): ContextPayload => ({ notes: [], todos: [], plans: [] });
const note = (overrides: Partial<NotePayload> = {}): NotePayload => ({
id: 'n1',
body: 'body',
createdAt: 1,
updatedAt: 1,
source: 'manual',
pinned: false,
...overrides,
});
const planLink = (overrides: Partial<ContextPayload['plans'][number]> = {}) => ({
id: 'p1',
file: 'a.md',
title: 'A',
createdAt: 1,
pinned: false,
...overrides,
});
// The UI tsconfig does not load bun's test globals, so these tests follow the
// local precedent of swapping plain handlers instead of using mock helpers.
const handlers = {
fetch: async (): Promise<ContextPayload> => emptyPayload(),
saveTodos: async (todos: ContextPayload['todos']): Promise<ContextPayload> => ({
notes: [],
todos,
plans: [],
}),
createNote: async (): Promise<{ note: NotePayload; context: ContextPayload }> => ({
note: note(),
context: { notes: [note()], todos: [], plans: [] },
}),
updateNote: async (): Promise<NotePayload | null> => note(),
deleteNote: async (): Promise<ContextPayload> => emptyPayload(),
create: async (): Promise<{ plan: ContextPayload['plans'][number]; context: ContextPayload }> => ({
plan: planLink(),
context: { notes: [], todos: [], plans: [planLink()] },
}),
update: async (): Promise<{ plan: ContextPayload['plans'][number]; raw: string } | null> => ({
plan: planLink(),
raw: '# A',
}),
pinPlan: async (): Promise<ContextPayload['plans'][number] | null> => planLink({ pinned: true }),
remove: async (): Promise<ContextPayload> => emptyPayload(),
};
const calls = { fetch: 0, saveTodos: 0, createNote: 0, updateNote: 0, deleteNote: 0, create: 0, update: 0, pinPlan: 0, remove: 0 };
mock.module('@/lib/projectContextApi', () => ({
fetchProjectContext: () => {
calls.fetch += 1;
return handlers.fetch();
},
saveProjectTodos: (_project: unknown, todos: ContextPayload['todos']) => {
calls.saveTodos += 1;
return handlers.saveTodos(todos);
},
createProjectNote: () => {
calls.createNote += 1;
return handlers.createNote();
},
updateProjectNote: () => {
calls.updateNote += 1;
return handlers.updateNote();
},
deleteProjectNote: () => {
calls.deleteNote += 1;
return handlers.deleteNote();
},
setProjectPlanPinned: () => {
calls.pinPlan += 1;
return handlers.pinPlan();
},
createProjectPlan: () => {
calls.create += 1;
return handlers.create();
},
updateProjectPlan: () => {
calls.update += 1;
return handlers.update();
},
deleteProjectPlan: () => {
calls.remove += 1;
return handlers.remove();
},
resolveProjectContextId: (project: { path?: string } | null | undefined) => (
project?.path ? `path_${project.path}` : ''
),
}));
const { useProjectContextStore } = await import('./useProjectContextStore');
const PROJECT = { id: 'ignored', path: '/repo' };
const store = () => useProjectContextStore.getState();
const entry = () => store().getEntry(PROJECT);
const deferred = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => { resolve = res; });
return { promise, resolve };
};
const failWith = (message: string) => async (): Promise<never> => {
throw new Error(message);
};
beforeEach(() => {
store().reset();
calls.fetch = 0;
calls.saveTodos = 0;
calls.createNote = 0;
calls.updateNote = 0;
calls.deleteNote = 0;
calls.create = 0;
calls.update = 0;
calls.pinPlan = 0;
calls.remove = 0;
handlers.fetch = async () => emptyPayload();
handlers.saveTodos = async (todos) => ({ notes: [], todos, plans: [] });
handlers.createNote = async () => ({ note: note(), context: { notes: [note()], todos: [], plans: [] } });
handlers.updateNote = async () => note();
handlers.deleteNote = async () => emptyPayload();
handlers.create = async () => ({ plan: planLink(), context: { notes: [], todos: [], plans: [planLink()] } });
handlers.update = async () => ({ plan: planLink(), raw: '# A' });
handlers.pinPlan = async () => planLink({ pinned: true });
handlers.remove = async () => emptyPayload();
});
describe('getEntry', () => {
test('returns a stable empty entry for an unknown project', () => {
expect(entry()).toEqual({ notes: [], todos: [], plans: [], loaded: false, loading: false, error: null });
});
test('returns the empty entry for a project without a path', () => {
expect(store().getEntry({ id: 'x', path: '' }).loaded).toBe(false);
});
});
describe('load', () => {
test('populates from the server', async () => {
handlers.fetch = async () => ({
notes: [note({ body: 'server note' })],
todos: [{ id: 't1', text: 'a', completed: false, createdAt: 1 }],
plans: [planLink()],
});
await store().load(PROJECT);
expect(entry().notes.map((entryNote) => entryNote.body)).toEqual(['server note']);
expect(entry().todos).toHaveLength(1);
expect(entry().plans).toHaveLength(1);
expect(entry().loaded).toBe(true);
expect(entry().error).toBeNull();
});
test('does not refetch once loaded', async () => {
await store().load(PROJECT);
await store().load(PROJECT);
expect(calls.fetch).toBe(1);
});
test('refetches when forced', async () => {
await store().load(PROJECT);
await store().load(PROJECT, { force: true });
expect(calls.fetch).toBe(2);
});
test('a failed load preserves previously loaded data instead of clearing it', async () => {
handlers.fetch = async () => ({ notes: [note({ body: 'kept' })], todos: [], plans: [] });
await store().load(PROJECT);
handlers.fetch = failWith('offline');
await store().load(PROJECT, { force: true });
expect(entry().notes.map((entryNote) => entryNote.body)).toEqual(['kept']);
expect(entry().loaded).toBe(true);
expect(entry().error).toBe('offline');
});
test('a first-load failure reports the error and stays unloaded', async () => {
handlers.fetch = failWith('boom');
await store().load(PROJECT);
expect(entry().loaded).toBe(false);
expect(entry().notes).toEqual([]);
expect(entry().error).toBe('boom');
});
test('concurrent loads issue a single request', async () => {
await Promise.all([store().load(PROJECT), store().load(PROJECT), store().load(PROJECT)]);
expect(calls.fetch).toBe(1);
});
});
describe('saveTodos', () => {
test('applies optimistically before the request resolves', async () => {
const gate = deferred<ContextPayload>();
handlers.saveTodos = () => gate.promise;
const pending = store().saveTodos(PROJECT, [{ id: 't1', text: 'typed', completed: false, createdAt: 1 }]);
expect(entry().todos).toHaveLength(1);
gate.resolve({ notes: [], todos: [{ id: 't1', text: 'typed', completed: false, createdAt: 1 }], plans: [] });
expect(await pending).toBe(true);
expect(entry().todos).toHaveLength(1);
});
test('rolls back and reports the error on failure', async () => {
await store().saveTodos(PROJECT, [{ id: 't1', text: 'original', completed: false, createdAt: 1 }]);
handlers.saveTodos = failWith('disk full');
expect(await store().saveTodos(PROJECT, [])).toBe(false);
expect(entry().todos.map((todo) => todo.text)).toEqual(['original']);
expect(entry().error).toBe('disk full');
});
test('serializes concurrent writes in call order', async () => {
const order: string[] = [];
handlers.saveTodos = async (todos) => {
const label = todos[0]?.text ?? 'empty';
order.push(`start:${label}`);
await new Promise((resolve) => setTimeout(resolve, 5));
order.push(`end:${label}`);
return { notes: [], todos, plans: [] };
};
await Promise.all([
store().saveTodos(PROJECT, [{ id: '1', text: 'first', completed: false, createdAt: 1 }]),
store().saveTodos(PROJECT, [{ id: '2', text: 'second', completed: false, createdAt: 2 }]),
]);
expect(order).toEqual(['start:first', 'end:first', 'start:second', 'end:second']);
});
test('a load resolving during an in-flight write does not clobber it', async () => {
const gate = deferred<ContextPayload>();
handlers.saveTodos = () => gate.promise;
handlers.fetch = async () => ({
notes: [note({ body: 'from server' })],
todos: [{ id: 'stale', text: 'stale', completed: false, createdAt: 0 }],
plans: [],
});
const pending = store().saveTodos(PROJECT, [{ id: 'local', text: 'local', completed: false, createdAt: 1 }]);
await store().load(PROJECT);
expect(entry().todos.map((todo) => todo.id)).toEqual(['local']);
// The same snapshot still delivers the fields the write did not touch.
expect(entry().notes.map((entryNote) => entryNote.body)).toEqual(['from server']);
gate.resolve({ notes: [], todos: [{ id: 'local', text: 'local', completed: false, createdAt: 1 }], plans: [] });
await pending;
});
test('ignores a project without a resolvable path', async () => {
expect(await store().saveTodos({ id: 'x', path: '' }, [])).toBe(false);
expect(calls.saveTodos).toBe(0);
});
});
describe('notes', () => {
test('createNote adopts the committed list', async () => {
handlers.createNote = async () => ({
note: note({ id: 'n9', body: 'fresh' }),
context: { notes: [note({ id: 'n9', body: 'fresh' })], todos: [], plans: [] },
});
const created = await store().createNote(PROJECT, { body: 'fresh' });
expect(created?.id).toBe('n9');
expect(entry().notes.map((entryNote) => entryNote.id)).toEqual(['n9']);
});
test('createNote refuses a whitespace-only body without calling the server', async () => {
expect(await store().createNote(PROJECT, { body: ' ' })).toBeNull();
expect(calls.createNote).toBe(0);
});
test('createNote reports failure without inserting a placeholder row', async () => {
handlers.createNote = failWith('no space');
expect(await store().createNote(PROJECT, { body: 'x' })).toBeNull();
expect(entry().notes).toEqual([]);
expect(entry().error).toBe('no space');
});
test('saveNoteBody applies optimistically and commits the server copy', async () => {
await store().createNote(PROJECT, { body: 'before' });
handlers.updateNote = async () => note({ body: 'after', updatedAt: 9 });
expect(await store().saveNoteBody(PROJECT, 'n1', 'after')).toBe(true);
expect(entry().notes[0].body).toBe('after');
expect(entry().notes[0].updatedAt).toBe(9);
});
test('saveNoteBody rolls back on failure', async () => {
await store().createNote(PROJECT, { body: 'before' });
handlers.updateNote = failWith('read only');
expect(await store().saveNoteBody(PROJECT, 'n1', 'after')).toBe(false);
expect(entry().notes[0].body).toBe('body');
expect(entry().error).toBe('read only');
});
test('saveNoteBody drops a note the server reports as gone', async () => {
await store().createNote(PROJECT, { body: 'before' });
handlers.updateNote = async () => null;
expect(await store().saveNoteBody(PROJECT, 'n1', 'after')).toBe(false);
expect(entry().notes).toEqual([]);
});
test('setNotePinned applies optimistically', async () => {
await store().createNote(PROJECT, { body: 'x' });
const gate = deferred<NotePayload | null>();
handlers.updateNote = () => gate.promise;
const pending = store().setNotePinned(PROJECT, 'n1', true);
expect(entry().notes[0].pinned).toBe(true);
gate.resolve(note({ pinned: true }));
expect(await pending).toBe(true);
});
test('setNotePinned rolls back on failure', async () => {
await store().createNote(PROJECT, { body: 'x' });
handlers.updateNote = failWith('locked');
expect(await store().setNotePinned(PROJECT, 'n1', true)).toBe(false);
expect(entry().notes[0].pinned).toBe(false);
});
test('deleteNote removes optimistically and restores on failure', async () => {
await store().createNote(PROJECT, { body: 'x' });
handlers.deleteNote = failWith('busy');
expect(await store().deleteNote(PROJECT, 'n1')).toBe(false);
expect(entry().notes.map((entryNote) => entryNote.id)).toEqual(['n1']);
expect(entry().error).toBe('busy');
});
test('deleteNote commits the server list on success', async () => {
await store().createNote(PROJECT, { body: 'x' });
expect(await store().deleteNote(PROJECT, 'n1')).toBe(true);
expect(entry().notes).toEqual([]);
});
});
describe('plans', () => {
test('createPlan commits the server context', async () => {
const plan = await store().createPlan(PROJECT, { title: 'A', body: 'x' });
expect(plan?.id).toBe('p1');
expect(entry().plans.map((item) => item.id)).toEqual(['p1']);
});
test('createPlan reports failure without inserting a placeholder row', async () => {
handlers.create = failWith('no space');
expect(await store().createPlan(PROJECT, { title: 'A', body: 'x' })).toBeNull();
expect(entry().plans).toEqual([]);
expect(entry().error).toBe('no space');
});
test('deletePlan removes optimistically', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
const gate = deferred<ContextPayload>();
handlers.remove = () => gate.promise;
const pending = store().deletePlan(PROJECT, 'p1');
expect(entry().plans).toEqual([]);
gate.resolve(emptyPayload());
expect(await pending).toBe(true);
});
test('savePlan folds the refreshed title back into the list', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.update = async () => ({ plan: planLink({ title: 'Renamed' }), raw: '# Renamed' });
expect(await store().savePlan(PROJECT, 'p1', '# Renamed')).toBe(true);
expect(entry().plans[0].title).toBe('Renamed');
});
test('savePlan drops a plan the server reports as gone', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.update = async () => null;
expect(await store().savePlan(PROJECT, 'p1', '# X')).toBe(false);
expect(entry().plans).toEqual([]);
});
test('savePlan keeps the row and reports the error when the request fails', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.update = failWith('read only');
expect(await store().savePlan(PROJECT, 'p1', '# X')).toBe(false);
expect(entry().plans.map((item) => item.id)).toEqual(['p1']);
expect(entry().error).toBe('read only');
});
test('setPlanPinned applies optimistically and rolls back on failure', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.pinPlan = failWith('locked');
expect(await store().setPlanPinned(PROJECT, 'p1', true)).toBe(false);
expect(entry().plans[0].pinned).toBe(false);
expect(entry().error).toBe('locked');
});
test('setPlanPinned commits the server copy', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
expect(await store().setPlanPinned(PROJECT, 'p1', true)).toBe(true);
expect(entry().plans[0].pinned).toBe(true);
});
test('deletePlan restores the row when the request fails', async () => {
await store().createPlan(PROJECT, { title: 'A', body: 'x' });
handlers.remove = failWith('locked');
expect(await store().deletePlan(PROJECT, 'p1')).toBe(false);
expect(entry().plans.map((item) => item.id)).toEqual(['p1']);
expect(entry().error).toBe('locked');
});
});
describe('reset', () => {
test('drops every cached project', async () => {
await store().load(PROJECT);
expect(entry().loaded).toBe(true);
store().reset();
expect(entry().loaded).toBe(false);
});
});