refactor(data): 收口关系表运行时数据源
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled

- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读
- 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移
- 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试

Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
2026-07-09 14:59:49 +08:00
parent 1e6fb0c7aa
commit 2e595c7e72
98 changed files with 2938 additions and 1441 deletions

View File

@@ -112,3 +112,20 @@ test('API availability probe retries after a transient failure', async () => {
globalThis.fetch = originalFetch;
}
});
test('raw API requests map abort errors to a readable timeout message', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error('signal is aborted without reason');
}) as typeof fetch;
try {
await assert.rejects(
() => api.postRaw('/ai/decompose', {}, 1),
/请求超时/,
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -37,6 +37,17 @@ function getErrorMessage(body: unknown, status: number) {
return `Request failed: ${status}`;
}
function isAbortLikeError(error: unknown) {
const value = error as { name?: unknown; message?: unknown };
const text = `${typeof value?.name === 'string' ? value.name : ''} ${typeof value?.message === 'string' ? value.message : String(error ?? '')}`;
return /abort|aborted|timeout|timed out/i.test(text);
}
function formatTimeoutMs(timeoutMs: number) {
if (timeoutMs >= 60000) return `${Math.ceil(timeoutMs / 60000)} 分钟`;
return `${Math.ceil(timeoutMs / 1000)}`;
}
export class ApiRequestError extends Error {
constructor(
public readonly status: number,
@@ -110,6 +121,11 @@ export const api = {
throw new ApiRequestError(res.status, err);
}
return res.json();
} catch (e) {
if (isAbortLikeError(e)) {
throw new Error(`请求超时:服务在${formatTimeoutMs(timeoutMs)}内没有返回,请稍后重试。`);
}
throw e;
} finally {
clearTimeout(tid);
}

View File

@@ -8,7 +8,7 @@ import type { Requirement, RequirementStatus, SourceType } from './requirement';
import type { TaskCategory } from './task-category';
import type { TaskWorklog } from './task-worklog';
import type { TestCase, TestCaseStatus } from './test-case';
import type { VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan';
import type { PlanTask, VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan';
import type { WorkActivity, WorkActivityCategory, WorkActivityDraft, WorkActivitySourceType } from './work-activity';
export interface RootProject {
@@ -117,6 +117,9 @@ interface DomainRequirementRow {
creatorId?: string | null;
creatorName?: string | null;
creator?: { id?: string | null; name?: string | null } | null;
productOwnerId?: string | null;
productOwnerName?: string | null;
productOwner?: { id?: string | null; name?: string | null } | null;
createdAt?: string | Date | null;
}
@@ -168,6 +171,7 @@ interface DomainVersionPlanRow {
actualStartAt?: string | Date | null;
completedAt?: string | Date | null;
resultUrl?: string | null;
tasks?: unknown;
requirementCoverage?: unknown;
logs?: unknown;
createdAt?: string | Date | null;
@@ -594,6 +598,7 @@ function toRequirementPayload(data: Partial<Requirement>) {
...(data.sourceTarget !== undefined && { sourceTarget: data.sourceTarget }),
...(data.platforms !== undefined && { platform: data.platforms.join(',') }),
...(data.priority !== undefined && { priority: priorityToNumber(data.priority) }),
...(data.productOwner !== undefined && { productOwnerId: data.productOwner }),
};
}
@@ -613,6 +618,7 @@ function normalizeRequirement(row: DomainRequirementRow): Requirement {
status: toRequirementStatus(row.status),
priority: toPriority(row.priority),
effort: 'M',
productOwner: row.productOwner?.name ?? row.productOwnerName ?? row.productOwnerId ?? '',
creator: row.creator?.name ?? row.creatorName ?? row.creatorId ?? '',
createdAt: isoString(row.createdAt),
};
@@ -669,6 +675,7 @@ function toVersionPlanPayload(data: Partial<VersionPlan>) {
...(data.actualStartAt !== undefined && { actualStartAt: data.actualStartAt }),
...(data.completedAt !== undefined && { completedAt: data.completedAt }),
...(data.resultUrl !== undefined && { resultUrl: data.resultUrl }),
...(data.tasks !== undefined && { tasks: data.tasks }),
...(data.linkedRequirementIds !== undefined && { linkedRequirementIds: data.linkedRequirementIds }),
...(data.requirementCoverage !== undefined && { requirementCoverage: data.requirementCoverage }),
...(data.logs !== undefined && { logs: data.logs }),
@@ -686,7 +693,7 @@ function normalizeVersionPlan(row: DomainVersionPlanRow): VersionPlan {
startTime: isoString(row.expectedStartAt),
endTime: isoString(row.expectedEndAt),
status: toPlanStatus(row.status),
tasks: [],
tasks: asArray<PlanTask>(row.tasks),
completedRequirementIds: requirementCoverage
.filter((item) => item?.status === 'completed')
.map((item) => item.requirementId)

View File

@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
test('governance settings is the unified dictionary entry for requirement and task types', () => {
const sidebar = readFileSync('components/layout/Sidebar.tsx', 'utf8');
const permissions = readFileSync('lib/permissions.ts', 'utf8');
const governancePage = readFileSync('app/admin/governance/page.tsx', 'utf8');
const appModule = readFileSync(join(process.cwd(), '../server/src/app.module.ts'), 'utf8');
assert.match(sidebar, /治理设置/);
assert.match(sidebar, /\/admin\/governance/);
assert.doesNotMatch(sidebar, /\/admin\/categories/);
assert.match(permissions, /governance:manage/);
assert.match(appModule, /GovernanceModule/);
assert.match(governancePage, /task_category/);
assert.match(governancePage, /requirement_type/);
assert.match(governancePage, /requirement_platform/);
assert.match(governancePage, /requirement_source/);
});

View File

@@ -8,7 +8,7 @@ test('member store save helper does not swallow AppData save failures', () => {
assert.match(
source,
/async function saveStored\(state: \{ departments: Department\[\]; members: Member\[\]; roles: RoleItem\[\]; passwordRule: PasswordRule \}\) \{\s+await saveServerData\('members', state\);\s+\}/,
/async function saveStored\(state: MemberConfigState\) \{\s+await saveServerData\('members', \{ departments: state\.departments, members: \[\], roles: state\.roles, passwordRule: state\.passwordRule \}\);\s+\}/,
);
});

View File

@@ -101,6 +101,7 @@ test('resolves legacy Chen Shi labels to the built-in admin display name', () =>
assert.equal(resolveMemberDisplayName('陈十', result.members), SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(resolveMemberDisplayName(SYSTEM_ADMIN_MEMBER_ID, result.members), SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(resolveMemberDisplayName('admin', result.members), SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(resolveMemberDisplayName('张三', result.members), '张三');
});
@@ -111,5 +112,6 @@ test('matches legacy admin names as the built-in admin member', () => {
assert.equal(isMemberReference('陈十', admin), true);
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_ID, admin), true);
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_NAME, admin), true);
assert.equal(isMemberReference('admin', admin), true);
assert.equal(isMemberReference('张三', admin), false);
});

View File

@@ -78,9 +78,9 @@ export function isSystemAdminMember(member: Pick<Member, 'id'>): boolean {
return member.id === SYSTEM_ADMIN_MEMBER_ID;
}
export function resolveMemberDisplayName(ref: string | undefined | null, members: Member[]): string {
export function resolveMemberDisplayName(ref: string | undefined | null, members: Array<{ id?: string; name: string; username?: string | null }>): string {
if (!ref) return '-';
const member = members.find((m) => m.id === ref || m.name === ref);
const member = members.find((m) => m.id === ref || m.name === ref || m.username === ref);
if (member) return member.name;
if (LEGACY_SYSTEM_ADMIN_NAMES.includes(ref)) {
const admin = members.find((m) => m.id === SYSTEM_ADMIN_MEMBER_ID);
@@ -89,8 +89,8 @@ export function resolveMemberDisplayName(ref: string | undefined | null, members
return ref;
}
export function isMemberReference(ref: string | undefined | null, member: Pick<Member, 'id' | 'name'>): boolean {
export function isMemberReference(ref: string | undefined | null, member: { id?: string; name: string; username?: string | null }): boolean {
if (!ref) return false;
if (ref === member.id || ref === member.name) return true;
if (ref === member.id || ref === member.name || ref === member.username) return true;
return member.id === SYSTEM_ADMIN_MEMBER_ID && LEGACY_SYSTEM_ADMIN_NAMES.includes(ref);
}

View File

@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(join(process.cwd(), path), 'utf8');
}
function assertNoBroadBusinessFetches(path: string, allowedPatterns: RegExp[] = []) {
const text = allowedPatterns.reduce(
(current, pattern) => current.replace(pattern, ''),
source(path),
);
for (const method of [
'fetchRequirements',
'fetchPlans',
'fetchTasks',
'fetchDevTasks',
'fetchTestCases',
'fetchBugs',
]) {
assert.doesNotMatch(
text,
new RegExp(`\\b${method}\\(\\s*\\)`),
`${path} must not call ${method}() without a relation scope`,
);
}
}
test('version detail runtime loads requirements and work data by relation scope', () => {
const text = source('app/versions/[id]/page.tsx');
assert.match(text, /fetchRequirements\(\{ productId: version\.productId, versionId \}\)/);
assert.match(text, /fetchRequirements\(\{ productId: version\.productId, versionId, force: true \}\)/);
assertNoBroadBusinessFetches('app/versions/[id]/page.tsx');
});
test('version list hydrates visible versions through scoped relation partitions', () => {
const text = source('app/versions/page.tsx');
assert.match(text, /fetchRequirements\(\{ productId: version\.productId, versionId: version\.id \}\)/);
assert.match(text, /fetchPlans\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchDevTasks\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchTestCases\(\{ versionId: version\.id \}\)/);
assertNoBroadBusinessFetches('app/versions/page.tsx');
});
test('workspace fallback and drawer hydration remain version scoped', () => {
const hook = source('hooks/useWorkspaceWorkItems.ts');
const page = source('app/workspace/page.tsx');
assert.match(hook, /hydrateWorkspaceStoreFallback/);
assert.match(page, /hydrateWorkspaceStoreFallback/);
assert.match(page, /fetchRequirements\(\{ productId: versionContext\.productId, versionId: item\.versionId \}\)/);
assertNoBroadBusinessFetches('hooks/useWorkspaceWorkItems.ts');
assertNoBroadBusinessFetches('app/workspace/page.tsx');
});
test('xiaobao fallback hydrates visible versions by relation scope only', () => {
const text = source('hooks/useXiaobaoWarningRisks.ts');
assert.match(text, /hydrateXiaobaoStoreFallback/);
assert.match(text, /fetchRequirements\(\{ productId: version\.productId, versionId: version\.id \}\)/);
assert.match(text, /fetchPlans\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchTasks\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchTestCases\(\{ versionId: version\.id \}\)/);
assert.match(text, /fetchBugs\(\{ versionId: version\.id \}\)/);
assertNoBroadBusinessFetches('hooks/useXiaobaoWarningRisks.ts');
});
test('overtime modal loads selected version requirements through relation scope', () => {
const text = source('app/overtime/page.tsx');
assert.match(text, /onVersionChange=\{\(version\) => fetchRequirements\(\{ productId: version\.productId, versionId: version\.id \}\)\}/);
assertNoBroadBusinessFetches('app/overtime/page.tsx');
});
test('requirement pool fallback hydrates the selected relation scope', () => {
const text = source('app/requirements/page.tsx');
assert.match(text, /hydrateRequirementStoreFallback/);
assert.match(text, /fetchRequirements\(\{ productId: selectedScope\.productId \}\)/);
assert.match(text, /fetchRequirements\(\{ productId: project\.productId, projectId: selectedScope\.projectId \}\)/);
assert.match(text, /fetchRequirements\(\{ productId: product\.id \}\)/);
assert.match(text, /fetchDevTasks\(\{ versionId: version\.id \}\)/);
assertNoBroadBusinessFetches('app/requirements/page.tsx');
});

View File

@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
function source(path: string) {
return readFileSync(join(process.cwd(), path), 'utf8');
}
const migratedBusinessStores = [
['stores/useProductStore.ts', 'products-overview'],
['stores/useRequirementStore.ts', 'requirements'],
['stores/useVersionPlanStore.ts', 'version-plans'],
['stores/useDevTaskStore.ts', 'dev-tasks'],
['stores/useTestCaseStore.ts', 'test-cases'],
['stores/useBugStore.ts', 'bugs'],
['stores/useTaskCategoryStore.ts', 'task-categories'],
['stores/useTaskWorklogStore.ts', 'task-worklogs'],
['stores/useWorkActivityStore.ts', 'work-activities'],
] as const;
test('migrated business stores do not read or write AppData documents at runtime', () => {
for (const [file, key] of migratedBusinessStores) {
const text = source(file);
assert.doesNotMatch(text, new RegExp(`loadServerData<[^>]*>\\('${key}'`), `${file} must not read ${key}`);
assert.doesNotMatch(text, new RegExp(`loadServerData\\([^)]*'${key}'`), `${file} must not read ${key}`);
assert.doesNotMatch(text, new RegExp(`saveServerData\\('${key}'`), `${file} must not write ${key}`);
assert.doesNotMatch(text, /\.catch\(loadStored\)/, `${file} must not fall back to legacy AppData reads`);
}
});
test('only explicit legacy config stores keep AppData access', () => {
assert.match(source('stores/useMemberStore.ts'), /saveServerData\('members'/);
assert.match(source('stores/useMemberStore.ts'), /loadServerData<[^>]+>\('members'/);
assert.match(source('stores/useOvertimeStore.ts'), /loadServerData<[^>]+>\('overtime'/);
});
test('read-only Xiaobao AppData archive keys are not used by runtime stores', () => {
for (const file of ['stores/useXiaobaoRiskStore.ts', 'stores/useXiaobaoWarningReadStore.ts']) {
const text = source(file);
assert.doesNotMatch(text, /loadServerData<[^>]+>\('xiaobao-risk-snapshots'/, `${file} must not read Xiaobao snapshots AppData`);
assert.doesNotMatch(text, /loadServerData<[^>]+>\('xiaobao-risk-insights'/, `${file} must not read Xiaobao insights AppData`);
assert.doesNotMatch(text, /loadServerData<[^>]+>\('xiaobao-warning-views'/, `${file} must not read Xiaobao warning read-state AppData`);
assert.doesNotMatch(text, /saveServerData\('xiaobao-risk-snapshots'/, `${file} must not write Xiaobao snapshots AppData`);
assert.doesNotMatch(text, /saveServerData\('xiaobao-risk-insights'/, `${file} must not write Xiaobao insights AppData`);
assert.doesNotMatch(text, /saveServerData\('xiaobao-warning-views'/, `${file} must not write Xiaobao warning read-state AppData`);
}
});
test('auth reads member identities from the member domain API instead of members AppData', () => {
const text = source('stores/useAuthStore.ts');
assert.match(text, /listMembersDomain/);
assert.doesNotMatch(text, /loadServerData<[^>]+>\('members'/);
assert.doesNotMatch(text, /from '@\/lib\/server-data'/);
});
test('member store keeps members AppData as config only, not member identity fallback', () => {
const text = source('stores/useMemberStore.ts');
assert.doesNotMatch(text, /cached\.members/);
assert.doesNotMatch(text, /members: get\(\)\.members,/);
assert.match(text, /saveServerData\('members', \{ departments: state\.departments, members: \[\], roles: state\.roles, passwordRule: state\.passwordRule \}\)/);
});

View File

@@ -3,11 +3,10 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
test('product store save helper does not swallow AppData save failures', () => {
test('product store no longer persists product tree through products-overview AppData', () => {
const source = readFileSync(join(process.cwd(), 'stores/useProductStore.ts'), 'utf8');
assert.match(
source,
/async function saveStoredOverview\(data: ProductOverview\[\]\) \{\s+await saveServerData\('products-overview', data\);\s+\}/,
);
assert.doesNotMatch(source, /loadServerData/);
assert.doesNotMatch(source, /saveServerData/);
assert.doesNotMatch(source, /products-overview/);
});

View File

@@ -5,6 +5,8 @@ import test from 'node:test';
const storeSource = () => readFileSync(join(process.cwd(), 'stores/useRequirementStore.ts'), 'utf8');
const pageSource = () => readFileSync(join(process.cwd(), 'app/requirements/page.tsx'), 'utf8');
const productDetailPage = () => readFileSync(join(process.cwd(), 'app/products/[id]/page.tsx'), 'utf8');
const projectDetailPage = () => readFileSync(join(process.cwd(), 'app/projects/[id]/page.tsx'), 'utf8');
function storeMethodBody(text: string, name: string) {
const start = text.indexOf(` ${name}:`);
@@ -52,11 +54,63 @@ test('requirement mutations use domain APIs instead of AppData requirements save
assert.doesNotMatch(deleteRequirement, /saveServerData\('requirements'/);
});
test('requirement dictionaries use governance dictionaries instead of AppData requirement saves', () => {
const text = storeSource();
const fetchRequirements = storeMethodBody(text, 'fetchRequirements');
const addType = storeMethodBody(text, 'addType');
const updateType = storeMethodBody(text, 'updateType');
const deleteType = storeMethodBody(text, 'deleteType');
const addSourceTarget = storeMethodBody(text, 'addSourceTarget');
const addPlatform = storeMethodBody(text, 'addPlatform');
assert.match(fetchRequirements, /loadRequirementGovernanceDictionaries/);
assert.match(addType, /createGovernanceDictionary\('requirement_type'/);
assert.match(updateType, /updateGovernanceDictionary\('requirement_type'/);
assert.match(deleteType, /deleteGovernanceDictionary\('requirement_type'/);
assert.match(addSourceTarget, /createGovernanceDictionary\('requirement_source'/);
assert.match(addPlatform, /createGovernanceDictionary\('requirement_platform'/);
for (const body of [addType, updateType, deleteType, addSourceTarget, addPlatform]) {
assert.doesNotMatch(body, /saveServerData\('requirements'/);
}
});
test('requirement pool no longer exposes local dictionary management drawers', () => {
const page = pageSource();
const modal = readFileSync(join(process.cwd(), 'components/requirement/RequirementModal.tsx'), 'utf8');
assert.doesNotMatch(page, /SourceDrawer|DictDrawer/);
assert.doesNotMatch(page, /setDrawerType/);
assert.doesNotMatch(modal, /onOpenDrawer/);
});
test('requirement pool uses server pagination without full AppData load for scoped list queries', () => {
const text = pageSource();
assert.match(text, /loadV22RequirementsPage\(v22RequirementQuery\)/);
assert.match(text, /if \(v22RequirementQuery && !v22RequirementsFailed\) return;\s+fetchRequirements\(\);/);
assert.match(text, /if \(v22RequirementQuery && !v22RequirementsFailed\) return;\s+void hydrateRequirementStoreFallback\(\);/);
assert.doesNotMatch(text, /fetchRequirements\(\);/);
assert.doesNotMatch(text, /fetchDevTasks\(\);/);
});
test('requirement store can hydrate scoped relation rows for product and project pages', () => {
const text = storeSource();
const fetchRequirements = storeMethodBody(text, 'fetchRequirements');
assert.match(text, /loadV22RequirementsPage/);
assert.match(fetchRequirements, /options\?\.productId/);
assert.match(fetchRequirements, /mergeRequirementsForScope/);
assert.doesNotMatch(fetchRequirements, /loadServerData<[^>]+>\('requirements'/);
});
test('product and project detail pages request scoped requirement relation rows', () => {
const productPage = productDetailPage();
const projectPage = projectDetailPage();
assert.match(productPage, /fetchRequirements\(\{ productId \}\)/);
assert.doesNotMatch(productPage, /fetchRequirements\(\);/);
assert.match(projectPage, /fetchRequirements\(\{ productId: project\.productId, projectId \}\)/);
assert.doesNotMatch(projectPage, /fetchRequirements\(\);/);
});
test('relation-backed requirement rows remain mutable without AppData identity', () => {

View File

@@ -5,6 +5,8 @@ import test from 'node:test';
const testCaseStore = () => readFileSync(join(process.cwd(), 'stores/useTestCaseStore.ts'), 'utf8');
const bugStore = () => readFileSync(join(process.cwd(), 'stores/useBugStore.ts'), 'utf8');
const testCaseTab = () => readFileSync(join(process.cwd(), 'components/test-case/TestCaseTab.tsx'), 'utf8');
const bugTab = () => readFileSync(join(process.cwd(), 'components/bug/BugTab.tsx'), 'utf8');
function storeMethodBody(text: string, name: string) {
const implementationStart = text.indexOf('export const');
@@ -30,8 +32,11 @@ function storeMethodBody(text: string, name: string) {
test('test case store uses domain APIs for version-scoped writes', () => {
const text = testCaseStore();
const fetchTestCases = storeMethodBody(text, 'fetchTestCases');
assert.match(text, /from '@\/lib\/domain-api'/);
assert.match(fetchTestCases, /listTestCasesByVersionId\(options\.versionId\)/);
assert.match(fetchTestCases, /!options\?\.versionId && !options\?\.force/);
assert.match(storeMethodBody(text, 'createTestCase'), /createTestCaseByVersionId\(tc\.versionId,/);
assert.match(storeMethodBody(text, 'createTestCases'), /createTestCasesByVersionId\(created\[0\]\.versionId,/);
assert.match(storeMethodBody(text, 'updateTestCase'), /updateTestCaseByVersionId\(versionId, id,/);
@@ -42,8 +47,11 @@ test('test case store uses domain APIs for version-scoped writes', () => {
test('bug store uses domain APIs for version-scoped writes', () => {
const text = bugStore();
const fetchBugs = storeMethodBody(text, 'fetchBugs');
assert.match(text, /from '@\/lib\/domain-api'/);
assert.match(fetchBugs, /listBugsByVersionId\(options\.versionId\)/);
assert.match(fetchBugs, /!options\?\.versionId && !options\?\.force/);
assert.match(storeMethodBody(text, 'createBug'), /createBugByVersionId\(bug\.versionId,/);
assert.match(storeMethodBody(text, 'updateBug'), /updateBugByVersionId\(versionId, id,/);
assert.match(storeMethodBody(text, 'changeStatus'), /updateBugStatusByVersionId\(bug\.versionId,/);
@@ -51,3 +59,32 @@ test('bug store uses domain APIs for version-scoped writes', () => {
assert.doesNotMatch(storeMethodBody(text, 'createBug'), /saveServerData\('bugs'/);
assert.doesNotMatch(storeMethodBody(text, 'updateBug'), /saveServerData\('bugs'/);
});
test('workspace test case and bug drawers load version-scoped relation data', () => {
const page = readFileSync(join(process.cwd(), 'app/workspace/page.tsx'), 'utf8');
assert.match(page, /fetchTestCases\(\{ versionId: item\.versionId \}\)/);
assert.match(page, /fetchBugs\(\{ versionId: item\.versionId \}\)/);
assert.doesNotMatch(page, /item\.type === 'testCase' && !testCasesLoaded/);
assert.doesNotMatch(page, /item\.type === 'bug' && !bugsLoaded/);
assert.doesNotMatch(page, /pendingLoads\.push\(fetchTestCases\(\)\);/);
assert.doesNotMatch(page, /pendingLoads\.push\(fetchBugs\(\)\);/);
});
test('test case tab fallback fetches current version relation partitions', () => {
const text = testCaseTab();
assert.match(text, /fetchTestCases\(\{ versionId \}\)/);
assert.match(text, /fetchBugs\(\{ versionId \}\)/);
assert.doesNotMatch(text, /if \(!scopedVersionCases\) fetchTestCases\(\);/);
assert.doesNotMatch(text, /if \(!scopedVersionBugs\) fetchBugs\(\);/);
});
test('bug tab fallback fetches current version relation partitions', () => {
const text = bugTab();
assert.match(text, /fetchBugs\(\{ versionId \}\)/);
assert.match(text, /fetchTestCases\(\{ versionId \}\)/);
assert.doesNotMatch(text, /if \(!scopedVersionBugs\) fetchBugs\(\);/);
assert.doesNotMatch(text, /if \(!versionTestCases\) fetchTestCases\(\);/);
});

View File

@@ -47,6 +47,9 @@ interface V22RequirementRow {
creatorId?: string | null;
creatorName?: string | null;
creator?: { id?: string | null; name?: string | null } | null;
productOwnerId?: string | null;
productOwnerName?: string | null;
productOwner?: { id?: string | null; name?: string | null } | null;
createdAt?: V22DateValue;
updatedAt?: V22DateValue;
}
@@ -63,6 +66,7 @@ interface V22VersionPlanRow {
actualStartAt?: V22DateValue;
completedAt?: V22DateValue;
resultUrl?: string | null;
tasks?: unknown;
requirementCoverage?: unknown;
logs?: unknown;
createdAt?: V22DateValue;
@@ -295,6 +299,7 @@ function mapRequirement(row: V22RequirementRow): Requirement {
status: toRequirementStatus(row.status),
priority: toPriority(row.priority),
effort: 'M',
productOwner: row.productOwner?.name ?? row.productOwnerName ?? row.productOwnerId ?? '',
creator: row.creator?.name ?? row.creatorName ?? row.creatorId ?? '',
createdAt: requiredIso(row.createdAt),
};
@@ -311,7 +316,7 @@ function mapVersionPlan(row: V22VersionPlanRow): VersionPlan {
startTime: requiredIso(row.expectedStartAt),
endTime: requiredIso(row.expectedEndAt),
status: toPlanStatus(row.status),
tasks: [],
tasks: asArray<PlanTask>(row.tasks),
completedRequirementIds: coverage
.filter((item) => item?.status === 'completed')
.map((item) => item.requirementId)

View File

@@ -36,6 +36,10 @@ test('member and dictionary stores use domain APIs as primary writes', () => {
assert.match(storeMethodBody(members, 'createMember'), /createMemberDomain\(member,/);
assert.match(storeMethodBody(members, 'updateMember'), /updateMemberDomain\(id,/);
assert.match(storeMethodBody(members, 'deleteMember'), /deleteMemberDomain\(id\)/);
assert.doesNotMatch(storeMethodBody(members, 'fetchMembers'), /saveStored\(/);
assert.doesNotMatch(storeMethodBody(members, 'createMember'), /saveStored\(/);
assert.doesNotMatch(storeMethodBody(members, 'updateMember'), /saveStored\(/);
assert.doesNotMatch(storeMethodBody(members, 'deleteMember'), /saveStored\(/);
assert.match(categories, /from '@\/lib\/domain-api'/);
assert.match(storeMethodBody(categories, 'addCategory'), /createTaskCategoryDomain\(item\)/);

View File

@@ -170,8 +170,8 @@ test('buildVersionDataScopeMap indexes many versions in one pass', () => {
assert.deepEqual(Object.keys(scopeMap).sort(), ['version-1', 'version-2', 'version-empty']);
});
test('selectVersionDataScope prefers non-empty V2.2 scope but keeps AppData overtime records', () => {
const appDataScope = buildVersionDataScope({
test('selectVersionDataScope prefers non-empty V2.2 scope but keeps store overtime records', () => {
const storeScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-app', 'version-1')],
plans: [plan('plan-app', 'version-1', 'research')],
@@ -190,15 +190,61 @@ test('selectVersionDataScope prefers non-empty V2.2 scope but keeps AppData over
overtimeRecords: [],
});
const selected = selectVersionDataScope({ appDataScope, v22Scope });
const selected = selectVersionDataScope({ storeScope, v22Scope });
assert.equal(selected?.requirements[0].id, 'req-v22');
assert.equal(selected?.plans[0].id, 'plan-v22');
assert.deepEqual(selected?.overtimeRecords.map((item) => item.id), ['ot-app']);
});
test('selectVersionDataScope falls back to AppData when V2.2 scope is empty', () => {
const appDataScope = buildVersionDataScope({
test('selectVersionDataScope overlays local store changes onto V2.2 rows by id', () => {
const storeScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [
{ ...requirement('req-v22', 'version-1'), title: '本地标题' },
{ ...requirement('req-local', 'version-1'), title: '本地新增' },
],
plans: [
{
...plan('plan-v22', 'version-1', 'product'),
status: 'in_progress',
requirementCoverage: [
{
requirementId: 'req-v22',
status: 'not_started',
currentWorkStartedAt: '2026-07-09T09:30:00.000Z',
updatedAt: '2026-07-09T09:30:00.000Z',
updatedBy: 'PM',
},
],
},
],
devTasks: [devTask('dev-v22', { versionId: 'version-1', status: 'in_progress' })],
testCases: [testCase('tc-v22', 'version-1')],
bugs: [bug('bug-v22', 'version-1')],
overtimeRecords: [],
});
const v22Scope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-v22', 'version-1')],
plans: [plan('plan-v22', 'version-1', 'product')],
devTasks: [devTask('dev-v22', { versionId: 'version-1', status: 'todo' })],
testCases: [testCase('tc-v22', 'version-1')],
bugs: [bug('bug-v22', 'version-1')],
overtimeRecords: [],
});
const selected = selectVersionDataScope({ storeScope, v22Scope });
assert.equal(selected?.requirements.find((item) => item.id === 'req-v22')?.title, '本地标题');
assert.equal(selected?.requirements.some((item) => item.id === 'req-local'), true);
assert.equal(selected?.plans[0].status, 'in_progress');
assert.equal(selected?.plans[0].requirementCoverage?.[0]?.currentWorkStartedAt, '2026-07-09T09:30:00.000Z');
assert.equal(selected?.devTasks[0].status, 'in_progress');
});
test('selectVersionDataScope falls back to store scope when V2.2 scope is empty', () => {
const storeScope = buildVersionDataScope({
versionId: 'version-1',
requirements: [requirement('req-app', 'version-1')],
plans: [],
@@ -215,7 +261,7 @@ test('selectVersionDataScope falls back to AppData when V2.2 scope is empty', ()
bugs: [],
});
const selected = selectVersionDataScope({ appDataScope, v22Scope: emptyV22Scope });
const selected = selectVersionDataScope({ storeScope, v22Scope: emptyV22Scope });
assert.equal(selected?.requirements[0].id, 'req-app');
});

View File

@@ -89,17 +89,27 @@ export function hasVersionDataScopeRows(scope: VersionDataScope | null | undefin
}
export function selectVersionDataScope(input: {
appDataScope: VersionDataScope | null;
storeScope: VersionDataScope | null;
v22Scope?: VersionDataScope | null;
}): VersionDataScope | null {
const { appDataScope, v22Scope } = input;
const { storeScope, v22Scope } = input;
if (hasVersionDataScopeRows(v22Scope)) {
const requirements = overlayRowsById(v22Scope.requirements, storeScope?.requirements ?? []);
const plans = overlayRowsById(v22Scope.plans, storeScope?.plans ?? []);
return {
...v22Scope,
overtimeRecords: appDataScope?.overtimeRecords ?? v22Scope.overtimeRecords,
requirements,
requirementIds: requirements.map((item) => item.id),
requirementIdSet: new Set(requirements.map((item) => item.id)),
plans,
plansByType: groupPlansByType(plans),
devTasks: overlayRowsById(v22Scope.devTasks, storeScope?.devTasks ?? []),
testCases: overlayRowsById(v22Scope.testCases, storeScope?.testCases ?? []),
bugs: overlayRowsById(v22Scope.bugs, storeScope?.bugs ?? []),
overtimeRecords: storeScope?.overtimeRecords ?? v22Scope.overtimeRecords,
};
}
return appDataScope;
return storeScope;
}
export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record<string, VersionDataScope> {
@@ -167,3 +177,25 @@ function createEmptyScope(): VersionDataScope {
overtimeRecords: [],
};
}
function overlayRowsById<T extends { id: string }>(baseRows: T[], overlayRows: T[]): T[] {
if (baseRows.length === 0) return baseRows;
const overlayById = new Map(overlayRows.map((item) => [item.id, item]));
const seen = new Set<string>();
const merged = baseRows.map((item) => {
seen.add(item.id);
return overlayById.get(item.id) ?? item;
});
for (const item of overlayRows) {
if (!seen.has(item.id)) merged.push(item);
}
return merged;
}
function groupPlansByType(plans: VersionPlan[]): Record<VersionPlan['type'], VersionPlan[]> {
return {
research: plans.filter((plan) => plan.type === 'research'),
product: plans.filter((plan) => plan.type === 'product'),
ui: plans.filter((plan) => plan.type === 'ui'),
};
}

View File

@@ -8,6 +8,7 @@ export interface VersionMember {
export interface VersionMemberCandidate {
id: string;
name: string;
username?: string;
departmentName?: string;
}

View File

@@ -5,6 +5,11 @@ import test from 'node:test';
const planStore = () => readFileSync(join(process.cwd(), 'stores/useVersionPlanStore.ts'), 'utf8');
const taskStore = () => readFileSync(join(process.cwd(), 'stores/useDevTaskStore.ts'), 'utf8');
const versionDetailPage = () => readFileSync(join(process.cwd(), 'app/versions/[id]/page.tsx'), 'utf8');
const workspacePage = () => readFileSync(join(process.cwd(), 'app/workspace/page.tsx'), 'utf8');
const devTaskTab = () => readFileSync(join(process.cwd(), 'components/dev-task/DevTaskTab.tsx'), 'utf8');
const projectListPage = () => readFileSync(join(process.cwd(), 'app/projects/page.tsx'), 'utf8');
const projectDetailPage = () => readFileSync(join(process.cwd(), 'app/projects/[id]/page.tsx'), 'utf8');
function storeMethodBody(text: string, name: string) {
const implementationStart = text.indexOf('export const');
@@ -37,12 +42,52 @@ test('version plan store uses domain APIs for version-scoped writes', () => {
assert.match(storeMethodBody(text, 'completePlan'), /completeVersionPlanByVersionId\(versionId, id,/);
assert.doesNotMatch(storeMethodBody(text, 'createPlan'), /saveServerData\('version-plans'/);
assert.doesNotMatch(storeMethodBody(text, 'updatePlan'), /saveServerData\('version-plans'/);
assert.doesNotMatch(text, /saveServerData\('version-plans'/);
assert.doesNotMatch(text, /saveVersionPlansFallback/);
});
test('version plan domain API preserves research direction tasks', () => {
const text = readFileSync(join(process.cwd(), 'lib/domain-api.ts'), 'utf8');
assert.match(text, /data\.tasks !== undefined && \{ tasks: data\.tasks \}/);
assert.match(text, /tasks: asArray<PlanTask>\(row\.tasks\)/);
});
test('version plan reads for version detail use version-scoped relation data', () => {
const store = planStore();
const fetchPlans = storeMethodBody(store, 'fetchPlans');
const page = versionDetailPage();
assert.match(fetchPlans, /listVersionPlansByVersionId\(options\.versionId\)/);
assert.match(fetchPlans, /!options\?\.versionId && !options\?\.force/);
assert.match(fetchPlans, /mergeVersionPlansForVersion/);
assert.doesNotMatch(fetchPlans, /listVersionPlansByVersionId\(options\.versionId\)\.catch\(loadStored\)/);
assert.match(page, /fetchPlans\(\{ versionId \}\);/);
assert.match(page, /v22Scope,\s*\}\)/);
assert.doesNotMatch(page, /versionWriteStoresReady \? null : v22Scope/);
assert.match(page, /const displayRequirements = scopedVersionData\.requirements/);
assert.match(page, /const displayRequirementIds = scopedVersionData\.requirementIds/);
assert.match(page, /const displayPlans = scopedVersionData\.plans/);
assert.match(page, /const displayDevTasks = scopedVersionData\.devTasks/);
assert.match(page, /const displayTestCases = scopedVersionData\.testCases/);
assert.match(page, /const displayBugs = scopedVersionData\.bugs/);
});
test('workspace plan drawer loads version-scoped relation data', () => {
const page = workspacePage();
assert.match(page, /fetchPlans\(\{ versionId: item\.versionId \}\)/);
assert.doesNotMatch(page, /&& !plansLoaded\) \{\s*pendingLoads\.push\(fetchPlans\(\{ versionId: item\.versionId \}\)\);/);
assert.doesNotMatch(page, /pendingLoads\.push\(fetchPlans\(\)\);/);
});
test('dev task store uses domain APIs for version-scoped writes', () => {
const text = taskStore();
const fetchTasks = storeMethodBody(text, 'fetchTasks');
assert.match(text, /from '@\/lib\/domain-api'/);
assert.match(fetchTasks, /listDevTasksByVersionId\(options\.versionId\)/);
assert.match(fetchTasks, /!options\?\.versionId && !options\?\.force/);
assert.match(storeMethodBody(text, 'createTask'), /createDevTaskByVersionId\(task\.versionId,/);
assert.match(storeMethodBody(text, 'updateTask'), /updateDevTaskByVersionId\(versionId, id,/);
assert.match(storeMethodBody(text, 'changeStatus'), /updateDevTaskStatusByVersionId\(task\.versionId,/);
@@ -50,3 +95,39 @@ test('dev task store uses domain APIs for version-scoped writes', () => {
assert.doesNotMatch(storeMethodBody(text, 'createTask'), /saveServerData\('dev-tasks'/);
assert.doesNotMatch(storeMethodBody(text, 'updateTask'), /saveServerData\('dev-tasks'/);
});
test('workspace dev task drawer loads version-scoped relation data', () => {
const page = workspacePage();
assert.match(page, /fetchTasks\(\{ versionId: item\.versionId \}\)/);
assert.doesNotMatch(page, /item\.type === 'devTask' && !devTasksLoaded/);
assert.doesNotMatch(page, /pendingLoads\.push\(fetchTasks\(\)\);/);
});
test('dev task tab fallback fetches the current version relation partition', () => {
const text = devTaskTab();
assert.match(text, /fetchTasks\(\{ versionId \}\)/);
assert.doesNotMatch(text, /if \(!scopedVersionTasks\) fetchTasks\(\);/);
});
test('project pages hydrate version summaries through version-scoped relation partitions', () => {
const listPage = projectListPage();
const detailPage = projectDetailPage();
assert.match(listPage, /fetchPlans\(\{ versionId: version\.id \}\)/);
assert.match(listPage, /fetchDevTasks\(\{ versionId: version\.id \}\)/);
assert.match(listPage, /fetchTestCases\(\{ versionId: version\.id \}\)/);
assert.doesNotMatch(listPage, /fetchPlans\(\);/);
assert.doesNotMatch(listPage, /fetchDevTasks\(\);/);
assert.doesNotMatch(listPage, /fetchTestCases\(\);/);
assert.match(detailPage, /fetchPlans\(\{ versionId: version\.id \}\)/);
assert.match(detailPage, /fetchDevTasks\(\{ versionId: version\.id \}\)/);
assert.match(detailPage, /fetchTestCases\(\{ versionId: version\.id \}\)/);
assert.match(detailPage, /fetchBugs\(\{ versionId: version\.id \}\)/);
assert.doesNotMatch(detailPage, /fetchPlans\(\);/);
assert.doesNotMatch(detailPage, /fetchDevTasks\(\);/);
assert.doesNotMatch(detailPage, /fetchTestCases\(\);/);
assert.doesNotMatch(detailPage, /fetchBugs\(\);/);
});

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
const rootFile = (path: string) => readFileSync(join(process.cwd(), path), 'utf8');
test('plan auto-start effect only persists the same pending plan once', () => {
const text = rootFile('components/version/PlanTab.tsx');
assert.match(text, /useRef/);
assert.match(text, /autoStartedPlanIds/);
assert.match(text, /!autoStartedPlanIds\.current\.has\(plan\.id\)/);
assert.match(text, /autoStartedPlanIds\.current\.add\(plan\.id\)/);
});
test('plan owner UI resolves stored member ids to display names and filters transfer choices by member reference', () => {
const text = rootFile('components/version/PlanTab.tsx');
assert.match(text, /resolveMemberDisplayName/);
assert.match(text, /isMemberReference/);
assert.match(text, /ownerLabel/);
assert.doesNotMatch(text, />\{plan\.owner\}<\/span>/);
assert.doesNotMatch(text, />\{plan\.owner\}<\/dd>/);
assert.doesNotMatch(text, /member\.name !== plan\.owner/);
});
test('version detail passes relation-backed member candidates to plan tabs for id display resolution', () => {
const text = rootFile('app/versions/[id]/page.tsx');
assert.match(text, /username: member\.username/);
assert.match(text, /currentUserReference=\{user\?\.username/);
assert.match(text, /allMembers=\{memberCandidates\}/);
});
test('plan create response merge preserves optimistic auto-start fields from the current local row', () => {
const text = rootFile('stores/useVersionPlanStore.ts');
assert.match(text, /mergeCreatedVersionPlan/);
assert.doesNotMatch(text, /\{\s*\.\.\.plan,\s*\.\.\.result\.item,\s*tasks: plan\.tasks\s*\}/);
});
test('requirement product owner is sent to the relation API instead of staying as a local-only field', () => {
const requirementStore = rootFile('stores/useRequirementStore.ts');
const domainApi = rootFile('lib/domain-api.ts');
assert.doesNotMatch(requirementStore, /delete domainPatch\.productOwner/);
assert.match(domainApi, /productOwnerId/);
assert.match(domainApi, /data\.productOwner/);
});

View File

@@ -36,6 +36,22 @@ test('sortPlansNewestFirst places newly created plans before older plans', () =>
assert.deepEqual(plans.map((item) => item.id), ['plan-100', 'manual-old', 'plan-300']);
});
test('calcTotalDuration follows overview actual-duration display for short plans', () => {
const calcTotalDuration = (versionPlan as any).calcTotalDuration as undefined | ((plans: VersionPlan[]) => string);
assert.equal(typeof calcTotalDuration, 'function');
assert.equal(calcTotalDuration!([
plan({
type: 'product',
startTime: '2026-06-22T09:00:00',
endTime: '2026-06-22T10:00:00',
status: 'completed',
actualStartAt: '2026-06-22T09:00:00',
completedAt: '2026-06-22T10:00:00',
}),
]), '1h0.04天)');
});
test('collects logs across plans with plan context and newest first', () => {
const getPlanLogsForPlans = (versionPlan as any).getPlanLogsForPlans as undefined | ((plans: VersionPlan[]) => Array<{
id: string;
@@ -210,6 +226,112 @@ test('starts requirement coverage work while preserving existing progress', () =
assert.equal(next.requirementCoverage?.[0]?.updatedBy, 'PM');
});
test('merges stale requirement coverage patches without dropping current sessions', () => {
const mergeVersionPlanPatch = (versionPlan as any).mergeVersionPlanPatch as undefined | ((
current: VersionPlan,
patch: Partial<VersionPlan>,
) => Partial<VersionPlan>);
assert.equal(typeof mergeVersionPlanPatch, 'function');
const current = plan({
requirementCoverage: [
{
requirementId: 'r1',
status: 'not_started',
currentWorkStartedAt: '2026-06-29T09:30:00.000Z',
updatedAt: '2026-06-29T09:30:00.000Z',
updatedBy: 'PM',
},
],
});
const merged = mergeVersionPlanPatch!(current, {
requirementCoverage: [
{
requirementId: 'r2',
status: 'not_started',
currentWorkStartedAt: '2026-06-29T10:00:00.000Z',
updatedAt: '2026-06-29T10:00:00.000Z',
updatedBy: 'PM',
},
],
});
assert.deepEqual(
merged.requirementCoverage?.map((item) => [item.requirementId, item.currentWorkStartedAt]),
[
['r2', '2026-06-29T10:00:00.000Z'],
['r1', '2026-06-29T09:30:00.000Z'],
],
);
});
test('merged requirement coverage patches preserve legacy completed ids outside the patch', () => {
const mergeVersionPlanPatch = (versionPlan as any).mergeVersionPlanPatch as undefined | ((
current: VersionPlan,
patch: Partial<VersionPlan>,
) => Partial<VersionPlan>);
assert.equal(typeof mergeVersionPlanPatch, 'function');
const merged = mergeVersionPlanPatch!(plan({
linkedRequirementIds: ['r1', 'r2'],
completedRequirementIds: ['r1'],
}), {
requirementCoverage: [
{
requirementId: 'r2',
status: 'not_started',
currentWorkStartedAt: '2026-06-29T10:00:00.000Z',
updatedAt: '2026-06-29T10:00:00.000Z',
updatedBy: 'PM',
},
],
});
assert.deepEqual(merged.completedRequirementIds?.sort(), ['r1']);
});
test('merges stale research direction patches without dropping current sessions', () => {
const mergeVersionPlanPatch = (versionPlan as any).mergeVersionPlanPatch as undefined | ((
current: VersionPlan,
patch: Partial<VersionPlan>,
) => Partial<VersionPlan>);
assert.equal(typeof mergeVersionPlanPatch, 'function');
const current = plan({
type: 'research',
tasks: [
{
id: 'task-1',
title: '竞品分析',
status: 'in_progress',
currentWorkStartedAt: '2026-06-29T09:30:00.000Z',
},
{ id: 'task-2', title: '用户访谈', status: 'pending' },
],
});
const merged = mergeVersionPlanPatch!(current, {
tasks: [
{ id: 'task-1', title: '竞品分析', status: 'pending' },
{
id: 'task-2',
title: '用户访谈',
status: 'in_progress',
currentWorkStartedAt: '2026-06-29T10:00:00.000Z',
},
],
});
assert.deepEqual(
merged.tasks?.map((item) => [item.id, item.status, item.currentWorkStartedAt]),
[
['task-1', 'in_progress', '2026-06-29T09:30:00.000Z'],
['task-2', 'in_progress', '2026-06-29T10:00:00.000Z'],
],
);
});
test('validates requirement coverage record drafts by started work session', () => {
const canSaveRequirementCoverageDraft = (versionPlan as any).canSaveRequirementCoverageDraft as undefined | ((
status: string,

View File

@@ -1,4 +1,5 @@
import type { AgentDecomposeTarget } from '@ftb/shared';
import { calcActualElapsedHours, formatActualDuration, type TimeInterval } from './work-hours';
export type PlanTaskStatus = 'pending' | 'in_progress' | 'completed';
export type ProductPlanKind = 'design' | 'review';
@@ -455,6 +456,125 @@ export function updateResearchDirectionProgress(
};
}
export function mergeVersionPlanPatch(current: VersionPlan, patch: Partial<VersionPlan>): Partial<VersionPlan> {
const nextPatch: Partial<VersionPlan> = { ...patch };
const changedRequirementIds = new Set(
(patch.logs ?? [])
.filter((log) => log.type === 'requirement_progress' && log.requirementId)
.map((log) => log.requirementId as string),
);
const changedTaskIds = new Set(
(patch.logs ?? [])
.filter((log) => log.type === 'research_direction_progress' && log.directionTaskId)
.map((log) => log.directionTaskId as string),
);
if (patch.requirementCoverage) {
nextPatch.requirementCoverage = mergeRequirementCoverage(
current.requirementCoverage ?? [],
patch.requirementCoverage,
changedRequirementIds,
);
nextPatch.completedRequirementIds = mergeCompletedRequirementIds(
current.completedRequirementIds ?? [],
nextPatch.requirementCoverage,
);
}
if (patch.tasks) {
nextPatch.tasks = mergePlanTasks(current.tasks ?? [], patch.tasks, changedTaskIds);
}
if (patch.logs) {
nextPatch.logs = mergePlanLogs(current.logs ?? [], patch.logs);
}
return nextPatch;
}
function mergeRequirementCoverage(
currentItems: VersionPlanRequirementCoverage[],
patchItems: VersionPlanRequirementCoverage[],
changedRequirementIds: Set<string>,
): VersionPlanRequirementCoverage[] {
const patchIds = new Set(patchItems.map((item) => item.requirementId));
return [
...patchItems.map((patchItem) => {
const currentItem = currentItems.find((item) => item.requirementId === patchItem.requirementId);
if (!currentItem || changedRequirementIds.has(patchItem.requirementId)) return patchItem;
if (isNewerThan(currentItem.updatedAt, patchItem.updatedAt)) return currentItem;
return {
...currentItem,
...patchItem,
currentWorkStartedAt: patchItem.currentWorkStartedAt ?? currentItem.currentWorkStartedAt,
};
}),
...currentItems.filter((item) => !patchIds.has(item.requirementId)),
];
}
function mergePlanTasks(
currentTasks: PlanTask[],
patchTasks: PlanTask[],
changedTaskIds: Set<string>,
): PlanTask[] {
const patchIds = new Set(patchTasks.map((item) => item.id));
return [
...patchTasks.map((patchTask) => {
const currentTask = currentTasks.find((item) => item.id === patchTask.id);
if (!currentTask || changedTaskIds.has(patchTask.id)) return patchTask;
if (isNewerThan(currentTask.updatedAt, patchTask.updatedAt)) return currentTask;
return {
...currentTask,
...patchTask,
status: maxPlanTaskStatus(currentTask.status, patchTask.status),
currentWorkStartedAt: patchTask.currentWorkStartedAt ?? currentTask.currentWorkStartedAt,
};
}),
...currentTasks.filter((item) => !patchIds.has(item.id)),
];
}
function mergePlanLogs(currentLogs: VersionPlanLog[], patchLogs: VersionPlanLog[]): VersionPlanLog[] {
const seen = new Set<string>();
const logs: VersionPlanLog[] = [];
for (const log of [...patchLogs, ...currentLogs]) {
if (seen.has(log.id)) continue;
seen.add(log.id);
logs.push(log);
}
return logs;
}
function mergeCompletedRequirementIds(
currentCompletedIds: string[],
coverage: VersionPlanRequirementCoverage[],
): string[] {
const completed = new Set(currentCompletedIds);
for (const item of coverage) {
if (item.status === 'completed') completed.add(item.requirementId);
else completed.delete(item.requirementId);
}
return Array.from(completed);
}
function isNewerThan(left?: string, right?: string): boolean {
if (!left || !right) return Boolean(left && !right);
const leftTime = new Date(left).getTime();
const rightTime = new Date(right).getTime();
if (!Number.isFinite(leftTime) || !Number.isFinite(rightTime)) return false;
return leftTime > rightTime;
}
function maxPlanTaskStatus(left: PlanTaskStatus, right: PlanTaskStatus): PlanTaskStatus {
const rank: Record<PlanTaskStatus, number> = {
pending: 0,
in_progress: 1,
completed: 2,
};
return rank[left] > rank[right] ? left : right;
}
function getPlanCreatedAtTime(plan: VersionPlan): number {
const time = new Date(plan.createdAt).getTime();
return Number.isFinite(time) ? time : 0;
@@ -505,43 +625,14 @@ export function formatDuration(days: number, hours: number): string {
}
export function calcTotalDuration(plans: VersionPlan[]): string {
const today = new Date().toISOString().slice(0, 10);
const activePlans = plans.filter((p) => p.status !== 'pending' || p.startTime <= today);
if (activePlans.length === 0) return '0天';
const intervals = activePlans.map((p) => {
const start = new Date(p.startTime).getTime();
const end = p.completedAt
? new Date(p.completedAt).getTime()
: p.startTime <= today
? new Date(today).getTime()
: new Date(p.startTime).getTime();
return { start, end };
}).filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
if (intervals.length === 0) return '0天';
let totalMs = 0;
let currentStart = intervals[0].start;
let currentEnd = intervals[0].end;
for (let i = 1; i < intervals.length; i++) {
if (intervals[i].start <= currentEnd) {
currentEnd = Math.max(currentEnd, intervals[i].end);
} else {
totalMs += currentEnd - currentStart;
currentStart = intervals[i].start;
currentEnd = intervals[i].end;
}
}
totalMs += currentEnd - currentStart;
const totalDays = Math.ceil(totalMs / (1000 * 60 * 60 * 24));
return `${totalDays}`;
const now = new Date().toISOString();
const totalHours = plans.reduce((sum, plan) => {
if (!plan.actualStartAt) return sum;
return sum + calcActualElapsedHours(plan.actualStartAt, plan.completedAt ?? now);
}, 0);
return formatActualDuration(Math.round(totalHours * 2) / 2);
}
import type { TimeInterval } from './work-hours';
/**
* 抽取每条已开始计划的 [actualStartAt, completedAt ?? now] 时间区间
* 用于双口径耗时统计calcTwoMetrics

View File

@@ -3,11 +3,11 @@ import test from 'node:test';
import { selectWorkspaceCollections } from './workspace-v22-source';
const appData = {
plans: [{ id: 'app-plan' }] as any[],
devTasks: [{ id: 'app-dev' }] as any[],
testCases: [{ id: 'app-test' }] as any[],
bugs: [{ id: 'app-bug' }] as any[],
const storeData = {
plans: [{ id: 'store-plan' }] as any[],
devTasks: [{ id: 'store-dev' }] as any[],
testCases: [{ id: 'store-test' }] as any[],
bugs: [{ id: 'store-bug' }] as any[],
};
test('selectWorkspaceCollections prefers successfully loaded V2.2 data even when it is empty', () => {
@@ -15,24 +15,63 @@ test('selectWorkspaceCollections prefers successfully loaded V2.2 data even when
v22Loaded: true,
v22Failed: false,
v22Data: { versionPlans: [], devTasks: [], testCases: [], bugs: [] },
appData,
storeData,
});
assert.deepEqual(selected, { versionPlans: [], devTasks: [], testCases: [], bugs: [] });
});
test('selectWorkspaceCollections falls back to AppData only when V2.2 is unavailable', () => {
test('selectWorkspaceCollections falls back to store data only when V2.2 is unavailable', () => {
const selected = selectWorkspaceCollections({
v22Loaded: false,
v22Failed: true,
v22Data: null,
appData,
storeData,
});
assert.deepEqual(selected, {
versionPlans: appData.plans,
devTasks: appData.devTasks,
testCases: appData.testCases,
bugs: appData.bugs,
versionPlans: storeData.plans,
devTasks: storeData.devTasks,
testCases: storeData.testCases,
bugs: storeData.bugs,
});
});
test('selectWorkspaceCollections overlays local store changes onto V2.2 rows by id', () => {
const selected = selectWorkspaceCollections({
v22Loaded: true,
v22Failed: false,
v22Data: {
versionPlans: [{ id: 'plan-1', status: 'pending' }] as any[],
devTasks: [{ id: 'dev-1', status: 'todo' }] as any[],
testCases: [{ id: 'tc-1', status: 'pending' }] as any[],
bugs: [{ id: 'bug-1', status: 'open' }] as any[],
},
storeData: {
plans: [
{
id: 'plan-1',
status: 'in_progress',
requirementCoverage: [
{
requirementId: 'req-1',
status: 'not_started',
currentWorkStartedAt: '2026-07-09T09:30:00.000Z',
updatedAt: '2026-07-09T09:30:00.000Z',
updatedBy: 'PM',
},
],
},
] as any[],
devTasks: [{ id: 'dev-1', status: 'in_progress' }] as any[],
testCases: [{ id: 'tc-1', status: 'running' }] as any[],
bugs: [{ id: 'bug-1', status: 'fixing' }] as any[],
},
});
assert.equal(selected.versionPlans[0].status, 'in_progress');
assert.equal(selected.versionPlans[0].requirementCoverage?.[0]?.currentWorkStartedAt, '2026-07-09T09:30:00.000Z');
assert.equal(selected.devTasks[0].status, 'in_progress');
assert.equal(selected.testCases[0].status, 'running');
assert.equal(selected.bugs[0].status, 'fixing');
});

View File

@@ -15,7 +15,7 @@ export interface SelectWorkspaceCollectionsInput {
v22Loaded: boolean;
v22Failed: boolean;
v22Data: V22WorkspaceData | null;
appData: {
storeData: {
plans: VersionPlan[];
devTasks: DevTask[];
testCases: TestCase[];
@@ -27,21 +27,46 @@ export function selectWorkspaceCollections({
v22Loaded,
v22Failed,
v22Data,
appData,
storeData,
}: SelectWorkspaceCollectionsInput): WorkspaceCollections {
if (v22Loaded && !v22Failed && v22Data) {
const hasV22Rows = v22Data.versionPlans.length > 0
|| v22Data.devTasks.length > 0
|| v22Data.testCases.length > 0
|| v22Data.bugs.length > 0;
if (!hasV22Rows) {
return {
versionPlans: v22Data.versionPlans,
devTasks: v22Data.devTasks,
testCases: v22Data.testCases,
bugs: v22Data.bugs,
};
}
return {
versionPlans: v22Data.versionPlans,
devTasks: v22Data.devTasks,
testCases: v22Data.testCases,
bugs: v22Data.bugs,
versionPlans: overlayRowsById(v22Data.versionPlans, storeData.plans),
devTasks: overlayRowsById(v22Data.devTasks, storeData.devTasks),
testCases: overlayRowsById(v22Data.testCases, storeData.testCases),
bugs: overlayRowsById(v22Data.bugs, storeData.bugs),
};
}
return {
versionPlans: appData.plans,
devTasks: appData.devTasks,
testCases: appData.testCases,
bugs: appData.bugs,
versionPlans: storeData.plans,
devTasks: storeData.devTasks,
testCases: storeData.testCases,
bugs: storeData.bugs,
};
}
function overlayRowsById<T extends { id: string }>(baseRows: T[], overlayRows: T[]): T[] {
const overlayById = new Map(overlayRows.map((item) => [item.id, item]));
const seen = new Set<string>();
const merged = baseRows.map((item) => {
seen.add(item.id);
return overlayById.get(item.id) ?? item;
});
for (const item of overlayRows) {
if (!seen.has(item.id)) merged.push(item);
}
return merged;
}

View File

@@ -8,7 +8,7 @@ import {
collectV22XiaobaoLatestInsights,
filterV22XiaobaoSummariesForVisibleVersions,
mergeV22XiaobaoLatestInsights,
shouldLoadXiaobaoAppDataFallback,
shouldLoadXiaobaoStoreFallback,
} from './xiaobao-v22-summary';
test('buildXiaobaoRisksFromV22Summaries preserves precomputed risk details with version context', () => {
@@ -144,12 +144,12 @@ test('buildXiaobaoRisksFromV22Summaries builds compatible defaults from sparse s
assert.equal(risks[0].trend.direction, 'up');
});
test('shouldLoadXiaobaoAppDataFallback waits for V2.2 summary before loading heavy AppData stores', () => {
assert.equal(shouldLoadXiaobaoAppDataFallback('idle'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('loading'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('ready'), false);
assert.equal(shouldLoadXiaobaoAppDataFallback('empty'), true);
assert.equal(shouldLoadXiaobaoAppDataFallback('failed'), true);
test('shouldLoadXiaobaoStoreFallback waits for V2.2 summary before loading relation-backed stores', () => {
assert.equal(shouldLoadXiaobaoStoreFallback('idle'), false);
assert.equal(shouldLoadXiaobaoStoreFallback('loading'), false);
assert.equal(shouldLoadXiaobaoStoreFallback('ready'), false);
assert.equal(shouldLoadXiaobaoStoreFallback('empty'), true);
assert.equal(shouldLoadXiaobaoStoreFallback('failed'), true);
});
test('filterV22XiaobaoSummariesForVisibleVersions keeps only visible version summaries', () => {

View File

@@ -25,7 +25,7 @@ const RISK_LEVELS = new Set<XiaobaoRiskLevel>(['on_track', 'attention', 'at_risk
const REASON_SEVERITIES = new Set<RiskReason['severity']>(['info', 'warning', 'danger']);
const SILENT_RISK_ITEM_TYPES = new Set<NonNullable<SilentRisk['itemType']>>(['dev_task', 'test_case', 'bug', 'version']);
export function shouldLoadXiaobaoAppDataFallback(state: V22XiaobaoSummaryLoadState): boolean {
export function shouldLoadXiaobaoStoreFallback(state: V22XiaobaoSummaryLoadState): boolean {
return state === 'empty' || state === 'failed';
}