Files
makelore/tests/unit/local-image-workspace.test.ts
brother7 be9bc84474
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
合并 AI 设计多会话客户端
整合 Enter 发送与多会话交互,保留服务端持久 Conversation Session,并补齐迁移、回归、Electron E2E 与 canonical 文档。
2026-08-11 16:18:52 +08:00

248 lines
8.5 KiB
TypeScript

import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
getLocalImageWorkspaceDirectory,
isLocalImageWorkspaceDevelopmentEnabled,
LocalImageWorkspace,
LocalImageWorkspaceError,
} from '@electron/image-workspace/local-workspace';
const temporaryDirectories: string[] = [];
function createTemporaryDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), 'niancode-image-workspace-'));
temporaryDirectories.push(directory);
return directory;
}
function createService(userDataDir: string) {
let sequence = 0;
let tick = 0;
return new LocalImageWorkspace({
userDataDir,
now: () => new Date(Date.parse('2026-07-31T10:00:00.000Z') + tick++ * 1_000),
createId: () => String(++sequence),
});
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe('local AI design workspace', () => {
it('routes the default development command through the local adapter', () => {
const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as {
scripts: Record<string, string>;
};
expect(packageJson.scripts.dev).toBe('node scripts/run-local-image-workspace-dev.mjs');
expect(packageJson.scripts['dev:image-workspace:local'])
.toBe('node scripts/run-local-image-workspace-dev.mjs');
});
it('never enables the local adapter in packaged apps or explicit cloud mode', () => {
expect(isLocalImageWorkspaceDevelopmentEnabled({
isPackaged: false,
configuredMode: 'local',
})).toBe(true);
expect(isLocalImageWorkspaceDevelopmentEnabled({
isPackaged: false,
isDevelopmentServer: true,
})).toBe(true);
expect(isLocalImageWorkspaceDevelopmentEnabled({
isPackaged: false,
configuredMode: 'cloud',
isDevelopmentServer: true,
})).toBe(false);
expect(isLocalImageWorkspaceDevelopmentEnabled({
isPackaged: true,
configuredMode: 'local',
})).toBe(false);
});
it('persists titled Workspaces and keeps creation idempotent by client key', async () => {
const userDataDir = createTemporaryDirectory();
const service = createService(userDataDir);
await expect(service.bootstrap()).resolves.toMatchObject({ workspaces: [] });
const created = await service.createWorkspace({
clientWorkspaceId: 'client-one',
title: ' 本地角色设计 ',
});
const replay = await service.createWorkspace({
clientWorkspaceId: 'client-one',
title: '不能覆盖标题',
});
expect(replay.workspaceId).toBe(created.workspaceId);
expect(replay.title).toBe('本地角色设计');
await service.renameWorkspace({
workspaceId: created.workspaceId,
title: '角色设计第二版',
});
const reloaded = new LocalImageWorkspace({ userDataDir });
await expect(reloaded.bootstrap()).resolves.toMatchObject({
workspaces: [{ title: '角色设计第二版', conversationCount: 1, viewRevision: 1 }],
});
});
it('migrates a legacy single conversation into schema v3 without losing project tasks', async () => {
const userDataDir = createTemporaryDirectory();
const rootDirectory = getLocalImageWorkspaceDirectory(userDataDir);
const timestamp = '2026-07-31T10:00:00.000Z';
mkdirSync(rootDirectory, { recursive: true });
writeFileSync(join(rootDirectory, 'design-workspace-v2.json'), JSON.stringify({
schemaVersion: 2,
workspaces: [{
workspaceId: 'workspace-legacy',
clientWorkspaceId: 'client-legacy',
title: '历史设计项目',
turnRevision: 2,
viewRevision: 4,
phase: 'awaiting_confirmation',
brief: {
version: 2,
status: 'ready',
medium: 'image',
summary: '历史海报',
ready: true,
missingDecision: null,
},
messages: [{
id: 'message-legacy',
role: 'user',
kind: 'user',
text: '保留这条历史消息',
quickReplies: [],
generationQuote: null,
turnRevision: 1,
createdAt: timestamp,
}],
requestHashes: { 'turn-legacy': 'hash-legacy' },
updatedAt: timestamp,
}],
tasksByWorkspaceId: {
'workspace-legacy': [{
taskId: 'task-legacy',
workspaceId: 'workspace-legacy',
medium: 'image',
status: 'succeeded',
briefVersion: 2,
briefSummary: '历史海报',
quoteId: null,
quotedDesignPoints: null,
failureCode: null,
resultAssets: [],
createdAt: timestamp,
updatedAt: timestamp,
}],
},
assetsById: {},
}), 'utf8');
const service = createService(userDataDir);
const workspace = await service.getWorkspace('workspace-legacy');
const migratedConversation = workspace.conversations[0];
expect(workspace).toMatchObject({
workspaceId: 'workspace-legacy',
conversationCount: 1,
conversations: [{ title: '历史会话', turnRevision: 2 }],
});
await expect(service.getConversation(
'workspace-legacy',
migratedConversation.conversationId,
)).resolves.toMatchObject({
messages: [{ text: '保留这条历史消息' }],
});
await expect(service.listTasks('workspace-legacy')).resolves.toMatchObject([{
taskId: 'task-legacy',
conversationId: migratedConversation.conversationId,
}]);
expect(existsSync(join(rootDirectory, 'design-workspace-v3.json'))).toBe(true);
});
it('creates a Quote first and a visible generation task only after confirmation', async () => {
const service = createService(createTemporaryDirectory());
const created = await service.createWorkspace({
clientWorkspaceId: 'client-flow',
title: '海洋公益海报',
});
const conversationId = created.conversations[0].conversationId;
const discussed = await service.submitMessage({
workspaceId: created.workspaceId,
conversationId,
clientTurnId: 'turn-one',
expectedTurnRevision: 0,
message: '做一张保护海洋的竖版公益海报',
});
const quote = discussed.messages.at(-1)?.generationQuote;
expect(discussed.phase).toBe('awaiting_confirmation');
expect(quote).toMatchObject({ status: 'active', medium: 'image' });
await expect(service.listTasks(created.workspaceId)).resolves.toEqual([]);
const confirmed = await service.confirmGeneration({
workspaceId: created.workspaceId,
conversationId,
clientTurnId: 'turn-two',
expectedTurnRevision: 1,
quoteId: quote!.quoteId,
});
expect(confirmed.turnRevision).toBe(2);
const queued = await service.listTasks(created.workspaceId);
const running = await service.listTasks(created.workspaceId);
const succeeded = await service.listTasks(created.workspaceId);
expect(queued[0].status).toBe('queued');
expect(running[0].status).toBe('running');
expect(succeeded[0]).toMatchObject({
status: 'succeeded',
resultAssets: [{ mimeType: 'image/svg+xml' }],
});
const asset = succeeded[0].resultAssets[0];
const response = await service.openAssetContent(created.workspaceId, asset.assetId);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('image/svg+xml');
expect(await response.text()).toContain('AI Design Workspace');
});
it('enforces turn CAS and removes only the isolated development data on reset', async () => {
const userDataDir = createTemporaryDirectory();
const service = createService(userDataDir);
const created = await service.createWorkspace({
clientWorkspaceId: 'client-reset',
title: '可重置项目',
});
const conversationId = created.conversations[0].conversationId;
await expect(service.submitMessage({
workspaceId: created.workspaceId,
conversationId,
clientTurnId: 'stale-turn',
expectedTurnRevision: 3,
message: '测试',
})).rejects.toMatchObject<LocalImageWorkspaceError>({
status: 409,
code: 'conversation_revision_conflict',
});
expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(true);
await expect(service.reset()).resolves.toMatchObject({ workspaces: [] });
expect(existsSync(getLocalImageWorkspaceDirectory(userDataDir))).toBe(false);
});
});