feat(student-portal): 完成Docker构建验证+全量测试通过+nextstep下游依赖文档
This commit is contained in:
346
apps/student-portal/src/hooks/use-anti-cheat.test.ts
Normal file
346
apps/student-portal/src/hooks/use-anti-cheat.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
281
apps/student-portal/src/hooks/use-exam-state-machine.test.ts
Normal file
281
apps/student-portal/src/hooks/use-exam-state-machine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
239
apps/student-portal/src/hooks/use-submit-dedup.test.ts
Normal file
239
apps/student-portal/src/hooks/use-submit-dedup.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user