feat(版本详情): 接入计划时间与日期选择

This commit is contained in:
Script Generator
2026-06-29 14:18:58 +08:00
parent dde11c0899
commit 0f64a17d44
20 changed files with 601 additions and 12 deletions

25
apps/web/lib/bug.test.ts Normal file
View File

@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Bug } from './bug';
test('bug supports planned fix time', () => {
const bug: Bug = {
id: 'bug-1',
bugNo: 'BUG-001',
versionId: 'version-1',
testCaseId: 'tc-1',
title: 'cannot save',
description: 'save button has no response',
severity: 'major',
priority: 'P1',
reportedBy: 'QA',
assigneeId: 'Dev',
status: 'open',
plannedFixAt: '2026-07-01T18:00:00.000Z',
createdAt: '2026-06-25T00:00:00.000Z',
updatedAt: '2026-06-25T00:00:00.000Z',
};
assert.equal(bug.plannedFixAt, '2026-07-01T18:00:00.000Z');
});

View File

@@ -34,6 +34,7 @@ export interface Bug {
resolution?: string;
estimateHours?: number;
aiEstimateHours?: number;
plannedFixAt?: string;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getChinaWorkdayInfo, isChinaWorkday } from './china-workday-calendar';
test('classifies 2026 official holidays and makeup workdays', () => {
const nationalDay = getChinaWorkdayInfo('2026-10-01');
assert.equal(nationalDay.type, 'holiday');
assert.equal(nationalDay.isWorkday, false);
assert.equal(nationalDay.label, '国庆节');
assert.equal(isChinaWorkday('2026-10-01'), false);
const makeupDay = getChinaWorkdayInfo('2026-10-10');
assert.equal(makeupDay.type, 'makeup_workday');
assert.equal(makeupDay.isWorkday, true);
assert.equal(makeupDay.label, '国庆节调休上班');
assert.equal(isChinaWorkday('2026-10-10'), true);
});
test('classifies 2026 regular weekends and weekdays', () => {
assert.equal(getChinaWorkdayInfo('2026-07-04').type, 'weekend');
assert.equal(getChinaWorkdayInfo('2026-07-04').isWorkday, false);
assert.equal(getChinaWorkdayInfo('2026-07-06').type, 'workday');
assert.equal(getChinaWorkdayInfo('2026-07-06').isWorkday, true);
});
test('uses only the date part from local datetime values', () => {
const dragonBoat = getChinaWorkdayInfo('2026-06-19T09:30');
assert.equal(dragonBoat.type, 'holiday');
assert.equal(dragonBoat.label, '端午节');
});

View File

@@ -0,0 +1,129 @@
export type ChinaWorkdayType = 'workday' | 'weekend' | 'holiday' | 'makeup_workday';
export interface ChinaWorkdayInfo {
date: string;
type: ChinaWorkdayType;
label: string;
isWorkday: boolean;
source: 'official_2026' | 'weekend_fallback';
}
type OverrideInfo = {
type: Extract<ChinaWorkdayType, 'holiday' | 'makeup_workday'>;
label: string;
};
export const CHINA_HOLIDAY_SOURCE_2026 = {
name: '国务院办公厅关于2026年部分节假日安排的通知',
url: 'https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm',
} as const;
const OFFICIAL_2026_OVERRIDES: Record<string, OverrideInfo> = {
'2026-01-01': { type: 'holiday', label: '元旦' },
'2026-01-02': { type: 'holiday', label: '元旦' },
'2026-01-03': { type: 'holiday', label: '元旦' },
'2026-01-04': { type: 'makeup_workday', label: '元旦调休上班' },
'2026-02-14': { type: 'makeup_workday', label: '春节调休上班' },
'2026-02-15': { type: 'holiday', label: '春节' },
'2026-02-16': { type: 'holiday', label: '春节' },
'2026-02-17': { type: 'holiday', label: '春节' },
'2026-02-18': { type: 'holiday', label: '春节' },
'2026-02-19': { type: 'holiday', label: '春节' },
'2026-02-20': { type: 'holiday', label: '春节' },
'2026-02-21': { type: 'holiday', label: '春节' },
'2026-02-22': { type: 'holiday', label: '春节' },
'2026-02-23': { type: 'holiday', label: '春节' },
'2026-02-28': { type: 'makeup_workday', label: '春节调休上班' },
'2026-04-04': { type: 'holiday', label: '清明节' },
'2026-04-05': { type: 'holiday', label: '清明节' },
'2026-04-06': { type: 'holiday', label: '清明节' },
'2026-05-01': { type: 'holiday', label: '劳动节' },
'2026-05-02': { type: 'holiday', label: '劳动节' },
'2026-05-03': { type: 'holiday', label: '劳动节' },
'2026-05-04': { type: 'holiday', label: '劳动节' },
'2026-05-05': { type: 'holiday', label: '劳动节' },
'2026-05-09': { type: 'makeup_workday', label: '劳动节调休上班' },
'2026-06-19': { type: 'holiday', label: '端午节' },
'2026-06-20': { type: 'holiday', label: '端午节' },
'2026-06-21': { type: 'holiday', label: '端午节' },
'2026-09-20': { type: 'makeup_workday', label: '国庆节调休上班' },
'2026-09-25': { type: 'holiday', label: '中秋节' },
'2026-09-26': { type: 'holiday', label: '中秋节' },
'2026-09-27': { type: 'holiday', label: '中秋节' },
'2026-10-01': { type: 'holiday', label: '国庆节' },
'2026-10-02': { type: 'holiday', label: '国庆节' },
'2026-10-03': { type: 'holiday', label: '国庆节' },
'2026-10-04': { type: 'holiday', label: '国庆节' },
'2026-10-05': { type: 'holiday', label: '国庆节' },
'2026-10-06': { type: 'holiday', label: '国庆节' },
'2026-10-07': { type: 'holiday', label: '国庆节' },
'2026-10-10': { type: 'makeup_workday', label: '国庆节调休上班' },
};
const DATE_KEY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})/;
export function getDateKey(value: string | Date): string {
if (value instanceof Date) {
return formatDateKey(value);
}
const matched = value.match(DATE_KEY_PATTERN);
if (matched) return `${matched[1]}-${matched[2]}-${matched[3]}`;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? '' : formatDateKey(parsed);
}
export function getChinaWorkdayInfo(value: string | Date): ChinaWorkdayInfo {
const date = getDateKey(value);
const override = OFFICIAL_2026_OVERRIDES[date];
if (override) {
return {
date,
type: override.type,
label: override.label,
isWorkday: override.type === 'makeup_workday',
source: 'official_2026',
};
}
const parsed = parseDateKey(date);
const weekend = parsed ? isWeekend(parsed) : false;
return {
date,
type: weekend ? 'weekend' : 'workday',
label: weekend ? '周末' : '工作日',
isWorkday: !weekend,
source: 'weekend_fallback',
};
}
export function isChinaWorkday(value: string | Date): boolean {
return getChinaWorkdayInfo(value).isWorkday;
}
function formatDateKey(date: Date): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
function parseDateKey(dateKey: string): Date | null {
const matched = dateKey.match(DATE_KEY_PATTERN);
if (!matched) return null;
const year = Number(matched[1]);
const month = Number(matched[2]);
const day = Number(matched[3]);
if (!year || !month || !day) return null;
return new Date(year, month - 1, day);
}
function isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}

View File

@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DevTask } from './dev-task';
import { needsDelayReason } from './dev-task-transitions';
function task(patch: Partial<DevTask> = {}): DevTask {
return {
id: 'task-1',
taskNo: 'DEV-001',
requirementId: 'req-1',
title: '实现排班规则',
categoryId: 'cat-frontend-interaction',
assigneeId: 'Alice',
priority: 'P2',
expectedStartAt: '2026-06-29T01:00:00.000Z',
expectedEndAt: '2026-06-29T10:00:00.000Z',
status: 'todo',
isBlocked: false,
createdBy: 'PM',
createdAt: '2026-06-29T00:00:00.000Z',
updatedAt: '2026-06-29T00:00:00.000Z',
...patch,
};
}
test('needsDelayReason does not require a reason before expected end time', () => {
assert.equal(needsDelayReason(task(), new Date('2026-06-29T09:00:00.000Z')), false);
});
test('needsDelayReason requires a reason after expected end time for todo tasks', () => {
assert.equal(needsDelayReason(task(), new Date('2026-06-29T10:01:00.000Z')), true);
});

View File

@@ -1,6 +1,6 @@
import type { DevTask } from './dev-task';
export function needsDelayReason(task: DevTask, now: Date = new Date()): boolean {
if (task.status !== 'todo' || !task.expectedStartAt) return false;
return now.getTime() > new Date(task.expectedStartAt).getTime();
if (task.status !== 'todo' || !task.expectedEndAt) return false;
return now.getTime() > new Date(task.expectedEndAt).getTime();
}

View File

@@ -48,6 +48,24 @@ test('normalizeTestCase keeps existing categoryId', () => {
assert.equal(tc.categoryId, 'cat-test-api');
});
test('normalizeTestCase preserves planned test time', () => {
const tc = normalizeTestCase({
id: 'tc-1',
caseNo: 'TC-001',
versionId: 'v1',
title: 'scheduled test case',
priority: 'P2',
status: 'pending',
categoryId: 'cat-test-api',
plannedTestAt: '2026-07-01T09:30:00.000Z',
createdBy: 'tester',
createdAt: '2026-06-25',
updatedAt: '2026-06-25',
} as any);
assert.equal(tc.plannedTestAt, '2026-07-01T09:30:00.000Z');
});
test('normalizeTestCase backfills missing roundNo to the first test round', () => {
const tc = normalizeTestCase({
id: 'tc-1',
@@ -116,6 +134,7 @@ test('copyTestCaseToRound preserves estimates and references but clears executio
categoryId: 'cat-test-functional',
estimateHours: 0.75,
aiEstimateHours: 0.25,
plannedTestAt: '2026-07-01T09:30:00.000Z',
assigneeId: 'QA',
startedAt: '2026-06-25T01:00:00.000Z',
completedAt: '2026-06-25T02:00:00.000Z',
@@ -137,6 +156,7 @@ test('copyTestCaseToRound preserves estimates and references but clears executio
assert.equal(copied.sourceCaseId, 'tc-source');
assert.equal(copied.aiEstimateHours, 0.25);
assert.equal(copied.estimateHours, 0.75);
assert.equal(copied.plannedTestAt, '2026-07-01T09:30:00.000Z');
assert.deepEqual(copied.references, source.references);
assert.equal(copied.startedAt, undefined);
assert.equal(copied.completedAt, undefined);

View File

@@ -22,6 +22,7 @@ export interface TestCase {
status: TestCaseStatus;
estimateHours?: number;
aiEstimateHours?: number;
plannedTestAt?: string;
startedAt?: string;
completedAt?: string;
executedAt?: string;
@@ -96,6 +97,7 @@ export function normalizeTestCase(testCase: Partial<TestCase>, index = 0): TestC
status: testCase.status || 'pending',
estimateHours: typeof testCase.estimateHours === 'number' && testCase.estimateHours > 0 ? testCase.estimateHours : undefined,
aiEstimateHours: typeof testCase.aiEstimateHours === 'number' && testCase.aiEstimateHours > 0 ? testCase.aiEstimateHours : undefined,
plannedTestAt: testCase.plannedTestAt,
startedAt: testCase.startedAt,
completedAt: testCase.completedAt,
executedAt: testCase.executedAt,
@@ -169,6 +171,7 @@ export function copyTestCaseToRound(source: TestCase, roundNo: number, createdBy
priority: source.priority,
estimateHours: source.estimateHours,
aiEstimateHours: source.aiEstimateHours,
plannedTestAt: source.plannedTestAt,
assigneeId: source.assigneeId,
startedAt: undefined,
completedAt: undefined,