fix: code-review 审查问题修复 + A2UI 测试补齐

Standards 轴:
- 移除 uiArtifacts.ts 的 payloadOf 死代码残留
- AttendanceDevices 残留 any 类型化(补 ClassroomOption.status 字段)
- 批量考勤纠错区分业务失败(已结算/无权限)与系统错误,
  前端提示精确到两类数量
- Dashboard queryFn 六段重复校验块收敛为 safeValidate 助手

Spec 轴:
- 补齐阶段 3.4 A2UI 测试:图表空数据占位、ArtifactErrorBoundary
  降级隔离、useSubmissionState/useXCardSurface 单测(7 用例)
- 阶段 2.2 补两处引导:教师工作台区分「今日无课」与「未分配班级」、
  班级花名册空态带「添加学员」动作
- 契约文档修正 DynamicReview 状态管理描述(多提交点如实说明)

aislop 剩余 16 警告均为必要豁免(类型边界/声明式 SQL 配置/既有文件规模)
This commit is contained in:
2026-08-08 09:43:48 +08:00
parent bcd2d1a559
commit 4b60e0c018
12 changed files with 323 additions and 91 deletions

View File

@@ -4,6 +4,7 @@ import { Bubble } from '@ant-design/x';
import { afterEach, describe, expect, it } from 'vitest';
import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer';
import { AiMessageContent } from './AiMessageContent';
import { ArtifactErrorBoundary } from './ArtifactErrorBoundary';
import { DynamicChart } from './DynamicChart';
import { DynamicForm } from './DynamicForm';
import { DynamicReview } from './DynamicReview';
@@ -504,4 +505,49 @@ describe('AI chat bubble rendering', () => {
expect(container.textContent).toContain(label);
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
});
it('renders an empty-data placeholder instead of a blank chart', async () => {
const chart: AiChartSchema = {
id: 'chart-empty',
title: '空图表',
chartType: 'bar',
columns: [{ key: 'name', title: '名称' }],
rows: [],
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(<DynamicChart chart={chart} />);
});
expect(container.textContent).toContain('空图表');
expect(container.textContent).toContain('暂无数据');
expect(container.querySelector('canvas')).toBeNull();
});
it('degrades a single failing artifact to an error card without crashing the bubble', async () => {
const Bomb: React.FC = () => {
throw new Error('boom');
};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
// 用无错误边界的兄弟节点 + 错误边界内的炸弹组件验证隔离
await act(async () => {
root?.render(
<div>
<div className="neighbor"></div>
<ArtifactErrorBoundary title="表单">
<Bomb />
</ArtifactErrorBoundary>
</div>,
);
});
expect(container.querySelector('.neighbor')?.textContent).toContain('正常内容');
expect(container.textContent).toContain('表单渲染失败');
});
});

View File

@@ -25,10 +25,6 @@ export function mergeById<T extends { id: string }>(
return next;
}
function payloadOf(artifact: AiArtifactSchema): unknown {
return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {};
}
/**
* 将统一 artifact 归入 uiArtifacts。
*
@@ -41,7 +37,6 @@ export function mergeArtifactIntoMessage(
artifact: AiArtifactSchema,
): AiChatMessage {
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
void payloadOf(artifact);
return message;
}

View File

@@ -0,0 +1,161 @@
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { useSubmissionState, useXCardSurface } from './useSubmissionState';
// 项目未安装 @testing-library/react用 createRoot + harness 组件暴露 hook API
let container: HTMLDivElement | null = null;
let root: ReturnType<typeof createRoot> | null = null;
let api: ReturnType<typeof useSubmissionState> | null = null;
let surface: ReturnType<typeof useXCardSurface> | null = null;
let surfaceId = 'surface-test';
function Harness() {
api = useSubmissionState();
surface = useXCardSurface(surfaceId);
return null;
}
function renderHarness(): void {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(<Harness />);
});
}
afterEach(async () => {
if (root) await act(async () => root?.unmount());
container?.remove();
root = null;
container = null;
api = null;
surface = null;
surfaceId = 'surface-test';
});
describe('useSubmissionState', () => {
it('tracks submitting during the task and succeeds afterwards', async () => {
renderHarness();
let resolveTask: () => void = () => undefined;
const task = () =>
new Promise<void>((resolve) => {
resolveTask = resolve;
});
let promise: Promise<void> | undefined;
act(() => {
promise = api?.run(task);
});
expect(api?.submitting).toBe(true);
expect(api?.error).toBeNull();
await act(async () => {
resolveTask();
await promise;
});
expect(api?.submitting).toBe(false);
expect(api?.submitted).toBe(true);
expect(api?.error).toBeNull();
});
it('captures the error message and keeps submitted false on failure', async () => {
renderHarness();
const failing = () => {
throw new Error('接口 500');
};
await act(async () => {
await api?.run(failing);
});
expect(api?.submitting).toBe(false);
expect(api?.submitted).toBe(false);
expect(api?.error).toBe('接口 500');
});
it('normalizes non-Error rejections to a generic message', async () => {
renderHarness();
const failing = () => Promise.reject('raw string');
await act(async () => {
await api?.run(failing);
});
expect(api?.error).toBe('提交失败,请稍后重试');
});
it('ignores re-entrant calls while a task is in flight', async () => {
renderHarness();
let resolveTask: () => void = () => undefined;
const task = () =>
new Promise<void>((resolve) => {
resolveTask = resolve;
});
let secondRan = false;
act(() => {
void api?.run(task);
void api?.run(() => {
secondRan = true;
});
});
expect(secondRan).toBe(false);
await act(async () => {
resolveTask();
});
expect(api?.submitted).toBe(true);
});
it('reset clears submitted and error states', async () => {
renderHarness();
await act(async () => {
await api?.run(() => undefined);
});
expect(api?.submitted).toBe(true);
act(() => {
api?.reset();
});
expect(api?.submitted).toBe(false);
expect(api?.error).toBeNull();
});
});
describe('useXCardSurface', () => {
it('deduplicates createSurface commands for the same surface id', () => {
renderHarness();
act(() => {
surface?.pushCommands([
{ version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } },
{ version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 1 } },
]);
surface?.pushCommands([
{ version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } },
{ version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 2 } },
]);
});
const createCommands = surface?.commands.filter((command) => 'createSurface' in command);
expect(createCommands).toHaveLength(1);
expect(surface?.commands).toHaveLength(3);
});
it('resets the command stream when the surface id changes', () => {
renderHarness();
act(() => {
surface?.pushCommands([
{ version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'c' } },
]);
});
expect(surface?.commands).toHaveLength(1);
surfaceId = 'surface-other';
act(() => {
root?.render(<Harness />);
surface?.pushCommands([
{ version: 'v0.9', createSurface: { surfaceId: 'surface-other', catalogId: 'c' } },
]);
});
expect(surface?.commands).toHaveLength(1);
});
});