feat(v2.2): 完成高频读取热路径
This commit is contained in:
90
apps/web/lib/requirement-v22-query.test.ts
Normal file
90
apps/web/lib/requirement-v22-query.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildV22RequirementQuery } from './requirement-v22-query';
|
||||
|
||||
test('buildV22RequirementQuery creates a product-scoped query with server-side filters', () => {
|
||||
const query = buildV22RequirementQuery({
|
||||
selectedScope: { type: 'product', productId: 'product-1' },
|
||||
projects: [],
|
||||
statusFilter: 'adopted',
|
||||
priorityFilter: 'P1',
|
||||
typeFilter: 'feature',
|
||||
versionFilter: 'version-1',
|
||||
search: 'login',
|
||||
dateSort: 'asc',
|
||||
limit: 50,
|
||||
cursor: 'req-1',
|
||||
});
|
||||
|
||||
assert.deepEqual(query, {
|
||||
productId: 'product-1',
|
||||
status: 'adopted',
|
||||
priority: 'P1',
|
||||
type: 'feature',
|
||||
versionId: 'version-1',
|
||||
q: 'login',
|
||||
sort: 'created_at_asc',
|
||||
limit: 50,
|
||||
cursor: 'req-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildV22RequirementQuery resolves project scope to product and project partition keys', () => {
|
||||
const query = buildV22RequirementQuery({
|
||||
selectedScope: { type: 'project', projectId: 'project-1' },
|
||||
projects: [{ id: 'project-1', productId: 'product-1' }],
|
||||
statusFilter: 'all',
|
||||
priorityFilter: 'all',
|
||||
typeFilter: 'all',
|
||||
versionFilter: 'all',
|
||||
search: ' ',
|
||||
dateSort: 'desc',
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
assert.deepEqual(query, {
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
sort: 'created_at_desc',
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test('buildV22RequirementQuery skips scopes that would require full scans or derived status filters', () => {
|
||||
assert.equal(buildV22RequirementQuery({
|
||||
selectedScope: { type: 'all' },
|
||||
projects: [],
|
||||
statusFilter: 'all',
|
||||
priorityFilter: 'all',
|
||||
typeFilter: 'all',
|
||||
versionFilter: 'all',
|
||||
search: '',
|
||||
dateSort: 'desc',
|
||||
limit: 20,
|
||||
}), undefined);
|
||||
|
||||
assert.equal(buildV22RequirementQuery({
|
||||
selectedScope: { type: 'product', productId: 'product-1' },
|
||||
projects: [],
|
||||
statusFilter: 'dev_completed',
|
||||
priorityFilter: 'all',
|
||||
typeFilter: 'all',
|
||||
versionFilter: 'all',
|
||||
search: '',
|
||||
dateSort: 'desc',
|
||||
limit: 20,
|
||||
}), undefined);
|
||||
|
||||
assert.equal(buildV22RequirementQuery({
|
||||
selectedScope: { type: 'project', projectId: 'missing-project' },
|
||||
projects: [{ id: 'project-1', productId: 'product-1' }],
|
||||
statusFilter: 'all',
|
||||
priorityFilter: 'all',
|
||||
typeFilter: 'all',
|
||||
versionFilter: 'all',
|
||||
search: '',
|
||||
dateSort: 'desc',
|
||||
limit: 20,
|
||||
}), undefined);
|
||||
});
|
||||
73
apps/web/lib/requirement-v22-query.ts
Normal file
73
apps/web/lib/requirement-v22-query.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { RequirementScopeSelection } from './requirement-scope';
|
||||
import type { RequirementDateSort } from './requirement-sort';
|
||||
import type { V22RequirementsQuery } from './v22-api';
|
||||
|
||||
export interface RequirementV22ProjectRef {
|
||||
id: string;
|
||||
productId: string;
|
||||
}
|
||||
|
||||
export interface BuildV22RequirementQueryInput {
|
||||
selectedScope: RequirementScopeSelection;
|
||||
projects: RequirementV22ProjectRef[];
|
||||
statusFilter: string;
|
||||
priorityFilter: string;
|
||||
typeFilter: string;
|
||||
versionFilter: string;
|
||||
search: string;
|
||||
dateSort: RequirementDateSort;
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export function buildV22RequirementQuery(input: BuildV22RequirementQueryInput): V22RequirementsQuery | undefined {
|
||||
if (input.statusFilter === 'dev_completed') return undefined;
|
||||
|
||||
const scoped = getPartitionScope(input.selectedScope, input.projects);
|
||||
if (!scoped) return undefined;
|
||||
|
||||
return compactQuery({
|
||||
...scoped,
|
||||
status: input.statusFilter === 'all' ? undefined : input.statusFilter,
|
||||
priority: input.priorityFilter === 'all' ? undefined : input.priorityFilter,
|
||||
type: input.typeFilter === 'all' ? undefined : input.typeFilter,
|
||||
versionId: input.versionFilter === 'all' ? undefined : input.versionFilter,
|
||||
q: input.search.trim() || undefined,
|
||||
sort: input.dateSort === 'asc' ? 'created_at_asc' : 'created_at_desc',
|
||||
cursor: input.cursor,
|
||||
limit: input.limit,
|
||||
});
|
||||
}
|
||||
|
||||
function getPartitionScope(
|
||||
selectedScope: RequirementScopeSelection,
|
||||
projects: RequirementV22ProjectRef[],
|
||||
): Pick<V22RequirementsQuery, 'productId' | 'projectId'> | undefined {
|
||||
if (selectedScope.type === 'product') {
|
||||
const productId = selectedScope.productId.trim();
|
||||
return productId ? { productId } : undefined;
|
||||
}
|
||||
|
||||
if (selectedScope.type === 'project') {
|
||||
const projectId = selectedScope.projectId.trim();
|
||||
const project = projects.find((item) => item.id === projectId);
|
||||
if (!project?.productId) return undefined;
|
||||
return { productId: project.productId, projectId };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compactQuery(query: V22RequirementsQuery): V22RequirementsQuery {
|
||||
const result: V22RequirementsQuery = { productId: query.productId };
|
||||
if (query.projectId) result.projectId = query.projectId;
|
||||
if (query.versionId) result.versionId = query.versionId;
|
||||
if (query.status) result.status = query.status;
|
||||
if (query.priority) result.priority = query.priority;
|
||||
if (query.type) result.type = query.type;
|
||||
if (query.q) result.q = query.q;
|
||||
if (query.sort) result.sort = query.sort;
|
||||
if (query.cursor) result.cursor = query.cursor;
|
||||
if (query.limit) result.limit = query.limit;
|
||||
return result;
|
||||
}
|
||||
204
apps/web/lib/v22-api.test.ts
Normal file
204
apps/web/lib/v22-api.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { __resetApiAvailabilityForTests, resolveApiBase } from './api';
|
||||
import {
|
||||
loadV22RequirementsPage,
|
||||
loadV22VersionDetailData,
|
||||
loadV22WorkspaceData,
|
||||
loadV22XiaobaoWarnings,
|
||||
} from './v22-api';
|
||||
|
||||
test('V2.2 version detail API maps relation rows to existing frontend scope types', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const apiBase = resolveApiBase();
|
||||
const calls: Array<{ url: string; method: string }> = [];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, method: init?.method ?? 'GET' });
|
||||
if (url === `${apiBase}/config/ai`) {
|
||||
return jsonResponse({});
|
||||
}
|
||||
if (url === `${apiBase}/v2.2/versions/version-1/detail-data`) {
|
||||
return jsonResponse({
|
||||
version: {
|
||||
id: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
name: 'Project A V1.0',
|
||||
releaseDate: '2026-01-20T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
requirements: [{
|
||||
id: 'req-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
code: 'REQ-001',
|
||||
title: 'Login',
|
||||
description: 'Password login',
|
||||
status: 'adopted',
|
||||
priority: 1,
|
||||
type: 'feature',
|
||||
sourceType: 'customer',
|
||||
sourceTarget: 'ACME',
|
||||
platform: 'web, ios',
|
||||
creatorId: 'member-1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}],
|
||||
versionPlans: [{
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
type: 'research',
|
||||
title: 'Research login',
|
||||
status: 'pending',
|
||||
ownerId: 'member-1',
|
||||
expectedStartAt: '2026-01-01',
|
||||
expectedEndAt: '2026-01-05T18:00:00.000Z',
|
||||
requirementCoverage: [{ requirementId: 'req-1', status: 'completed', updatedAt: '2026-01-02T00:00:00.000Z', updatedBy: 'member-1' }],
|
||||
logs: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}],
|
||||
devTasks: [{
|
||||
id: 'dev-1',
|
||||
versionId: 'version-1',
|
||||
requirementId: 'req-1',
|
||||
categoryId: 'cat-dev',
|
||||
code: 'DEV-001',
|
||||
title: 'Build login',
|
||||
description: 'Implement login',
|
||||
status: 'in_progress',
|
||||
priority: 2,
|
||||
assigneeId: 'member-2',
|
||||
creatorId: 'member-1',
|
||||
isBlocked: false,
|
||||
expectedStartAt: '2026-01-02T09:00:00.000Z',
|
||||
expectedEndAt: '2026-01-03T18:00:00.000Z',
|
||||
startDate: '2026-01-02T09:00:00.000Z',
|
||||
completedAt: null,
|
||||
estimateHours: 8,
|
||||
references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }],
|
||||
aiDraft: false,
|
||||
createdAt: '2026-01-02T08:00:00.000Z',
|
||||
updatedAt: '2026-01-02T09:00:00.000Z',
|
||||
}],
|
||||
testCases: [{
|
||||
id: 'tc-1',
|
||||
versionId: 'version-1',
|
||||
requirementId: 'req-1',
|
||||
categoryId: 'cat-test',
|
||||
code: 'TC-001',
|
||||
title: 'Verify login',
|
||||
description: 'Check password login',
|
||||
status: 'pending',
|
||||
roundNo: 1,
|
||||
priority: 3,
|
||||
assigneeId: 'member-3',
|
||||
creatorId: 'member-1',
|
||||
references: [],
|
||||
aiDraft: false,
|
||||
createdAt: '2026-01-03T08:00:00.000Z',
|
||||
updatedAt: '2026-01-03T08:00:00.000Z',
|
||||
}],
|
||||
bugs: [{
|
||||
id: 'bug-1',
|
||||
versionId: 'version-1',
|
||||
testCaseId: 'tc-1',
|
||||
code: 'BUG-001',
|
||||
title: 'Login error',
|
||||
description: 'Wrong prompt',
|
||||
status: 'open',
|
||||
severity: 'major',
|
||||
priority: 0,
|
||||
assigneeId: 'member-2',
|
||||
reporterId: 'member-3',
|
||||
createdAt: '2026-01-04T08:00:00.000Z',
|
||||
updatedAt: '2026-01-04T08:00:00.000Z',
|
||||
}],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
__resetApiAvailabilityForTests();
|
||||
const result = await loadV22VersionDetailData('version-1');
|
||||
|
||||
assert.equal(calls[1].url, `${apiBase}/v2.2/versions/version-1/detail-data`);
|
||||
assert.equal(result.scope.requirements[0].typeId, 'feature');
|
||||
assert.deepEqual(result.scope.requirements[0].platforms, ['web', 'ios']);
|
||||
assert.equal(result.scope.requirements[0].priority, 'P1');
|
||||
assert.equal(result.scope.requirements[0].creator, 'member-1');
|
||||
assert.equal(result.scope.devTasks[0].taskNo, 'DEV-001');
|
||||
assert.equal(result.scope.devTasks[0].actualStartAt, '2026-01-02T09:00:00.000Z');
|
||||
assert.equal(result.scope.devTasks[0].actualEndAt, undefined);
|
||||
assert.equal(result.scope.devTasks[0].createdBy, 'member-1');
|
||||
assert.equal(result.scope.testCases[0].caseNo, 'TC-001');
|
||||
assert.equal(result.scope.testCases[0].createdBy, 'member-1');
|
||||
assert.equal(result.scope.bugs[0].bugNo, 'BUG-001');
|
||||
assert.equal(result.scope.bugs[0].reportedBy, 'member-3');
|
||||
assert.equal(result.scope.plans[0].owner, 'member-1');
|
||||
assert.equal(result.scope.plans[0].startTime, '2026-01-01T00:00:00.000Z');
|
||||
assert.equal(result.scope.plansByType.research[0].id, 'plan-1');
|
||||
assert.equal(result.scope.requirementIdSet.has('req-1'), true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('V2.2 list APIs build scoped query strings without full-table reads', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const apiBase = resolveApiBase();
|
||||
const urls: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (url === `${apiBase}/config/ai`) return jsonResponse({});
|
||||
if (url.startsWith(`${apiBase}/v2.2/requirements?`)) return jsonResponse({ items: [], nextCursor: 'req-2' });
|
||||
if (url.startsWith(`${apiBase}/v2.2/workspace?`)) return jsonResponse({ versionPlans: [], devTasks: [], testCases: [], bugs: [] });
|
||||
if (url.startsWith(`${apiBase}/v2.2/xiaobao-warning?`)) return jsonResponse([]);
|
||||
throw new Error(`Unexpected fetch ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
__resetApiAvailabilityForTests();
|
||||
await loadV22RequirementsPage({
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
status: 'adopted',
|
||||
priority: 'P1',
|
||||
type: 'feature',
|
||||
q: '登录',
|
||||
sort: 'created_at_asc',
|
||||
cursor: 'req-1',
|
||||
limit: 50,
|
||||
});
|
||||
await loadV22WorkspaceData('member-1');
|
||||
await loadV22XiaobaoWarnings({ userId: 'member-1' });
|
||||
|
||||
const requirementUrl = new URL(urls[1]);
|
||||
assert.equal(requirementUrl.pathname.endsWith('/v2.2/requirements'), true);
|
||||
assert.equal(requirementUrl.searchParams.get('productId'), 'product-1');
|
||||
assert.equal(requirementUrl.searchParams.get('projectId'), 'project-1');
|
||||
assert.equal(requirementUrl.searchParams.get('status'), 'adopted');
|
||||
assert.equal(requirementUrl.searchParams.get('priority'), 'P1');
|
||||
assert.equal(requirementUrl.searchParams.get('type'), 'feature');
|
||||
assert.equal(requirementUrl.searchParams.get('q'), '登录');
|
||||
assert.equal(requirementUrl.searchParams.get('sort'), 'created_at_asc');
|
||||
assert.equal(requirementUrl.searchParams.get('cursor'), 'req-1');
|
||||
assert.equal(requirementUrl.searchParams.get('limit'), '50');
|
||||
assert.equal(new URL(urls[2]).searchParams.get('userId'), 'member-1');
|
||||
assert.equal(new URL(urls[3]).searchParams.get('userId'), 'member-1');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
481
apps/web/lib/v22-api.ts
Normal file
481
apps/web/lib/v22-api.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import { api } from './api';
|
||||
import type { Bug, BugSeverity, BugStatus } from './bug';
|
||||
import type { DevTask, DevTaskStatus, Reference } from './dev-task';
|
||||
import type { Priority } from './derive';
|
||||
import type { Requirement, RequirementStatus, SourceType } from './requirement';
|
||||
import type { TestCase, TestCaseStatus } from './test-case';
|
||||
import type { VersionDataScope } from './version-data-scope';
|
||||
import type {
|
||||
PlanTask,
|
||||
VersionPlan,
|
||||
VersionPlanLog,
|
||||
VersionPlanRequirementCoverage,
|
||||
} from './version-plan';
|
||||
|
||||
type V22DateValue = string | Date | null | undefined;
|
||||
|
||||
interface V22VersionRow {
|
||||
id: string;
|
||||
productId: string;
|
||||
projectId?: string | null;
|
||||
name: string;
|
||||
releaseDate?: V22DateValue;
|
||||
createdAt?: V22DateValue;
|
||||
updatedAt?: V22DateValue;
|
||||
}
|
||||
|
||||
interface V22RequirementRow {
|
||||
id: string;
|
||||
productId: string;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
code: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status?: string | null;
|
||||
priority?: number | string | null;
|
||||
type?: string | null;
|
||||
sourceType?: string | null;
|
||||
sourceTarget?: string | null;
|
||||
platform?: string | null;
|
||||
creatorId?: string | null;
|
||||
createdAt?: V22DateValue;
|
||||
updatedAt?: V22DateValue;
|
||||
}
|
||||
|
||||
interface V22VersionPlanRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
type: string;
|
||||
title: string;
|
||||
status?: string | null;
|
||||
ownerId?: string | null;
|
||||
expectedStartAt?: V22DateValue;
|
||||
expectedEndAt?: V22DateValue;
|
||||
actualStartAt?: V22DateValue;
|
||||
completedAt?: V22DateValue;
|
||||
resultUrl?: string | null;
|
||||
requirementCoverage?: unknown;
|
||||
logs?: unknown;
|
||||
createdAt?: V22DateValue;
|
||||
updatedAt?: V22DateValue;
|
||||
}
|
||||
|
||||
interface V22DevTaskRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
requirementId?: string | null;
|
||||
categoryId?: string | null;
|
||||
code: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status?: string | null;
|
||||
priority?: number | string | null;
|
||||
assigneeId?: string | null;
|
||||
creatorId?: string | null;
|
||||
isBlocked?: boolean | null;
|
||||
blockReason?: string | null;
|
||||
expectedStartAt?: V22DateValue;
|
||||
expectedEndAt?: V22DateValue;
|
||||
startDate?: V22DateValue;
|
||||
completedAt?: V22DateValue;
|
||||
estimateHours?: number | null;
|
||||
aiEstimateHours?: number | null;
|
||||
references?: unknown;
|
||||
aiDraft?: boolean | null;
|
||||
aiDraftAt?: V22DateValue;
|
||||
createdAt?: V22DateValue;
|
||||
updatedAt?: V22DateValue;
|
||||
}
|
||||
|
||||
interface V22TestCaseRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
requirementId?: string | null;
|
||||
categoryId?: string | null;
|
||||
code: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status?: string | null;
|
||||
roundNo?: number | null;
|
||||
priority?: number | string | null;
|
||||
assigneeId?: string | null;
|
||||
creatorId?: string | null;
|
||||
plannedTestAt?: V22DateValue;
|
||||
plannedEndAt?: V22DateValue;
|
||||
startedAt?: V22DateValue;
|
||||
completedAt?: V22DateValue;
|
||||
estimateHours?: number | null;
|
||||
aiEstimateHours?: number | null;
|
||||
references?: unknown;
|
||||
aiDraft?: boolean | null;
|
||||
aiDraftAt?: V22DateValue;
|
||||
createdAt?: V22DateValue;
|
||||
updatedAt?: V22DateValue;
|
||||
}
|
||||
|
||||
interface V22BugRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
testCaseId?: string | null;
|
||||
code: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
status?: string | null;
|
||||
severity?: string | null;
|
||||
priority?: number | string | null;
|
||||
assigneeId?: string | null;
|
||||
reporterId?: string | null;
|
||||
plannedFixAt?: V22DateValue;
|
||||
resolvedAt?: V22DateValue;
|
||||
closedAt?: V22DateValue;
|
||||
resolution?: string | null;
|
||||
createdAt?: V22DateValue;
|
||||
updatedAt?: V22DateValue;
|
||||
}
|
||||
|
||||
interface V22VersionDetailResponse {
|
||||
version: V22VersionRow;
|
||||
requirements: V22RequirementRow[];
|
||||
versionPlans: V22VersionPlanRow[];
|
||||
devTasks: V22DevTaskRow[];
|
||||
testCases: V22TestCaseRow[];
|
||||
bugs: V22BugRow[];
|
||||
}
|
||||
|
||||
export interface V22VersionDetailData {
|
||||
version: V22VersionRow;
|
||||
scope: VersionDataScope;
|
||||
}
|
||||
|
||||
export interface V22RequirementsQuery {
|
||||
productId: string;
|
||||
projectId?: string;
|
||||
versionId?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
type?: string;
|
||||
q?: string;
|
||||
sort?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface V22RequirementsPage {
|
||||
items: Requirement[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface V22WorkspaceData {
|
||||
versionPlans: VersionPlan[];
|
||||
devTasks: DevTask[];
|
||||
testCases: TestCase[];
|
||||
bugs: Bug[];
|
||||
}
|
||||
|
||||
export interface V22XiaobaoWarningQuery {
|
||||
userId?: string;
|
||||
manager?: boolean;
|
||||
}
|
||||
|
||||
export interface V22XiaobaoWarningSummary {
|
||||
versionId: string;
|
||||
riskLevel: string;
|
||||
riskScore: number;
|
||||
confidence: number;
|
||||
forecastReleaseDate?: string;
|
||||
riskSignature: string;
|
||||
summary: unknown;
|
||||
dirty?: boolean;
|
||||
recomputedAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export async function loadV22VersionDetailData(versionId: string): Promise<V22VersionDetailData> {
|
||||
const response = await api.get<V22VersionDetailResponse>(
|
||||
`/v2.2/versions/${encodeURIComponent(versionId)}/detail-data`,
|
||||
);
|
||||
return {
|
||||
version: response.version,
|
||||
scope: buildScope(response),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadV22RequirementsPage(query: V22RequirementsQuery): Promise<V22RequirementsPage> {
|
||||
const response = await api.get<{ items: V22RequirementRow[]; nextCursor?: string }>(
|
||||
`/v2.2/requirements?${queryString(query)}`,
|
||||
);
|
||||
return {
|
||||
items: response.items.map(mapRequirement),
|
||||
nextCursor: response.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadV22WorkspaceData(userId: string): Promise<V22WorkspaceData> {
|
||||
const response = await api.get<{
|
||||
versionPlans: V22VersionPlanRow[];
|
||||
devTasks: V22DevTaskRow[];
|
||||
testCases: V22TestCaseRow[];
|
||||
bugs: V22BugRow[];
|
||||
}>(`/v2.2/workspace?${queryString({ userId })}`);
|
||||
return {
|
||||
versionPlans: response.versionPlans.map(mapVersionPlan),
|
||||
devTasks: response.devTasks.map(mapDevTask),
|
||||
testCases: response.testCases.map(mapTestCase),
|
||||
bugs: response.bugs.map(mapBug),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadV22XiaobaoWarnings(
|
||||
query: V22XiaobaoWarningQuery,
|
||||
): Promise<V22XiaobaoWarningSummary[]> {
|
||||
const response = await api.get<Array<Omit<V22XiaobaoWarningSummary, 'forecastReleaseDate' | 'recomputedAt' | 'updatedAt'> & {
|
||||
forecastReleaseDate?: V22DateValue;
|
||||
recomputedAt?: V22DateValue;
|
||||
updatedAt?: V22DateValue;
|
||||
}>>(`/v2.2/xiaobao-warning?${queryString({
|
||||
userId: query.userId,
|
||||
manager: query.manager ? 'true' : undefined,
|
||||
})}`);
|
||||
return response.map((row) => ({
|
||||
...row,
|
||||
forecastReleaseDate: optionalIso(row.forecastReleaseDate),
|
||||
recomputedAt: optionalIso(row.recomputedAt),
|
||||
updatedAt: optionalIso(row.updatedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildScope(response: V22VersionDetailResponse): VersionDataScope {
|
||||
const requirements = response.requirements.map(mapRequirement);
|
||||
const requirementIds = requirements.map((requirement) => requirement.id);
|
||||
const requirementIdSet = new Set(requirementIds);
|
||||
const plans = response.versionPlans.map(mapVersionPlan);
|
||||
const plansByType: VersionDataScope['plansByType'] = { research: [], product: [], ui: [] };
|
||||
for (const plan of plans) {
|
||||
plansByType[plan.type].push(plan);
|
||||
}
|
||||
|
||||
return {
|
||||
requirements,
|
||||
requirementIds,
|
||||
requirementIdSet,
|
||||
plans,
|
||||
plansByType,
|
||||
devTasks: response.devTasks.map(mapDevTask),
|
||||
testCases: response.testCases.map(mapTestCase),
|
||||
bugs: response.bugs.map(mapBug),
|
||||
overtimeRecords: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mapRequirement(row: V22RequirementRow): Requirement {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
title: row.title,
|
||||
description: row.description ?? '',
|
||||
productId: row.productId,
|
||||
projectId: row.projectId ?? '',
|
||||
versionId: row.versionId ?? undefined,
|
||||
sourceType: toSourceType(row.sourceType),
|
||||
sourceTarget: row.sourceTarget ?? '',
|
||||
platforms: splitCsv(row.platform),
|
||||
typeId: row.type ?? '',
|
||||
status: toRequirementStatus(row.status),
|
||||
priority: toPriority(row.priority),
|
||||
effort: 'M',
|
||||
creator: row.creatorId ?? '',
|
||||
createdAt: requiredIso(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function mapVersionPlan(row: V22VersionPlanRow): VersionPlan {
|
||||
const coverage = asArray<VersionPlanRequirementCoverage>(row.requirementCoverage);
|
||||
return {
|
||||
id: row.id,
|
||||
versionId: row.versionId,
|
||||
type: toPlanType(row.type),
|
||||
title: row.title,
|
||||
owner: row.ownerId ?? '',
|
||||
startTime: requiredIso(row.expectedStartAt),
|
||||
endTime: requiredIso(row.expectedEndAt),
|
||||
status: toPlanStatus(row.status),
|
||||
tasks: [],
|
||||
completedRequirementIds: coverage
|
||||
.filter((item) => item?.status === 'completed')
|
||||
.map((item) => item.requirementId)
|
||||
.filter(Boolean),
|
||||
linkedRequirementIds: coverage.map((item) => item?.requirementId).filter(Boolean),
|
||||
requirementCoverage: coverage,
|
||||
logs: asArray<VersionPlanLog>(row.logs),
|
||||
resultUrl: row.resultUrl ?? undefined,
|
||||
actualStartAt: optionalIso(row.actualStartAt),
|
||||
createdAt: requiredIso(row.createdAt),
|
||||
completedAt: optionalIso(row.completedAt),
|
||||
addedBy: row.ownerId ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function mapDevTask(row: V22DevTaskRow): DevTask {
|
||||
return {
|
||||
id: row.id,
|
||||
taskNo: row.code,
|
||||
versionId: row.versionId,
|
||||
requirementId: row.requirementId ?? '',
|
||||
title: row.title,
|
||||
description: row.description ?? '',
|
||||
categoryId: row.categoryId ?? '',
|
||||
assigneeId: row.assigneeId ?? '',
|
||||
priority: toPriority(row.priority),
|
||||
expectedStartAt: requiredIso(row.expectedStartAt),
|
||||
expectedEndAt: requiredIso(row.expectedEndAt),
|
||||
estimateHours: row.estimateHours ?? undefined,
|
||||
aiEstimateHours: row.aiEstimateHours ?? undefined,
|
||||
actualStartAt: optionalIso(row.startDate),
|
||||
actualEndAt: optionalIso(row.completedAt),
|
||||
status: toDevTaskStatus(row.status),
|
||||
isBlocked: row.isBlocked ?? false,
|
||||
blockReason: row.blockReason ?? undefined,
|
||||
references: asArray<Reference>(row.references),
|
||||
aiDraft: row.aiDraft ?? false,
|
||||
aiDraftAt: optionalIso(row.aiDraftAt),
|
||||
createdBy: row.creatorId ?? '',
|
||||
createdAt: requiredIso(row.createdAt),
|
||||
updatedAt: requiredIso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function mapTestCase(row: V22TestCaseRow): TestCase {
|
||||
return {
|
||||
id: row.id,
|
||||
caseNo: row.code,
|
||||
versionId: row.versionId,
|
||||
requirementId: row.requirementId ?? undefined,
|
||||
roundNo: row.roundNo ?? 1,
|
||||
title: row.title,
|
||||
description: row.description ?? '',
|
||||
categoryId: row.categoryId ?? '',
|
||||
priority: toPriority(row.priority),
|
||||
assigneeId: row.assigneeId ?? undefined,
|
||||
status: toTestCaseStatus(row.status),
|
||||
estimateHours: row.estimateHours ?? undefined,
|
||||
aiEstimateHours: row.aiEstimateHours ?? undefined,
|
||||
plannedTestAt: optionalIso(row.plannedTestAt),
|
||||
plannedEndAt: optionalIso(row.plannedEndAt),
|
||||
startedAt: optionalIso(row.startedAt),
|
||||
completedAt: optionalIso(row.completedAt),
|
||||
references: asArray<Reference>(row.references),
|
||||
aiDraft: row.aiDraft ?? false,
|
||||
aiDraftAt: optionalIso(row.aiDraftAt),
|
||||
createdBy: row.creatorId ?? '',
|
||||
createdAt: requiredIso(row.createdAt),
|
||||
updatedAt: requiredIso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function mapBug(row: V22BugRow): Bug {
|
||||
return {
|
||||
id: row.id,
|
||||
bugNo: row.code,
|
||||
versionId: row.versionId,
|
||||
testCaseId: row.testCaseId ?? '',
|
||||
title: row.title,
|
||||
description: row.description ?? '',
|
||||
severity: toBugSeverity(row.severity),
|
||||
priority: toPriority(row.priority),
|
||||
reportedBy: row.reporterId ?? '',
|
||||
assigneeId: row.assigneeId ?? '',
|
||||
status: toBugStatus(row.status),
|
||||
resolvedAt: optionalIso(row.resolvedAt),
|
||||
closedAt: optionalIso(row.closedAt),
|
||||
resolution: row.resolution ?? undefined,
|
||||
plannedFixAt: optionalIso(row.plannedFixAt),
|
||||
createdAt: requiredIso(row.createdAt),
|
||||
updatedAt: requiredIso(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function queryString<T extends object>(values: T): string {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value === undefined || value === '') return;
|
||||
params.set(key, String(value));
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function requiredIso(value: V22DateValue): string {
|
||||
return optionalIso(value) ?? '';
|
||||
}
|
||||
|
||||
function optionalIso(value: V22DateValue): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return `${trimmed}T00:00:00.000Z`;
|
||||
const parsed = new Date(trimmed);
|
||||
if (Number.isFinite(parsed.getTime())) return parsed.toISOString();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function splitCsv(value?: string | null): string[] {
|
||||
if (!value) return [];
|
||||
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function asArray<T>(value: unknown): T[] {
|
||||
return Array.isArray(value) ? value as T[] : [];
|
||||
}
|
||||
|
||||
function toPriority(value: number | string | null | undefined): Priority {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
if (/^P[0-4]$/.test(normalized)) return normalized as Priority;
|
||||
const parsed = Number(normalized);
|
||||
if (Number.isFinite(parsed)) return toPriority(parsed);
|
||||
}
|
||||
const rank = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 2;
|
||||
return `P${Math.max(0, Math.min(4, rank))}` as Priority;
|
||||
}
|
||||
|
||||
function toSourceType(value?: string | null): SourceType {
|
||||
const allowed: SourceType[] = ['customer', 'internal', 'operation', 'aftersale', 'market', 'competitor', 'management'];
|
||||
return allowed.includes(value as SourceType) ? value as SourceType : 'internal';
|
||||
}
|
||||
|
||||
function toRequirementStatus(value?: string | null): RequirementStatus {
|
||||
const allowed: RequirementStatus[] = ['pending_review', 'adopted', 'rejected', 'planned', 'developing', 'testing', 'released', 'closed'];
|
||||
return allowed.includes(value as RequirementStatus) ? value as RequirementStatus : 'pending_review';
|
||||
}
|
||||
|
||||
function toPlanType(value: string): VersionPlan['type'] {
|
||||
return value === 'research' || value === 'ui' ? value : 'product';
|
||||
}
|
||||
|
||||
function toPlanStatus(value?: string | null): VersionPlan['status'] {
|
||||
if (value === 'in_progress' || value === 'completed') return value;
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function toDevTaskStatus(value?: string | null): DevTaskStatus {
|
||||
if (value === 'in_progress' || value === 'testing' || value === 'submitted') return value;
|
||||
return 'todo';
|
||||
}
|
||||
|
||||
function toTestCaseStatus(value?: string | null): TestCaseStatus {
|
||||
if (value === 'running' || value === 'passed' || value === 'failed' || value === 'blocked') return value;
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function toBugStatus(value?: string | null): BugStatus {
|
||||
if (value === 'fixing' || value === 'fixed' || value === 'verifying' || value === 'closed' || value === 'rejected') {
|
||||
return value;
|
||||
}
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function toBugSeverity(value?: string | null): BugSeverity {
|
||||
if (value === 'critical' || value === 'major' || value === 'trivial') return value;
|
||||
return 'minor';
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildVersionDataScope, buildVersionDataScopeMap } from './version-data-scope';
|
||||
import { buildVersionDataScope, buildVersionDataScopeMap, selectVersionDataScope } from './version-data-scope';
|
||||
import type { Bug } from './bug';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { OvertimeRecord } from './overtime';
|
||||
@@ -169,3 +169,53 @@ test('buildVersionDataScopeMap indexes many versions in one pass', () => {
|
||||
assert.deepEqual(scopeMap['version-empty'].requirements, []);
|
||||
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({
|
||||
versionId: 'version-1',
|
||||
requirements: [requirement('req-app', 'version-1')],
|
||||
plans: [plan('plan-app', 'version-1', 'research')],
|
||||
devTasks: [devTask('dev-app', { versionId: 'version-1' })],
|
||||
testCases: [testCase('tc-app', 'version-1')],
|
||||
bugs: [bug('bug-app', 'version-1')],
|
||||
overtimeRecords: [overtime('ot-app', 'version-1')],
|
||||
});
|
||||
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' })],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
overtimeRecords: [],
|
||||
});
|
||||
|
||||
const selected = selectVersionDataScope({ appDataScope, 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({
|
||||
versionId: 'version-1',
|
||||
requirements: [requirement('req-app', 'version-1')],
|
||||
plans: [],
|
||||
devTasks: [],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
});
|
||||
const emptyV22Scope = buildVersionDataScope({
|
||||
versionId: 'version-1',
|
||||
requirements: [],
|
||||
plans: [],
|
||||
devTasks: [],
|
||||
testCases: [],
|
||||
bugs: [],
|
||||
});
|
||||
|
||||
const selected = selectVersionDataScope({ appDataScope, v22Scope: emptyV22Scope });
|
||||
|
||||
assert.equal(selected?.requirements[0].id, 'req-app');
|
||||
});
|
||||
|
||||
@@ -77,6 +77,31 @@ export function buildVersionDataScope(input: VersionDataScopeInput): VersionData
|
||||
};
|
||||
}
|
||||
|
||||
export function hasVersionDataScopeRows(scope: VersionDataScope | null | undefined): scope is VersionDataScope {
|
||||
if (!scope) return false;
|
||||
return (
|
||||
scope.requirements.length > 0 ||
|
||||
scope.plans.length > 0 ||
|
||||
scope.devTasks.length > 0 ||
|
||||
scope.testCases.length > 0 ||
|
||||
scope.bugs.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function selectVersionDataScope(input: {
|
||||
appDataScope: VersionDataScope | null;
|
||||
v22Scope?: VersionDataScope | null;
|
||||
}): VersionDataScope | null {
|
||||
const { appDataScope, v22Scope } = input;
|
||||
if (hasVersionDataScopeRows(v22Scope)) {
|
||||
return {
|
||||
...v22Scope,
|
||||
overtimeRecords: appDataScope?.overtimeRecords ?? v22Scope.overtimeRecords,
|
||||
};
|
||||
}
|
||||
return appDataScope;
|
||||
}
|
||||
|
||||
export function buildVersionDataScopeMap(input: VersionDataScopeMapInput): Record<string, VersionDataScope> {
|
||||
const scopes: Record<string, VersionDataScope> = {};
|
||||
const targetVersionIds = new Set(input.versionIds);
|
||||
|
||||
38
apps/web/lib/workspace-v22-source.test.ts
Normal file
38
apps/web/lib/workspace-v22-source.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
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[],
|
||||
};
|
||||
|
||||
test('selectWorkspaceCollections prefers successfully loaded V2.2 data even when it is empty', () => {
|
||||
const selected = selectWorkspaceCollections({
|
||||
v22Loaded: true,
|
||||
v22Failed: false,
|
||||
v22Data: { versionPlans: [], devTasks: [], testCases: [], bugs: [] },
|
||||
appData,
|
||||
});
|
||||
|
||||
assert.deepEqual(selected, { versionPlans: [], devTasks: [], testCases: [], bugs: [] });
|
||||
});
|
||||
|
||||
test('selectWorkspaceCollections falls back to AppData only when V2.2 is unavailable', () => {
|
||||
const selected = selectWorkspaceCollections({
|
||||
v22Loaded: false,
|
||||
v22Failed: true,
|
||||
v22Data: null,
|
||||
appData,
|
||||
});
|
||||
|
||||
assert.deepEqual(selected, {
|
||||
versionPlans: appData.plans,
|
||||
devTasks: appData.devTasks,
|
||||
testCases: appData.testCases,
|
||||
bugs: appData.bugs,
|
||||
});
|
||||
});
|
||||
47
apps/web/lib/workspace-v22-source.ts
Normal file
47
apps/web/lib/workspace-v22-source.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Bug } from './bug';
|
||||
import type { DevTask } from './dev-task';
|
||||
import type { TestCase } from './test-case';
|
||||
import type { V22WorkspaceData } from './v22-api';
|
||||
import type { VersionPlan } from './version-plan';
|
||||
|
||||
export interface WorkspaceCollections {
|
||||
versionPlans: VersionPlan[];
|
||||
devTasks: DevTask[];
|
||||
testCases: TestCase[];
|
||||
bugs: Bug[];
|
||||
}
|
||||
|
||||
export interface SelectWorkspaceCollectionsInput {
|
||||
v22Loaded: boolean;
|
||||
v22Failed: boolean;
|
||||
v22Data: V22WorkspaceData | null;
|
||||
appData: {
|
||||
plans: VersionPlan[];
|
||||
devTasks: DevTask[];
|
||||
testCases: TestCase[];
|
||||
bugs: Bug[];
|
||||
};
|
||||
}
|
||||
|
||||
export function selectWorkspaceCollections({
|
||||
v22Loaded,
|
||||
v22Failed,
|
||||
v22Data,
|
||||
appData,
|
||||
}: SelectWorkspaceCollectionsInput): WorkspaceCollections {
|
||||
if (v22Loaded && !v22Failed && v22Data) {
|
||||
return {
|
||||
versionPlans: v22Data.versionPlans,
|
||||
devTasks: v22Data.devTasks,
|
||||
testCases: v22Data.testCases,
|
||||
bugs: v22Data.bugs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
versionPlans: appData.plans,
|
||||
devTasks: appData.devTasks,
|
||||
testCases: appData.testCases,
|
||||
bugs: appData.bugs,
|
||||
};
|
||||
}
|
||||
196
apps/web/lib/xiaobao-v22-summary.test.ts
Normal file
196
apps/web/lib/xiaobao-v22-summary.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { VersionWithContext } from './derive';
|
||||
import type { V22XiaobaoWarningSummary } from './v22-api';
|
||||
import {
|
||||
buildXiaobaoRisksFromV22Summaries,
|
||||
filterV22XiaobaoSummariesForVisibleVersions,
|
||||
shouldLoadXiaobaoAppDataFallback,
|
||||
} from './xiaobao-v22-summary';
|
||||
|
||||
test('buildXiaobaoRisksFromV22Summaries preserves precomputed risk details with version context', () => {
|
||||
const risks = buildXiaobaoRisksFromV22Summaries({
|
||||
summaries: [{
|
||||
versionId: 'version-1',
|
||||
riskLevel: 'likely_delayed',
|
||||
riskScore: 86,
|
||||
confidence: 72,
|
||||
forecastReleaseDate: '2026-07-09T00:00:00.000Z',
|
||||
riskSignature: 'signature-1',
|
||||
recomputedAt: '2026-07-03T09:00:00.000Z',
|
||||
updatedAt: '2026-07-03T09:05:00.000Z',
|
||||
summary: {
|
||||
delayDays: 2,
|
||||
remainingWorkHours: 30,
|
||||
reasons: [{
|
||||
key: 'forecast_delay',
|
||||
title: 'Forecast delay',
|
||||
detail: 'Forecast release date moved after the expected release date.',
|
||||
severity: 'danger',
|
||||
count: 1,
|
||||
}],
|
||||
silentRisks: [{
|
||||
key: 'no_activity',
|
||||
title: 'No activity',
|
||||
detail: 'No work activity for four days.',
|
||||
itemId: 'dev-1',
|
||||
itemType: 'dev_task',
|
||||
}],
|
||||
signals: {
|
||||
unfinishedCount: 5,
|
||||
openBugCount: 2,
|
||||
criticalBugCount: 1,
|
||||
failedTestCount: 1,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 1,
|
||||
daysToExpectedRelease: 1,
|
||||
},
|
||||
dailyEvidence: {
|
||||
todayDeliveries: [{ id: 'ev-1', title: 'Delivery', summary: 'Submitted core task.', occurredAt: '2026-07-03T08:00:00.000Z' }],
|
||||
todayProgress: [],
|
||||
todayCreations: [],
|
||||
todayRisks: [],
|
||||
progressNotes: [],
|
||||
needsProgressItems: [],
|
||||
recentActivityCount: 3,
|
||||
totalActivityCount: 1,
|
||||
todayActualHours: 2,
|
||||
lastActivityAt: '2026-07-03T08:00:00.000Z',
|
||||
},
|
||||
trend: {
|
||||
direction: 'up',
|
||||
delta: 16,
|
||||
summary: 'Risk rose by 16 points.',
|
||||
pattern: 'score_delta',
|
||||
},
|
||||
currentSnapshot: {
|
||||
date: '2026-07-03',
|
||||
openBugCount: 2,
|
||||
criticalBugCount: 1,
|
||||
failedTestCount: 1,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 1,
|
||||
createdAt: '2026-07-03T09:00:00.000Z',
|
||||
},
|
||||
},
|
||||
}],
|
||||
versions: [version()],
|
||||
snapshots: [],
|
||||
now: new Date('2026-07-03T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(risks.length, 1);
|
||||
assert.equal(risks[0].versionName, 'Payments V1.0');
|
||||
assert.equal(risks[0].productName, 'Payments');
|
||||
assert.equal(risks[0].projectName, 'Wallet');
|
||||
assert.equal(risks[0].expectedReleaseDate, '2026-07-07');
|
||||
assert.equal(risks[0].riskLevel, 'likely_delayed');
|
||||
assert.equal(risks[0].riskScore, 86);
|
||||
assert.equal(risks[0].confidence, 72);
|
||||
assert.equal(risks[0].confidenceLevel, 'medium');
|
||||
assert.equal(risks[0].forecastReleaseDate, '2026-07-09T00:00:00.000Z');
|
||||
assert.equal(risks[0].delayDays, 2);
|
||||
assert.equal(risks[0].remainingWorkHours, 30);
|
||||
assert.equal(risks[0].reasons[0].key, 'forecast_delay');
|
||||
assert.equal(risks[0].silentRisks[0].itemType, 'dev_task');
|
||||
assert.equal(risks[0].signals.openBugCount, 2);
|
||||
assert.equal(risks[0].dailyEvidence?.todayDeliveries[0].summary, 'Submitted core task.');
|
||||
assert.equal(risks[0].currentSnapshot.date, '2026-07-03');
|
||||
assert.equal(risks[0].currentSnapshot.riskScore, 86);
|
||||
assert.equal(risks[0].currentSnapshot.riskLevel, 'likely_delayed');
|
||||
assert.equal(risks[0].trend.summary, 'Risk rose by 16 points.');
|
||||
});
|
||||
|
||||
test('buildXiaobaoRisksFromV22Summaries builds compatible defaults from sparse summaries', () => {
|
||||
const risks = buildXiaobaoRisksFromV22Summaries({
|
||||
summaries: [{
|
||||
versionId: 'version-1',
|
||||
riskLevel: 'at_risk',
|
||||
riskScore: 66,
|
||||
confidence: 48,
|
||||
forecastReleaseDate: '2026-07-09T00:00:00.000Z',
|
||||
riskSignature: 'signature-1',
|
||||
updatedAt: '2026-07-03T09:05:00.000Z',
|
||||
summary: { reasons: 'not-an-array' },
|
||||
}],
|
||||
versions: [version({ expectedReleaseDate: '2026-07-07' })],
|
||||
snapshots: [{
|
||||
versionId: 'version-1',
|
||||
date: '2026-07-01',
|
||||
riskScore: 40,
|
||||
riskLevel: 'attention',
|
||||
openBugCount: 1,
|
||||
criticalBugCount: 0,
|
||||
failedTestCount: 0,
|
||||
blockedCount: 0,
|
||||
silentRiskCount: 0,
|
||||
confidence: 80,
|
||||
createdAt: '2026-07-01T09:00:00.000Z',
|
||||
}],
|
||||
now: new Date('2026-07-03T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
assert.equal(risks[0].delayDays, 2);
|
||||
assert.equal(risks[0].remainingWorkHours, 0);
|
||||
assert.equal(risks[0].confidenceLevel, 'low');
|
||||
assert.equal(risks[0].reasons.length, 1);
|
||||
assert.equal(risks[0].reasons[0].key, 'precomputed_risk');
|
||||
assert.equal(risks[0].signals.unfinishedCount, 0);
|
||||
assert.equal(risks[0].currentSnapshot.openBugCount, 0);
|
||||
assert.equal(risks[0].currentSnapshot.createdAt, '2026-07-03T09:05:00.000Z');
|
||||
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('filterV22XiaobaoSummariesForVisibleVersions keeps only visible version summaries', () => {
|
||||
const summaries = [
|
||||
summary({ versionId: 'visible-1', riskScore: 80 }),
|
||||
summary({ versionId: 'hidden-1', riskScore: 95 }),
|
||||
summary({ versionId: 'visible-2', riskScore: 70 }),
|
||||
];
|
||||
|
||||
const result = filterV22XiaobaoSummariesForVisibleVersions(summaries, [
|
||||
version({ id: 'visible-1' }),
|
||||
version({ id: 'visible-2' }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(result.map((item) => item.versionId), ['visible-1', 'visible-2']);
|
||||
});
|
||||
|
||||
function summary(patch: Partial<V22XiaobaoWarningSummary> = {}): V22XiaobaoWarningSummary {
|
||||
return {
|
||||
versionId: 'version-1',
|
||||
riskLevel: 'at_risk',
|
||||
riskScore: 60,
|
||||
confidence: 80,
|
||||
riskSignature: 'signature',
|
||||
summary: {},
|
||||
updatedAt: '2026-07-03T09:00:00.000Z',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function version(patch: Partial<VersionWithContext> = {}): VersionWithContext {
|
||||
return {
|
||||
id: 'version-1',
|
||||
name: 'Payments V1.0',
|
||||
status: 'developing',
|
||||
releaseDate: null,
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
productId: 'product-1',
|
||||
productName: 'Payments',
|
||||
projectId: 'project-1',
|
||||
projectName: 'Wallet',
|
||||
expectedReleaseDate: '2026-07-07',
|
||||
members: [{ name: 'Alice', role: 'frontend' }],
|
||||
...patch,
|
||||
} as VersionWithContext;
|
||||
}
|
||||
345
apps/web/lib/xiaobao-v22-summary.ts
Normal file
345
apps/web/lib/xiaobao-v22-summary.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import type { VersionWithContext } from './derive';
|
||||
import type { V22XiaobaoWarningSummary } from './v22-api';
|
||||
import type {
|
||||
RiskReason,
|
||||
SilentRisk,
|
||||
XiaobaoConfidenceLevel,
|
||||
XiaobaoRiskLevel,
|
||||
XiaobaoRiskSignals,
|
||||
XiaobaoVersionRisk,
|
||||
} from './xiaobao-risk';
|
||||
import type { EvidenceItem, VersionDailyEvidence } from './xiaobao-risk-evidence';
|
||||
import { summarizeRiskTrendWithCurrent, type XiaobaoRiskSnapshot } from './xiaobao-risk-trend';
|
||||
|
||||
interface BuildXiaobaoRisksFromV22SummariesInput {
|
||||
summaries: V22XiaobaoWarningSummary[];
|
||||
versions: VersionWithContext[];
|
||||
snapshots?: XiaobaoRiskSnapshot[];
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export type V22XiaobaoSummaryLoadState = 'idle' | 'loading' | 'ready' | 'empty' | 'failed';
|
||||
|
||||
const RISK_LEVELS = new Set<XiaobaoRiskLevel>(['on_track', 'attention', 'at_risk', 'likely_delayed', 'blocked']);
|
||||
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 {
|
||||
return state === 'empty' || state === 'failed';
|
||||
}
|
||||
|
||||
export function filterV22XiaobaoSummariesForVisibleVersions(
|
||||
summaries: V22XiaobaoWarningSummary[],
|
||||
versions: VersionWithContext[],
|
||||
): V22XiaobaoWarningSummary[] {
|
||||
const visibleVersionIds = new Set(versions.map((version) => version.id));
|
||||
if (visibleVersionIds.size === 0) return [];
|
||||
return summaries.filter((summary) => visibleVersionIds.has(summary.versionId));
|
||||
}
|
||||
|
||||
export function buildXiaobaoRisksFromV22Summaries(
|
||||
input: BuildXiaobaoRisksFromV22SummariesInput,
|
||||
): XiaobaoVersionRisk[] {
|
||||
const now = input.now ?? new Date();
|
||||
const versionMap = new Map(input.versions.map((version) => [version.id, version]));
|
||||
|
||||
return input.summaries
|
||||
.map((summary) => mapSummaryToRisk(summary, versionMap.get(summary.versionId), input.snapshots ?? [], now))
|
||||
.sort((a, b) => b.riskScore - a.riskScore);
|
||||
}
|
||||
|
||||
function mapSummaryToRisk(
|
||||
row: V22XiaobaoWarningSummary,
|
||||
version: VersionWithContext | undefined,
|
||||
snapshots: XiaobaoRiskSnapshot[],
|
||||
now: Date,
|
||||
): XiaobaoVersionRisk {
|
||||
const payload = getRiskPayload(row.summary);
|
||||
const riskLevel = toRiskLevel(row.riskLevel) ?? toRiskLevel(readString(payload.riskLevel)) ?? 'attention';
|
||||
const riskScore = clampScore(readNumber(payload.riskScore) ?? row.riskScore);
|
||||
const confidence = clampScore(readNumber(payload.confidence) ?? row.confidence);
|
||||
const forecastReleaseDate = readString(payload.forecastReleaseDate) ?? row.forecastReleaseDate;
|
||||
const expectedReleaseDate =
|
||||
readString(payload.expectedReleaseDate)
|
||||
?? version?.expectedReleaseDate
|
||||
?? version?.releaseDate
|
||||
?? null;
|
||||
const delayDays = nonNegativeNumber(readNumber(payload.delayDays)) ?? calcDelayDays(expectedReleaseDate, forecastReleaseDate);
|
||||
const remainingWorkHours = nonNegativeNumber(readNumber(payload.remainingWorkHours)) ?? 0;
|
||||
const dailyEvidence = readDailyEvidence(payload.dailyEvidence);
|
||||
const silentRisks = readSilentRisks(payload.silentRisks);
|
||||
const signals = readSignals(payload.signals, dailyEvidence, silentRisks, expectedReleaseDate, now);
|
||||
const reasons = readReasons(payload.reasons);
|
||||
const sourceTime = row.recomputedAt ?? row.updatedAt ?? now.toISOString();
|
||||
const currentSnapshot = buildCurrentSnapshot({
|
||||
payload,
|
||||
row,
|
||||
riskLevel,
|
||||
riskScore,
|
||||
confidence,
|
||||
forecastReleaseDate,
|
||||
signals,
|
||||
sourceTime,
|
||||
now,
|
||||
});
|
||||
const trend = readTrend(payload.trend)
|
||||
?? summarizeRiskTrendWithCurrent(
|
||||
snapshots.filter((snapshot) => snapshot.versionId === row.versionId),
|
||||
currentSnapshot,
|
||||
);
|
||||
|
||||
return {
|
||||
versionId: row.versionId,
|
||||
versionName: readString(payload.versionName) ?? version?.name ?? row.versionId,
|
||||
productId: readString(payload.productId) ?? version?.productId,
|
||||
productName: readString(payload.productName) ?? version?.productName,
|
||||
projectId: readString(payload.projectId) ?? version?.projectId,
|
||||
projectName: readString(payload.projectName) ?? version?.projectName,
|
||||
riskScore,
|
||||
riskLevel,
|
||||
expectedReleaseDate,
|
||||
confidence,
|
||||
confidenceLevel: getConfidenceLevel(confidence),
|
||||
forecastReleaseDate,
|
||||
delayDays,
|
||||
remainingWorkHours,
|
||||
reasons: reasons.length > 0 ? reasons : buildDefaultReasons(riskLevel, riskScore),
|
||||
silentRisks,
|
||||
dailyEvidence,
|
||||
signals,
|
||||
currentSnapshot,
|
||||
trend,
|
||||
};
|
||||
}
|
||||
|
||||
function getRiskPayload(summary: unknown): Record<string, unknown> {
|
||||
const root = readRecord(summary);
|
||||
const nested = readRecord(root.risk);
|
||||
return Object.keys(nested).length > 0 ? nested : root;
|
||||
}
|
||||
|
||||
function buildCurrentSnapshot(input: {
|
||||
payload: Record<string, unknown>;
|
||||
row: V22XiaobaoWarningSummary;
|
||||
riskLevel: XiaobaoRiskLevel;
|
||||
riskScore: number;
|
||||
confidence: number;
|
||||
forecastReleaseDate?: string;
|
||||
signals: XiaobaoRiskSignals;
|
||||
sourceTime: string;
|
||||
now: Date;
|
||||
}): XiaobaoRiskSnapshot {
|
||||
const snapshot = readRecord(input.payload.currentSnapshot);
|
||||
const createdAt = readString(snapshot.createdAt) ?? input.sourceTime ?? input.now.toISOString();
|
||||
return {
|
||||
versionId: input.row.versionId,
|
||||
date: readString(snapshot.date) ?? createdAt.slice(0, 10),
|
||||
riskScore: clampScore(readNumber(snapshot.riskScore) ?? input.riskScore),
|
||||
riskLevel: toRiskLevel(readString(snapshot.riskLevel)) ?? input.riskLevel,
|
||||
forecastReleaseDate: readString(snapshot.forecastReleaseDate) ?? input.forecastReleaseDate,
|
||||
openBugCount: nonNegativeInteger(readNumber(snapshot.openBugCount)) ?? input.signals.openBugCount,
|
||||
criticalBugCount: nonNegativeInteger(readNumber(snapshot.criticalBugCount)) ?? input.signals.criticalBugCount,
|
||||
failedTestCount: nonNegativeInteger(readNumber(snapshot.failedTestCount)) ?? input.signals.failedTestCount,
|
||||
blockedCount: nonNegativeInteger(readNumber(snapshot.blockedCount)) ?? input.signals.blockedCount,
|
||||
silentRiskCount: nonNegativeInteger(readNumber(snapshot.silentRiskCount)) ?? input.signals.silentRiskCount,
|
||||
confidence: clampScore(readNumber(snapshot.confidence) ?? input.confidence),
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readSignals(
|
||||
value: unknown,
|
||||
dailyEvidence: VersionDailyEvidence | undefined,
|
||||
silentRisks: SilentRisk[],
|
||||
expectedReleaseDate: string | null,
|
||||
now: Date,
|
||||
): XiaobaoRiskSignals {
|
||||
const row = readRecord(value);
|
||||
return {
|
||||
unfinishedCount: nonNegativeInteger(readNumber(row.unfinishedCount)) ?? 0,
|
||||
openBugCount: nonNegativeInteger(readNumber(row.openBugCount)) ?? 0,
|
||||
criticalBugCount: nonNegativeInteger(readNumber(row.criticalBugCount)) ?? 0,
|
||||
failedTestCount: nonNegativeInteger(readNumber(row.failedTestCount)) ?? 0,
|
||||
blockedCount: nonNegativeInteger(readNumber(row.blockedCount)) ?? 0,
|
||||
silentRiskCount:
|
||||
nonNegativeInteger(readNumber(row.silentRiskCount))
|
||||
?? dailyEvidence?.silentRisks?.length
|
||||
?? silentRisks.length,
|
||||
daysToExpectedRelease: readNumber(row.daysToExpectedRelease) ?? calcDaysToExpectedRelease(expectedReleaseDate, now),
|
||||
};
|
||||
}
|
||||
|
||||
function readReasons(value: unknown): RiskReason[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => {
|
||||
const row = readRecord(item);
|
||||
const key = readString(row.key);
|
||||
const title = readString(row.title);
|
||||
const detail = readString(row.detail);
|
||||
if (!key || !title || !detail) return undefined;
|
||||
return {
|
||||
key,
|
||||
title,
|
||||
detail,
|
||||
severity: toReasonSeverity(readString(row.severity)),
|
||||
count: nonNegativeInteger(readNumber(row.count)),
|
||||
};
|
||||
}).filter(isDefined);
|
||||
}
|
||||
|
||||
function readSilentRisks(value: unknown): SilentRisk[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => {
|
||||
const row = readRecord(item);
|
||||
const key = readString(row.key);
|
||||
const title = readString(row.title);
|
||||
const detail = readString(row.detail);
|
||||
if (!key || !title || !detail) return undefined;
|
||||
return {
|
||||
key,
|
||||
title,
|
||||
detail,
|
||||
itemId: readString(row.itemId),
|
||||
itemType: toSilentRiskItemType(readString(row.itemType)),
|
||||
};
|
||||
}).filter(isDefined);
|
||||
}
|
||||
|
||||
function readDailyEvidence(value: unknown): VersionDailyEvidence | undefined {
|
||||
const row = readRecord(value);
|
||||
if (Object.keys(row).length === 0) return undefined;
|
||||
return {
|
||||
todayDeliveries: readEvidenceItems(row.todayDeliveries),
|
||||
todayProgress: readEvidenceItems(row.todayProgress),
|
||||
todayCreations: readEvidenceItems(row.todayCreations),
|
||||
todayRisks: readEvidenceItems(row.todayRisks),
|
||||
progressNotes: readEvidenceItems(row.progressNotes),
|
||||
needsProgressItems: readEvidenceItems(row.needsProgressItems),
|
||||
recentActivityCount: nonNegativeInteger(readNumber(row.recentActivityCount)) ?? 0,
|
||||
totalActivityCount: nonNegativeInteger(readNumber(row.totalActivityCount)) ?? 0,
|
||||
todayActualHours: nonNegativeNumber(readNumber(row.todayActualHours)) ?? 0,
|
||||
lastActivityAt: readString(row.lastActivityAt),
|
||||
silentRisks: readSilentRisks(row.silentRisks),
|
||||
};
|
||||
}
|
||||
|
||||
function readEvidenceItems(value: unknown): EvidenceItem[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item, index) => {
|
||||
const row = readRecord(item);
|
||||
const summary = readString(row.summary);
|
||||
if (!summary) return undefined;
|
||||
return {
|
||||
id: readString(row.id) ?? `v22-evidence-${index}`,
|
||||
title: readString(row.title) ?? summary,
|
||||
summary,
|
||||
occurredAt: readString(row.occurredAt) ?? '',
|
||||
actorId: readString(row.actorId),
|
||||
};
|
||||
}).filter(isDefined);
|
||||
}
|
||||
|
||||
function readTrend(value: unknown): XiaobaoVersionRisk['trend'] | undefined {
|
||||
const row = readRecord(value);
|
||||
const direction = readString(row.direction);
|
||||
const summary = readString(row.summary);
|
||||
if (!direction || !summary) return undefined;
|
||||
if (direction !== 'up' && direction !== 'down' && direction !== 'flat' && direction !== 'unknown') return undefined;
|
||||
const pattern = readString(row.pattern);
|
||||
return {
|
||||
direction,
|
||||
delta: readNumber(row.delta) ?? 0,
|
||||
summary,
|
||||
pattern: pattern === 'continuous_rising'
|
||||
|| pattern === 'continuous_falling'
|
||||
|| pattern === 'score_delta'
|
||||
|| pattern === 'stable'
|
||||
|| pattern === 'unknown'
|
||||
? pattern
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultReasons(riskLevel: XiaobaoRiskLevel, riskScore: number): RiskReason[] {
|
||||
if (riskLevel === 'on_track') return [];
|
||||
return [{
|
||||
key: 'precomputed_risk',
|
||||
title: 'Precomputed risk',
|
||||
detail: `V2.2 precomputed summary reports risk score ${riskScore}.`,
|
||||
severity: riskScore >= 75 ? 'danger' : 'warning',
|
||||
}];
|
||||
}
|
||||
|
||||
function toRiskLevel(value: string | undefined): XiaobaoRiskLevel | undefined {
|
||||
return value && RISK_LEVELS.has(value as XiaobaoRiskLevel) ? value as XiaobaoRiskLevel : undefined;
|
||||
}
|
||||
|
||||
function toReasonSeverity(value: string | undefined): RiskReason['severity'] {
|
||||
return value && REASON_SEVERITIES.has(value as RiskReason['severity'])
|
||||
? value as RiskReason['severity']
|
||||
: 'warning';
|
||||
}
|
||||
|
||||
function toSilentRiskItemType(value: string | undefined): SilentRisk['itemType'] | undefined {
|
||||
return value && SILENT_RISK_ITEM_TYPES.has(value as NonNullable<SilentRisk['itemType']>)
|
||||
? value as NonNullable<SilentRisk['itemType']>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getConfidenceLevel(confidence: number): XiaobaoConfidenceLevel {
|
||||
if (confidence >= 75) return 'high';
|
||||
if (confidence >= 50) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function calcDelayDays(expectedReleaseDate: string | null, forecastReleaseDate: string | undefined): number {
|
||||
const expected = parseDate(expectedReleaseDate);
|
||||
const forecast = parseDate(forecastReleaseDate);
|
||||
if (!expected || !forecast || forecast.getTime() <= expected.getTime()) return 0;
|
||||
return Math.round(((forecast.getTime() - expected.getTime()) / 86_400_000) * 10) / 10;
|
||||
}
|
||||
|
||||
function calcDaysToExpectedRelease(expectedReleaseDate: string | null, now: Date): number | undefined {
|
||||
const expected = parseDate(expectedReleaseDate);
|
||||
if (!expected) return undefined;
|
||||
return Math.round(((expected.getTime() - now.getTime()) / 86_400_000) * 10) / 10;
|
||||
}
|
||||
|
||||
function parseDate(value: string | null | undefined): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
return Number.isFinite(date.getTime()) ? date : undefined;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value: number | undefined): number | undefined {
|
||||
return value !== undefined && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: number | undefined): number | undefined {
|
||||
return value !== undefined && value >= 0 ? Math.round(value) : undefined;
|
||||
}
|
||||
|
||||
function clampScore(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function isDefined<T>(value: T | undefined): value is T {
|
||||
return value !== undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user