test(web): add typed project settings errors and frozen mode

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-14 06:48:14 +00:00
co-authored by Cursor
parent 94065976a5
commit 1b0f99a455
4 changed files with 318 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
ProjectSettingsApiError,
parseProjectSettingsErrorBody,
projectSettingsErrorFromResponse,
recoverFromProjectSettingsError,
retainDraftOnFrozenConflict,
} from './projectSettingsError';
describe('projectSettingsError', () => {
it('keeps status, code, message and issues on PLAN_FROZEN', () => {
const err = projectSettingsErrorFromResponse(
409,
JSON.stringify({ error: 'PLAN_FROZEN', message: '工作流已激活,规划已冻结' }),
'保存工作流规划失败: 409',
);
assert.equal(err instanceof ProjectSettingsApiError, true);
assert.equal(err.status, 409);
assert.equal(err.code, 'PLAN_FROZEN');
assert.equal(err.message, '工作流已激活,规划已冻结');
assert.deepEqual(err.issues, []);
assert.equal(recoverFromProjectSettingsError(err), 'refresh_and_freeze');
});
it('maps revision and concurrent conflicts to distinct recovery', () => {
assert.equal(
recoverFromProjectSettingsError({ code: 'PLAN_REVISION_CONFLICT', status: 409 }),
'reload_revision',
);
assert.equal(
recoverFromProjectSettingsError({ code: 'ERR_CONCURRENT_SWITCH', status: 409 }),
'retry_after_switch',
);
});
it('joins validation issues but still keeps typed codes', () => {
const body = parseProjectSettingsErrorBody(
JSON.stringify({
error: 'VALIDATION_FAILED',
message: '工作流配置校验未通过',
issues: [{ code: 'DUTY_PARTIAL_ASSIGNMENT', field: 'intake.duties', message: '不能只指派部分职责' }],
}),
);
assert.equal(body.code, 'VALIDATION_FAILED');
assert.equal(body.message, '不能只指派部分职责');
assert.equal(body.issues[0]?.code, 'DUTY_PARTIAL_ASSIGNMENT');
});
it('retains the local draft when a late tab hits PLAN_FROZEN', () => {
const draft = { workflow_definition_key: 'main', workflow_version: 1, stages: [] };
assert.equal(retainDraftOnFrozenConflict(draft), draft);
});
});
+83
View File
@@ -0,0 +1,83 @@
export type ProjectSettingsIssue = {
code?: string;
field?: string;
message?: string;
};
export type ProjectSettingsErrorRecovery =
| 'refresh_and_freeze'
| 'reload_revision'
| 'retry_after_switch'
| 'show_message';
export class ProjectSettingsApiError extends Error {
readonly status: number;
readonly code: string;
readonly issues: ProjectSettingsIssue[];
constructor(input: {
status: number;
code: string;
message: string;
issues?: ProjectSettingsIssue[];
}) {
super(input.message);
this.name = 'ProjectSettingsApiError';
this.status = input.status;
this.code = input.code;
this.issues = input.issues ?? [];
}
}
export function parseProjectSettingsErrorBody(text: string): {
message: string;
code: string;
issues: ProjectSettingsIssue[];
} {
try {
const parsed = JSON.parse(text) as {
error?: string;
message?: string;
issues?: ProjectSettingsIssue[];
};
const issues = Array.isArray(parsed.issues) ? parsed.issues : [];
const issueText = issues
.map((issue) => issue.message)
.filter((msg): msg is string => Boolean(msg))
.join(';');
return {
code: String(parsed.error || ''),
message: issueText || parsed.message || parsed.error || text || '请求失败',
issues,
};
} catch {
return { code: '', message: text || '请求失败', issues: [] };
}
}
export function projectSettingsErrorFromResponse(
status: number,
text: string,
fallback: string,
): ProjectSettingsApiError {
const parsed = parseProjectSettingsErrorBody(text);
return new ProjectSettingsApiError({
status,
code: parsed.code,
message: parsed.message || fallback,
issues: parsed.issues,
});
}
export function recoverFromProjectSettingsError(
err: Pick<ProjectSettingsApiError, 'code' | 'status'>,
): ProjectSettingsErrorRecovery {
if (err.code === 'PLAN_FROZEN') return 'refresh_and_freeze';
if (err.code === 'PLAN_REVISION_CONFLICT') return 'reload_revision';
if (err.code === 'ERR_CONCURRENT_SWITCH') return 'retry_after_switch';
return 'show_message';
}
export function retainDraftOnFrozenConflict<T>(draft: T): T {
return draft;
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
afterFrozenConflictReload,
frozenDutySnapshot,
projectSettingsChrome,
resolveProjectSettingsMode,
shouldShowPlanEditor,
shouldShowSavePreview,
} from './projectSettingsMode';
import type { WorkflowPlan } from '../types/projectSettings';
describe('projectSettingsMode', () => {
it('enters frozen when can_edit_plan is false or the project is active', () => {
assert.equal(
resolveProjectSettingsMode({
settings: { can_edit_plan: false, lifecycle_status: 'planning' },
}),
'frozen',
);
assert.equal(
resolveProjectSettingsMode({
settings: { can_edit_plan: true, lifecycle_status: 'active' },
}),
'frozen',
);
assert.equal(
resolveProjectSettingsMode({
settings: {
can_edit_plan: true,
lifecycle_status: 'planning',
live_instance: { id: 89, status: 'active', config_revision: 1 },
},
}),
'frozen',
);
assert.equal(shouldShowPlanEditor('frozen'), false);
assert.equal(shouldShowSavePreview('frozen'), false);
const chrome = projectSettingsChrome('frozen');
assert.equal(chrome.showWorkflowSelector, false);
assert.equal(chrome.showDutySelects, false);
assert.equal(chrome.showSavePreview, false);
assert.equal(chrome.showFrozenSnapshot, true);
});
it('stays planning only for an editable draft', () => {
assert.equal(
resolveProjectSettingsMode({
settings: { can_edit_plan: true, lifecycle_status: 'planning' },
}),
'planning',
);
assert.equal(shouldShowPlanEditor('planning'), true);
assert.equal(shouldShowSavePreview('planning'), true);
});
it('lists enabled duties for the frozen snapshot and skips disabled tasks', () => {
const plan: WorkflowPlan = {
workflow_definition_key: 'main',
workflow_version: 2,
stages: [
{
stage_key: 'V0.1_triage',
enabled: true,
doc_tasks: [
{
task_key: 'intake',
task_title: '用户初始需求收集表',
enabled: true,
duties: { author_user_id: 9, reviewer_user_id: 2, approver_user_id: 3 },
},
{
task_key: 'research',
enabled: false,
duties: { author_user_id: 9 },
},
],
},
],
};
assert.deepEqual(frozenDutySnapshot(plan), [
{
taskKey: 'intake',
title: '用户初始需求收集表',
author: 9,
reviewer: 2,
approver: 3,
},
]);
});
it('keeps the local draft after PLAN_FROZEN refresh and switches to frozen', () => {
const draft: WorkflowPlan = {
workflow_definition_key: 'main',
workflow_version: 1,
stages: [],
};
const recovered = afterFrozenConflictReload(
{ can_edit_plan: false, lifecycle_status: 'active' },
draft,
);
assert.equal(recovered.plan, draft);
assert.equal(recovered.mode, 'frozen');
assert.equal(projectSettingsChrome(recovered.mode).showSavePreview, false);
});
});
+75
View File
@@ -0,0 +1,75 @@
import type { ProjectSettings, WorkflowPlan } from '../types/projectSettings';
export type ProjectSettingsMode = 'loading' | 'planning' | 'frozen' | 'error';
export function resolveProjectSettingsMode(input: {
loading?: boolean;
settings?: Pick<ProjectSettings, 'can_edit_plan' | 'lifecycle_status' | 'live_instance'> | null;
loadError?: boolean;
}): ProjectSettingsMode {
if (input.loadError && !input.settings) return 'error';
if (input.loading && !input.settings) return 'loading';
const settings = input.settings;
if (!settings) return 'loading';
if (!settings.can_edit_plan) return 'frozen';
if (settings.lifecycle_status === 'active') return 'frozen';
const live = settings.live_instance;
if (live && live.status !== 'completed' && live.status !== 'terminated') return 'frozen';
return 'planning';
}
export function shouldShowPlanEditor(mode: ProjectSettingsMode): boolean {
return mode === 'planning';
}
export function shouldShowSavePreview(mode: ProjectSettingsMode): boolean {
return mode === 'planning';
}
export function projectSettingsChrome(mode: ProjectSettingsMode): {
showWorkflowSelector: boolean;
showDutySelects: boolean;
showSavePreview: boolean;
showStepPills: boolean;
showFrozenSnapshot: boolean;
} {
const planning = mode === 'planning';
return {
showWorkflowSelector: planning,
showDutySelects: planning,
showSavePreview: planning,
showStepPills: planning,
showFrozenSnapshot: mode === 'frozen',
};
}
export function afterFrozenConflictReload(
settings: Pick<ProjectSettings, 'can_edit_plan' | 'lifecycle_status' | 'live_instance'>,
draftPlan: WorkflowPlan,
): { plan: WorkflowPlan; mode: ProjectSettingsMode } {
return {
plan: draftPlan,
mode: resolveProjectSettingsMode({ settings }),
};
}
export function frozenDutySnapshot(plan?: WorkflowPlan | null): Array<{
taskKey: string;
title: string;
author?: number;
reviewer?: number;
approver?: number;
}> {
if (!plan) return [];
return plan.stages.flatMap((stage) =>
stage.doc_tasks
.filter((task) => task.enabled)
.map((task) => ({
taskKey: task.task_key,
title: task.task_title || task.task_key,
author: task.duties.author_user_id,
reviewer: task.duties.reviewer_user_id,
approver: task.duties.approver_user_id,
})),
);
}