refactor(data): 收口关系表运行时数据源
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
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:
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
apps/web/lib/governance-source.test.ts
Normal file
21
apps/web/lib/governance-source.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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+\}/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
89
apps/web/lib/no-broad-business-fetch-source.test.ts
Normal file
89
apps/web/lib/no-broad-business-fetch-source.test.ts
Normal 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');
|
||||
});
|
||||
66
apps/web/lib/no-business-appdata-runtime-source.test.ts
Normal file
66
apps/web/lib/no-business-appdata-runtime-source.test.ts
Normal 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 \}\)/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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\(\);/);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface VersionMember {
|
||||
export interface VersionMemberCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
username?: string;
|
||||
departmentName?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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\(\);/);
|
||||
});
|
||||
|
||||
50
apps/web/lib/version-plan-owner-source.test.ts
Normal file
50
apps/web/lib/version-plan-owner-source.test.ts
Normal 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/);
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
]), '1h(0.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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user