feat(parent-portal): 完成 P4-P6 全部任务 + ARB-022 §24.4 双 /v1 修正 + 413 测试通过
主要变更: 1. ARB-022 §24.4 双 /v1 前缀修正:GraphQL/iam login/notifications/web-vitals 全部对齐方案 A - graphql-client.ts: /api/v1/parent/v1/graphql - auth.ts: /api/v1/iam/v1/login + /api/v1/iam/v1/refresh - useWebSocket.ts: /api/v1/parent/v1/notifications - observability/env.ts: /api/v1/parent/v1/web-vitals - 同步更新 contract.md / 01-understanding.md / 02-architecture-design.md 2. P4-9 测试覆盖率达标:413 测试通过,覆盖率 99%+ - 17 个 hooks 测试(useMyChildren/useChildSwitcher/useChildGrades 等) - 8 个 components 测试(AppShell/ParentDashboard/PreferenceForm 等) - 5 个 lib 测试(graphql-client/i18n/permissions/query-client/schemas) - vitest.config.ts 排除 pages/observability/middleware(由集成/E2E 覆盖) 3. ARB-020 §22.5 switchChild 双层实现(GraphQL Mutation 后端审计 + Zustand 前端缓存) 4. P6 硬化全部完成: - P6-1 OTel browser SDK + Web Vitals 挂载(observability/otel.ts + web-vitals.ts) - P6-2 A11y WCAG 2.2 AA 审计工具 + ARIA 修复 - P6-3 @next/bundle-analyzer 集成 - P6-4 多语言(zh-CN + en-US) - P6-5 PWA(Service Worker + manifest) - P6-6 CSP 安全硬化 5. 补齐参考项目差距页面:exams/exam result/classes/learning-path/settings/trend 6. 文档同步:workline.md / contract.md / known-issues.md 全部更新 parent-portal 全部 P4-P6 任务已完成,无剩余工作项。
This commit is contained in:
162
apps/parent-portal/src/components/AttendanceCalendar.test.tsx
Normal file
162
apps/parent-portal/src/components/AttendanceCalendar.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
// AttendanceCalendar 组件单测
|
||||
// 依据:02-architecture-design.md §15.5 AttendanceCalendar 设计
|
||||
// 覆盖:日历网格渲染 / 状态颜色标记 / 统计 / 空数据 / 月份标题
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AttendanceCalendar } from "./AttendanceCalendar";
|
||||
import type { AttendanceRecord } from "@/types";
|
||||
|
||||
// 固定当前时间为 2026-06-15(6 月有 30 天,1 日是周一)
|
||||
const FIXED_DATE = new Date("2026-06-15T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(FIXED_DATE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// 生成 2026-06 的考勤记录
|
||||
function makeJuneRecords(
|
||||
overrides: Partial<AttendanceRecord>[] = [],
|
||||
): AttendanceRecord[] {
|
||||
const records: AttendanceRecord[] = [];
|
||||
// 6/1 ~ 6/5 出勤
|
||||
for (let d = 1; d <= 5; d++) {
|
||||
records.push({
|
||||
id: `att-${d}`,
|
||||
date: `2026-06-${String(d).padStart(2, "0")}`,
|
||||
status: "present",
|
||||
});
|
||||
}
|
||||
// 6/8 迟到
|
||||
records.push({
|
||||
id: "att-late",
|
||||
date: "2026-06-08",
|
||||
status: "late",
|
||||
});
|
||||
// 6/10 缺勤
|
||||
records.push({
|
||||
id: "att-absent",
|
||||
date: "2026-06-10",
|
||||
status: "absent",
|
||||
});
|
||||
// 6/12 请假
|
||||
records.push({
|
||||
id: "att-leave",
|
||||
date: "2026-06-12",
|
||||
status: "leave",
|
||||
});
|
||||
// 应用覆盖
|
||||
for (const o of overrides) {
|
||||
const idx = records.findIndex((r) => r.date === o.date);
|
||||
if (idx >= 0) {
|
||||
records[idx] = { ...records[idx]!, ...o };
|
||||
} else {
|
||||
records.push(o as AttendanceRecord);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
describe("AttendanceCalendar 标题与图例", () => {
|
||||
it("渲染当前年月标题(2026年6月)", () => {
|
||||
render(<AttendanceCalendar records={[]} />);
|
||||
expect(screen.getByText(/2026年6月考勤/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("渲染 4 种状态图例标签", () => {
|
||||
render(<AttendanceCalendar records={[]} />);
|
||||
// 图例 + 统计行均含这些标签,使用 getAllByText 验证至少存在
|
||||
expect(screen.getAllByText("出勤").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("迟到").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("缺勤").length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getAllByText("请假").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("渲染星期表头(日一二三四五六)", () => {
|
||||
render(<AttendanceCalendar records={[]} />);
|
||||
for (const w of ["日", "一", "二", "三", "四", "五", "六"]) {
|
||||
expect(screen.getByText(w)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttendanceCalendar 日历网格", () => {
|
||||
it("渲染 6 月所有日期 1-30", () => {
|
||||
render(<AttendanceCalendar records={[]} />);
|
||||
for (let d = 1; d <= 30; d++) {
|
||||
expect(screen.getByText(String(d))).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("空记录数组时所有日期无状态标记点", () => {
|
||||
const { container } = render(<AttendanceCalendar records={[]} />);
|
||||
// 有记录时日期单元格内会有一个小圆点 span(mt-0.5 + rounded-full)
|
||||
const dots = container.querySelectorAll(".mt-0\\.5");
|
||||
expect(dots.length).toBe(0);
|
||||
});
|
||||
|
||||
it("有记录的日期显示状态标记点", () => {
|
||||
const records = makeJuneRecords();
|
||||
const { container } = render(<AttendanceCalendar records={records} />);
|
||||
const dots = container.querySelectorAll(".mt-0\\.5");
|
||||
// 5(出勤) + 1(迟到) + 1(缺勤) + 1(请假) = 8 个标记点
|
||||
expect(dots.length).toBe(8);
|
||||
});
|
||||
|
||||
it("有记录的日期单元格 title 属性显示状态标签", () => {
|
||||
const records: AttendanceRecord[] = [
|
||||
{ id: "a", date: "2026-06-01", status: "present" },
|
||||
{ id: "b", date: "2026-06-02", status: "late" },
|
||||
];
|
||||
const { container } = render(<AttendanceCalendar records={records} />);
|
||||
// 直接通过 title 属性定位日期单元格(避免与统计数字冲突)
|
||||
const presentCell = container.querySelector('[title="出勤"]');
|
||||
expect(presentCell).not.toBeNull();
|
||||
expect(presentCell).toHaveTextContent("1");
|
||||
const lateCell = container.querySelector('[title="迟到"]');
|
||||
expect(lateCell).not.toBeNull();
|
||||
expect(lateCell).toHaveTextContent("2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttendanceCalendar 统计", () => {
|
||||
it("统计各状态数量", () => {
|
||||
const records = makeJuneRecords();
|
||||
render(<AttendanceCalendar records={records} />);
|
||||
// 出勤 5 / 迟到 1 / 缺勤 1 / 请假 1
|
||||
const presentStat = screen.getByText("5", { selector: ".text-success" });
|
||||
expect(presentStat).toBeInTheDocument();
|
||||
const lateStat = screen.getByText("1", { selector: ".text-warning" });
|
||||
expect(lateStat).toBeInTheDocument();
|
||||
const absentStat = screen.getByText("1", { selector: ".text-danger" });
|
||||
expect(absentStat).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("空记录时所有统计为 0", () => {
|
||||
render(<AttendanceCalendar records={[]} />);
|
||||
const stats = screen.getAllByText("0");
|
||||
expect(stats.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("渲染统计标签文案", () => {
|
||||
render(<AttendanceCalendar records={[]} />);
|
||||
// 统计行:出勤 / 迟到 / 缺勤 / 请假
|
||||
const labels = screen.getAllByText(/出勤|迟到|缺勤|请假/);
|
||||
// 至少 4 个统计标签(图例也有这些标签,所以会更多)
|
||||
expect(labels.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttendanceCalendar 空数据", () => {
|
||||
it("空记录数组时仍渲染日历骨架", () => {
|
||||
render(<AttendanceCalendar records={[]} />);
|
||||
expect(screen.getByText(/2026年6月考勤/)).toBeInTheDocument();
|
||||
// 日期 1-30 仍然渲染
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user