test(web): add typed project settings errors and frozen mode
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user