feat(student-portal): 完成Docker构建验证+全量测试通过+nextstep下游依赖文档

This commit is contained in:
SpecialX
2026-07-13 16:49:31 +08:00
parent 64b2016fa8
commit 40f07b7a16
27 changed files with 4742 additions and 31 deletions

View File

@@ -0,0 +1,346 @@
/**
* useAntiCheat Hook 测试
*
* 测试防作弊 hook 的核心逻辑:
* - 事件监听copy / paste / contextmenu / visibilitychange
* - 违规记录
* - 多次切屏后显示警告
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useAntiCheat } from "./use-anti-cheat";
// ClipboardEvent 在 jsdom 中未实现,需要 polyfill
if (typeof globalThis.ClipboardEvent === "undefined") {
class ClipboardEventPolyfill extends Event {
clipboardData: DataTransfer | null;
constructor(type: string, eventInit?: EventInit & { clipboardData?: DataTransfer | null }) {
super(type, eventInit);
this.clipboardData = eventInit?.clipboardData ?? null;
}
}
globalThis.ClipboardEvent = ClipboardEventPolyfill as unknown as typeof ClipboardEvent;
}
describe("useAntiCheat", () => {
const examId = "exam-001";
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("初始状态", () => {
it("violations 初始应为空数组", () => {
const { result } = renderHook(() => useAntiCheat(examId));
expect(result.current.violations).toEqual([]);
});
it("isFullscreen 初始应为 false", () => {
const { result } = renderHook(() => useAntiCheat(examId));
expect(result.current.isFullscreen).toBe(false);
});
it("showWarning 初始应为 false", () => {
const { result } = renderHook(() => useAntiCheat(examId));
expect(result.current.showWarning).toBe(false);
});
it("warningMessage 初始应为 null", () => {
const { result } = renderHook(() => useAntiCheat(examId));
expect(result.current.warningMessage).toBeNull();
});
});
describe("copy 事件监听", () => {
it("copy 事件应记录 copy 违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
});
expect(result.current.violations).toHaveLength(1);
expect(result.current.violations[0]?.type).toBe("copy");
});
it("copy 事件应调用 preventDefault", () => {
renderHook(() => useAntiCheat(examId));
const event = new ClipboardEvent("copy", { cancelable: true });
const preventSpy = vi.spyOn(event, "preventDefault");
act(() => {
document.dispatchEvent(event);
});
expect(preventSpy).toHaveBeenCalled();
});
});
describe("paste 事件监听", () => {
it("paste 事件应记录 paste 违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(
new ClipboardEvent("paste", { cancelable: true }),
);
});
expect(result.current.violations).toHaveLength(1);
expect(result.current.violations[0]?.type).toBe("paste");
});
it("paste 事件应调用 preventDefault", () => {
renderHook(() => useAntiCheat(examId));
const event = new ClipboardEvent("paste", { cancelable: true });
const preventSpy = vi.spyOn(event, "preventDefault");
act(() => {
document.dispatchEvent(event);
});
expect(preventSpy).toHaveBeenCalled();
});
});
describe("contextmenu 事件监听", () => {
it("contextmenu 事件应记录 contextmenu 违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(
new MouseEvent("contextmenu", { cancelable: true }),
);
});
expect(result.current.violations).toHaveLength(1);
expect(result.current.violations[0]?.type).toBe("contextmenu");
});
it("contextmenu 事件应调用 preventDefault", () => {
renderHook(() => useAntiCheat(examId));
const event = new MouseEvent("contextmenu", { cancelable: true });
const preventSpy = vi.spyOn(event, "preventDefault");
act(() => {
document.dispatchEvent(event);
});
expect(preventSpy).toHaveBeenCalled();
});
});
describe("visibilitychange 事件监听", () => {
function setVisibility(state: "visible" | "hidden"): void {
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => state,
});
}
afterEach(() => {
// 恢复默认
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
});
it("切到 hidden 应记录 tab-switch 违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
setVisibility("hidden");
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(result.current.violations).toHaveLength(1);
expect(result.current.violations[0]?.type).toBe("tab-switch");
});
it("切到 visible 不应记录违规", () => {
const { result } = renderHook(() => useAntiCheat(examId));
setVisibility("visible");
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
expect(result.current.violations).toHaveLength(0);
});
it("3 次切屏后应显示警告", () => {
const { result } = renderHook(() => useAntiCheat(examId));
for (let i = 0; i < 3; i++) {
setVisibility("hidden");
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
}
expect(result.current.violations).toHaveLength(3);
expect(result.current.showWarning).toBe(true);
expect(result.current.warningMessage).toContain("切屏");
});
it("2 次切屏不应显示警告", () => {
const { result } = renderHook(() => useAntiCheat(examId));
for (let i = 0; i < 2; i++) {
setVisibility("hidden");
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
}
expect(result.current.violations).toHaveLength(2);
expect(result.current.showWarning).toBe(false);
});
});
describe("dismissWarning", () => {
it("dismissWarning 应清除警告状态", () => {
const { result } = renderHook(() => useAntiCheat(examId));
// 触发 3 次切屏显示警告
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
for (let i = 0; i < 3; i++) {
act(() => {
document.dispatchEvent(new Event("visibilitychange"));
});
}
expect(result.current.showWarning).toBe(true);
// 恢复 visibility
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
act(() => {
result.current.dismissWarning();
});
expect(result.current.showWarning).toBe(false);
expect(result.current.warningMessage).toBeNull();
});
});
describe("违规记录结构", () => {
it("每条违规应包含 type / timestamp / details", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
});
const violation = result.current.violations[0];
expect(violation).toBeDefined();
expect(violation?.type).toBe("copy");
expect(typeof violation?.timestamp).toBe("number");
expect(typeof violation?.details).toBe("string");
});
it("多次违规应累积记录", () => {
const { result } = renderHook(() => useAntiCheat(examId));
act(() => {
document.dispatchEvent(new ClipboardEvent("copy", { cancelable: true }));
document.dispatchEvent(new ClipboardEvent("paste", { cancelable: true }));
document.dispatchEvent(new MouseEvent("contextmenu", { cancelable: true }));
});
expect(result.current.violations).toHaveLength(3);
expect(result.current.violations[0]?.type).toBe("copy");
expect(result.current.violations[1]?.type).toBe("paste");
expect(result.current.violations[2]?.type).toBe("contextmenu");
});
});
describe("批量上报", () => {
it("违规满 10 条后应调用 fetch 上报", () => {
renderHook(() => useAntiCheat(examId));
act(() => {
for (let i = 0; i < 10; i++) {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
}
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
}),
);
});
it("不满 10 条时不应调用 fetch", () => {
renderHook(() => useAntiCheat(examId));
act(() => {
for (let i = 0; i < 5; i++) {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
}
});
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("卸载清理", () => {
it("卸载时应移除事件监听", () => {
const { unmount } = renderHook(() => useAntiCheat(examId));
unmount();
// 卸载后触发事件不应报错
expect(() => {
document.dispatchEvent(new ClipboardEvent("copy", { cancelable: true }));
}).not.toThrow();
});
it("卸载时有未上报违规应 flush", () => {
const { result, unmount } = renderHook(() => useAntiCheat(examId));
act(() => {
for (let i = 0; i < 3; i++) {
document.dispatchEvent(
new ClipboardEvent("copy", { cancelable: true }),
);
}
});
expect(result.current.violations).toHaveLength(3);
expect(fetchMock).not.toHaveBeenCalled();
unmount();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,281 @@
/**
* useExamStateMachine Hook 测试
*
* 测试考试状态机的状态流转:
* NotStarted → InProgress → AutoSaving → Submitting → Submitted
* 验证合法转移与非法转移拒绝。
*/
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useExamStateMachine } from "./use-exam-state-machine";
describe("useExamStateMachine", () => {
it("初始状态应为 NotStarted", () => {
const { result } = renderHook(() => useExamStateMachine());
expect(result.current.state).toBe("NotStarted");
});
it("初始时 canSubmit 应为 false", () => {
const { result } = renderHook(() => useExamStateMachine());
expect(result.current.canSubmit).toBe(false);
});
describe("合法状态转移", () => {
it("start: NotStarted → InProgress", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
});
expect(result.current.state).toBe("InProgress");
expect(result.current.canSubmit).toBe(true);
});
it("beginAutoSave: InProgress → AutoSaving", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
});
expect(result.current.state).toBe("AutoSaving");
expect(result.current.canSubmit).toBe(true);
});
it("endAutoSave: AutoSaving → InProgress", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
result.current.actions.endAutoSave();
});
expect(result.current.state).toBe("InProgress");
});
it("beginSubmit: InProgress → Submitting", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
});
expect(result.current.state).toBe("Submitting");
expect(result.current.canSubmit).toBe(false);
});
it("beginSubmit: AutoSaving → Submitting", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
result.current.actions.beginSubmit();
});
expect(result.current.state).toBe("Submitting");
});
it("completeSubmit: Submitting → Submitted", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.completeSubmit();
});
expect(result.current.state).toBe("Submitted");
expect(result.current.canSubmit).toBe(false);
});
it("完整流程: NotStarted → InProgress → AutoSaving → InProgress → Submitting → Submitted", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => result.current.actions.start());
expect(result.current.state).toBe("InProgress");
act(() => result.current.actions.beginAutoSave());
expect(result.current.state).toBe("AutoSaving");
act(() => result.current.actions.endAutoSave());
expect(result.current.state).toBe("InProgress");
act(() => result.current.actions.beginSubmit());
expect(result.current.state).toBe("Submitting");
act(() => result.current.actions.completeSubmit());
expect(result.current.state).toBe("Submitted");
});
});
describe("非法状态转移(应保持原状态)", () => {
it("NotStarted 时 beginAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.beginAutoSave();
});
expect(result.current.state).toBe("NotStarted");
});
it("NotStarted 时 endAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.endAutoSave();
});
expect(result.current.state).toBe("NotStarted");
});
it("NotStarted 时 beginSubmit 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.beginSubmit();
});
expect(result.current.state).toBe("NotStarted");
});
it("NotStarted 时 completeSubmit 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.completeSubmit();
});
expect(result.current.state).toBe("NotStarted");
});
it("InProgress 时 start 应无效果(不能重复 start", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
});
expect(result.current.state).toBe("InProgress");
act(() => {
result.current.actions.start();
});
expect(result.current.state).toBe("InProgress");
});
it("InProgress 时 endAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.endAutoSave();
});
expect(result.current.state).toBe("InProgress");
});
it("InProgress 时 completeSubmit 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.completeSubmit();
});
expect(result.current.state).toBe("InProgress");
});
it("AutoSaving 时 start 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
result.current.actions.start();
});
expect(result.current.state).toBe("AutoSaving");
});
it("AutoSaving 时 endAutoSave 后再 endAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
result.current.actions.endAutoSave();
result.current.actions.endAutoSave();
});
expect(result.current.state).toBe("InProgress");
});
it("Submitting 时 start 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.start();
});
expect(result.current.state).toBe("Submitting");
});
it("Submitting 时 beginAutoSave 应无效果", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.beginAutoSave();
});
expect(result.current.state).toBe("Submitting");
});
it("Submitted 时 start 应无效果(终态)", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.completeSubmit();
result.current.actions.start();
});
expect(result.current.state).toBe("Submitted");
});
it("Submitted 时 beginSubmit 应无效果(终态)", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.completeSubmit();
result.current.actions.beginSubmit();
});
expect(result.current.state).toBe("Submitted");
});
});
describe("canSubmit 状态", () => {
it("NotStarted 时 canSubmit=false", () => {
const { result } = renderHook(() => useExamStateMachine());
expect(result.current.canSubmit).toBe(false);
});
it("InProgress 时 canSubmit=true", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => result.current.actions.start());
expect(result.current.canSubmit).toBe(true);
});
it("AutoSaving 时 canSubmit=true", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginAutoSave();
});
expect(result.current.canSubmit).toBe(true);
});
it("Submitting 时 canSubmit=false", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
});
expect(result.current.canSubmit).toBe(false);
});
it("Submitted 时 canSubmit=false", () => {
const { result } = renderHook(() => useExamStateMachine());
act(() => {
result.current.actions.start();
result.current.actions.beginSubmit();
result.current.actions.completeSubmit();
});
expect(result.current.canSubmit).toBe(false);
});
});
describe("actions 引用稳定性", () => {
it("actions 对象应在多次渲染间保持引用稳定", () => {
const { result, rerender } = renderHook(() => useExamStateMachine());
const firstActions = result.current.actions;
rerender();
expect(result.current.actions).toBe(firstActions);
});
});
});

View File

@@ -0,0 +1,239 @@
/**
* useSubmitDedup Hook 测试
*
* 测试提交去重逻辑:
* - markSubmitting 生成唯一幂等 key
* - markComplete 后可以再次 markSubmitting
* - lockRef 防止重复点击
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useSubmitDedup } from "./use-submit-dedup";
describe("useSubmitDedup", () => {
const examId = "exam-001";
beforeEach(() => {
vi.restoreAllMocks();
});
it("初始状态 isSubmitting 应为 false", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
expect(result.current.isSubmitting).toBe(false);
});
it("初始状态 canSubmit 应为 true", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
expect(result.current.canSubmit).toBe(true);
});
it("初始应生成 idempotencyKey", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
expect(result.current.idempotencyKey).toBeTruthy();
expect(typeof result.current.idempotencyKey).toBe("string");
});
it("idempotencyKey 应包含 examId", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
expect(result.current.idempotencyKey).toContain(`exam-${examId}`);
});
describe("markSubmitting", () => {
it("markSubmitting 后 isSubmitting 应为 true", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
});
it("markSubmitting 后 canSubmit 应为 false", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
});
expect(result.current.canSubmit).toBe(false);
});
it("markSubmitting 后应生成新的 idempotencyKey", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
const initialKey = result.current.idempotencyKey;
act(() => {
result.current.markSubmitting();
});
expect(result.current.idempotencyKey).not.toBe(initialKey);
});
it("重复调用 markSubmitting 不应改变状态lockRef 保护)", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
});
const keyAfterFirst = result.current.idempotencyKey;
act(() => {
result.current.markSubmitting();
});
// key 不应变lockRef 阻止了第二次)
expect(result.current.idempotencyKey).toBe(keyAfterFirst);
expect(result.current.isSubmitting).toBe(true);
});
});
describe("markComplete", () => {
it("markComplete 后 isSubmitting 应为 false", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
result.current.markComplete();
});
expect(result.current.isSubmitting).toBe(false);
});
it("markComplete 后 canSubmit 应为 true", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
result.current.markComplete();
});
expect(result.current.canSubmit).toBe(true);
});
it("markComplete 后应生成新的 idempotencyKey", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
});
const keyAfterSubmit = result.current.idempotencyKey;
act(() => {
result.current.markComplete();
});
expect(result.current.idempotencyKey).not.toBe(keyAfterSubmit);
});
it("markComplete 后可以再次 markSubmitting", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
// 第一次提交
act(() => {
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
// 完成
act(() => {
result.current.markComplete();
});
expect(result.current.isSubmitting).toBe(false);
expect(result.current.canSubmit).toBe(true);
// 第二次提交
act(() => {
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
expect(result.current.canSubmit).toBe(false);
});
});
describe("幂等 key 唯一性", () => {
it("不同 Hook 实例应生成不同的初始 key", () => {
const { result: r1 } = renderHook(() => useSubmitDedup(examId));
const { result: r2 } = renderHook(() => useSubmitDedup(examId));
expect(r1.current.idempotencyKey).not.toBe(r2.current.idempotencyKey);
});
it("完整提交周期应生成 3 个不同的 key初始 → submitting → complete", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
const keys: string[] = [result.current.idempotencyKey];
act(() => {
result.current.markSubmitting();
});
keys.push(result.current.idempotencyKey);
act(() => {
result.current.markComplete();
});
keys.push(result.current.idempotencyKey);
// 三个 key 应互不相同
expect(new Set(keys).size).toBe(3);
});
it("多次提交周期每次都应生成不同的 key", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
const keys: string[] = [];
for (let i = 0; i < 3; i++) {
act(() => {
result.current.markSubmitting();
});
keys.push(result.current.idempotencyKey);
act(() => {
result.current.markComplete();
});
}
// 每次提交的 key 应唯一
expect(new Set(keys).size).toBe(keys.length);
});
});
describe("重复点击防护", () => {
it("快速连续点击 markSubmitting 只应生效一次", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
act(() => {
result.current.markSubmitting();
result.current.markSubmitting();
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
// canSubmit 仍为 false已被锁定
expect(result.current.canSubmit).toBe(false);
});
it("markComplete 后 lockRef 解锁,可以再次提交", () => {
const { result } = renderHook(() => useSubmitDedup(examId));
// 第一次提交周期
act(() => {
result.current.markSubmitting();
result.current.markSubmitting(); // 重复点击
});
expect(result.current.canSubmit).toBe(false);
act(() => {
result.current.markComplete();
});
expect(result.current.canSubmit).toBe(true);
// 第二次提交周期
act(() => {
result.current.markSubmitting();
});
expect(result.current.isSubmitting).toBe(true);
expect(result.current.canSubmit).toBe(false);
});
});
describe("examId 变化", () => {
it("examId 变化后 markSubmitting 应使用新 examId 生成 key", () => {
const { result, rerender } = renderHook(
({ id }) => useSubmitDedup(id),
{ initialProps: { id: "exam-001" } },
);
rerender({ id: "exam-002" });
act(() => {
result.current.markSubmitting();
});
expect(result.current.idempotencyKey).toContain("exam-exam-002");
});
});
});