Files
makelore/tests/unit/coding-workspace-store.test.ts

309 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { createCodingWorkspaceStore } from '@/stores/coding-workspace';
import type {
CodingConversationMetadata,
CodingProjectAgent,
CodingProjectConfig,
CodingProjectSummary,
} from '@/types/coding-project';
const project: CodingProjectSummary = {
id: 'project-1',
name: 'Local project',
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
lastOpenedAt: '2026-08-23T00:00:00.000Z',
};
function agent(id: string, overrides: Partial<CodingProjectAgent> = {}): CodingProjectAgent {
return {
id,
avatarId: 'default',
roleName: '伙伴',
name: id,
builtIn: false,
enabled: true,
skillIds: [],
responsibility: {
mission: '协助当前项目',
owns: [],
boundaries: [],
collaborators: [],
principles: [],
},
prompt: '',
archivedAt: null,
pinned: false,
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
model: null,
modelResolution: 'default',
...overrides,
};
}
function config(agents: CodingProjectAgent[]): CodingProjectConfig {
return {
schemaVersion: 2,
projectType: 'custom',
initialized: true,
agents,
knowledgeDirectory: 'knowledge',
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
};
}
function conversation(id: string, agentId: string): CodingConversationMetadata {
return {
id,
agentId,
title: '新对话',
archivedAt: null,
unread: false,
createdAt: '2026-08-23T00:00:00.000Z',
updatedAt: '2026-08-23T00:00:00.000Z',
model: null,
modelResolution: 'default',
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
describe('coding workspace store', () => {
it('discards a stale list read and refreshes after a manual rename', async () => {
const old = conversation('conversation-a', 'agent-a');
const stale = deferred<CodingConversationMetadata[]>();
const latest = { ...old, title: '手动标题' };
const listConversations = vi.fn().mockResolvedValueOnce([old])
.mockReturnValueOnce(stale.promise).mockResolvedValue([latest]);
const store = createCodingWorkspaceStore({
listProjects: async () => ({ projects: [project], activeProjectId: project.id }),
getConfig: async () => ({ project, config: config([agent('agent-a')]) }),
listConversations, patchConversation: async () => latest,
});
await store.getState().load();
const refresh = store.getState().refreshConversations(project.id);
await vi.waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2));
await store.getState().patchConversation(old.id, { title: latest.title });
stale.resolve([old]);
await refresh;
expect(store.getState().conversations[0].title).toBe(latest.title);
expect(listConversations).toHaveBeenCalledTimes(3);
expect(store.getState().loadState).toBe('ready');
});
it('does a follow-up read when metadata changes during an in-flight refresh', async () => {
const old = conversation('conversation-a', 'agent-a');
const stale = deferred<CodingConversationMetadata[]>();
const latest = { ...old, archivedAt: '2026-09-20T00:00:00Z' };
const listConversations = vi.fn().mockResolvedValueOnce([old])
.mockReturnValueOnce(stale.promise).mockResolvedValue([latest]);
const store = createCodingWorkspaceStore({
listProjects: async () => ({ projects: [project], activeProjectId: project.id }),
getConfig: async () => ({ project, config: config([agent('agent-a')]) }), listConversations,
});
await store.getState().load();
const refresh = store.getState().refreshConversations(project.id);
await vi.waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2));
const followup = store.getState().refreshConversations(project.id);
stale.resolve([old]);
await Promise.all([refresh, followup]);
expect(store.getState().conversations).toEqual([latest]);
});
it('loads local project metadata and selects the pinned Agent without touching runtime APIs', async () => {
const listProjects = vi.fn(async () => ({ projects: [project], activeProjectId: project.id }));
const getConfig = vi.fn(async () => ({
project,
config: config([agent('agent-a'), agent('agent-b', { pinned: true })]),
}));
const listConversations = vi.fn(async () => [conversation('conversation-b', 'agent-b')]);
const createConversation = vi.fn();
const store = createCodingWorkspaceStore({
listProjects,
getConfig,
listConversations,
createConversation,
});
await store.getState().load();
expect(store.getState()).toMatchObject({
activeProjectId: 'project-1',
selectedAgentId: 'agent-b',
loadState: 'ready',
conversations: [{ id: 'conversation-b' }],
});
expect(createConversation).not.toHaveBeenCalled();
});
it('passes the explicit identity choice through project creation', async () => {
const createProject = vi.fn(async () => ({
project,
config: config([agent('agent-a')]),
knowledgeFiles: [],
}));
const store = createCodingWorkspaceStore({
listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })),
getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })),
listConversations: vi.fn(async () => []),
createProject,
});
await expect(store.getState().createProject({
projectPath: 'C:/projects/new',
projectType: 'custom',
identity: { kind: 'bind', projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' },
})).resolves.toEqual(project);
expect(createProject).toHaveBeenCalledWith({
projectPath: 'C:/projects/new',
projectType: 'custom',
identity: { kind: 'bind', projectId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' },
});
});
it('creates a missing first Conversation once for concurrent callers', async () => {
let resolveCreate!: (value: CodingConversationMetadata) => void;
const createFlight = new Promise<CodingConversationMetadata>((resolve) => {
resolveCreate = resolve;
});
const createConversation = vi.fn(() => createFlight);
const store = createCodingWorkspaceStore({
listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })),
getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })),
listConversations: vi.fn(async () => []),
createConversation,
});
await store.getState().load();
const first = store.getState().ensureConversation('agent-a');
const second = store.getState().ensureConversation('agent-a');
expect(createConversation).toHaveBeenCalledTimes(1);
expect(store.getState().creatingAgentIds).toEqual({ 'agent-a': true });
const created = conversation('conversation-a', 'agent-a');
resolveCreate(created);
await expect(Promise.all([first, second])).resolves.toEqual([created, created]);
expect(store.getState().conversations).toEqual([created]);
expect(store.getState().creatingAgentIds).toEqual({});
});
it('keeps title, archive, and unread metadata scoped to the patched Conversation', async () => {
const first = conversation('conversation-a', 'agent-a');
const second = conversation('conversation-b', 'agent-a');
const patchConversation = vi.fn(async (_id: string, patch: { title?: string; archived?: boolean; unread?: boolean }) => ({
...first,
...(patch.title ? { title: patch.title } : {}),
...(patch.archived ? { archivedAt: '2026-08-24T01:00:00.000Z' } : {}),
...(patch.unread !== undefined ? { unread: patch.unread } : {}),
}));
const store = createCodingWorkspaceStore({
listProjects: vi.fn(async () => ({ projects: [project], activeProjectId: project.id })),
getConfig: vi.fn(async () => ({ project, config: config([agent('agent-a')]) })),
listConversations: vi.fn(async () => [first, second]),
createConversation: vi.fn(),
patchConversation,
});
await store.getState().load();
await store.getState().patchConversation(first.id, {
title: 'Feature UI',
archived: true,
unread: true,
});
expect(patchConversation).toHaveBeenCalledWith(first.id, {
title: 'Feature UI',
archived: true,
unread: true,
});
expect(store.getState().conversations).toEqual([
expect.objectContaining({ id: first.id, title: 'Feature UI', unread: true, archivedAt: expect.any(String) }),
second,
]);
});
it('does not write an old project metadata result into the newly active project', async () => {
const secondProject = { ...project, id: 'project-2', name: 'Second project' };
const firstConversation = conversation('conversation-a', 'agent-a');
const secondConversation = conversation('conversation-b', 'agent-b');
const patchFlight = deferred<CodingConversationMetadata>();
let activeProject = project;
const store = createCodingWorkspaceStore({
listProjects: vi.fn(async () => ({
projects: [project, secondProject],
activeProjectId: activeProject.id,
})),
getConfig: vi.fn(async (projectId: string) => (
projectId === project.id
? { project, config: config([agent('agent-a')]) }
: { project: secondProject, config: config([agent('agent-b')]) }
)),
listConversations: vi.fn(async (projectId: string) => (
projectId === project.id ? [firstConversation] : [secondConversation]
)),
createConversation: vi.fn(),
patchConversation: vi.fn(() => patchFlight.promise),
});
await store.getState().load();
const pendingPatch = store.getState().patchConversation(firstConversation.id, { title: 'Old project title' });
activeProject = secondProject;
await store.getState().load();
patchFlight.resolve({ ...firstConversation, title: 'Old project title' });
await pendingPatch;
expect(store.getState()).toMatchObject({
activeProjectId: secondProject.id,
conversations: [secondConversation],
});
});
it('keeps a metadata rejection on its source project and Conversation', async () => {
const secondProject = { ...project, id: 'project-2', name: 'Second project' };
const firstConversation = conversation('conversation-a', 'agent-a');
const secondConversation = conversation('conversation-b', 'agent-b');
const patchFlight = deferred<CodingConversationMetadata>();
let activeProject = project;
const store = createCodingWorkspaceStore({
listProjects: vi.fn(async () => ({
projects: [project, secondProject],
activeProjectId: activeProject.id,
})),
getConfig: vi.fn(async (projectId: string) => (
projectId === project.id
? { project, config: config([agent('agent-a')]) }
: { project: secondProject, config: config([agent('agent-b')]) }
)),
listConversations: vi.fn(async (projectId: string) => (
projectId === project.id ? [firstConversation] : [secondConversation]
)),
createConversation: vi.fn(),
patchConversation: vi.fn(() => patchFlight.promise),
});
await store.getState().load();
const pendingPatch = store.getState().patchConversation(firstConversation.id, { title: 'Rejected title' });
const rejection = expect(pendingPatch).rejects.toThrow('metadata rejected');
activeProject = secondProject;
await store.getState().load();
patchFlight.reject(new Error('metadata rejected'));
await rejection;
expect(store.getState().error).toBeNull();
expect(store.getState().conversationErrorsByProjectId).toEqual({
[project.id]: { [firstConversation.id]: 'metadata rejected' },
});
expect(store.getState().conversationErrorsByProjectId[secondProject.id]).toBeUndefined();
});
});