const fs = require("fs"); const path = require("path"); const vm = require("vm"); const staticDir = path.resolve(__dirname, "../app/static"); function domNode(id, extras = {}) { let value = ""; return { id, textContent: "", innerHTML: "", hidden: false, disabled: false, dataset: {}, classList: { toggle() {} }, get value() { return value; }, set value(nextValue) { value = String(nextValue ?? ""); }, setAttribute(name, nextValue) { this[name] = nextValue; }, addEventListener() {}, focus() {}, ...extras, }; } const elements = new Map(); function element(id) { if (!elements.has(id)) { elements.set(id, domNode(id)); } return elements.get(id); } const documentStub = { querySelector(selector) { return element(selector.replace(/^#/, "")); }, querySelectorAll() { return []; }, addEventListener() {}, createElement(tag) { return element(`created-${tag}-${elements.size}`); }, body: { appendChild() {} }, execCommand() { return true; }, }; const context = { console, document: documentStub, navigator: {}, fetch: async () => ({ ok: true, json: async () => ({ classnotes: { mtime: 0 } }) }), Date, JSON, Map, Number, String, }; context.window = { location: { href: "" } }; context.confirm = () => true; context.alert = () => {}; function assertEqual(name, actual, expected) { if (actual !== expected) { throw new Error(`${name}: got ${actual}, expected ${expected}`); } } function value(expression) { return vm.runInContext(expression, context); } vm.createContext(context); vm.runInContext(fs.readFileSync(path.join(staticDir, "app.js"), "utf8"), context); vm.runInContext( ` currentRecords = [ { date: "2026.06.12", weekday: "星期五", time: "10:00-11:00", student: "甲", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 0, _recordKey: "a" }, { date: "2026.06.12", weekday: "星期五", time: "08:00-09:00", student: "乙", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 1, _recordKey: "b" }, { date: "2026.06.13", weekday: "星期六", time: "09:00-10:00", student: "丙", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 2, _recordKey: "c" }, { date: "2026.06.12", weekday: "星期五", time: "07:00-08:00", student: "丁", duration: "1小时0分", duration_hours: 1, teacher: "李老师", subject: "数学", _recordIndex: 3, _recordKey: "d" }, ]; renderGroupedRecords(currentRecords); `, context, ); assertEqual("初始日期按钮文案", value("dateSortBtn.textContent"), "日期 升序排列"); assertEqual("初始时间按钮文案", value("timeSortBtn.textContent"), "时间 升序排列"); assertEqual("默认组内排序", value("currentRecordOrder.join('')"), "bacd"); vm.runInContext("toggleRecordSort('date')", context); assertEqual("日期降序按钮文案", value("dateSortBtn.textContent"), "日期 降序排列"); assertEqual("日期降序组内排序", value("currentRecordOrder.join('')"), "cbad"); vm.runInContext("toggleRecordSort('date'); toggleRecordSort('time')", context); assertEqual("时间降序按钮文案", value("timeSortBtn.textContent"), "时间 降序排列"); assertEqual("日期升序时间降序组内排序", value("currentRecordOrder.join('')"), "abcd"); vm.runInContext( ` correctedRecords.set("a", { ...currentRecords[0], date: "2026.06.11", weekday: "星期四", time: "07:00-08:00" }); renderGroupedRecords(currentRecords); `, context, ); assertEqual("纠错后排序仍使用显示记录", value("currentRecordOrder.join('')"), "abcd"); vm.runInContext("updateCorrectionToolbar('测试提交按钮')", context); assertEqual("提交审核按钮文案", value("submitCorrectionsBtn.textContent"), "提交审核 1 条"); vm.runInContext( ` const longSummaryBody = "完整课程小结内容".repeat(20); currentRecords = [ { date: "2026.06.14", weekday: "星期日", time: "10:00-11:00", student: "甲", duration: "1小时0分", duration_hours: 1, teacher: "王老师", subject: "英语", _recordIndex: 0, _recordKey: "summary-test", summary_count: 1, summaries: [{ title: "课程小结", body: longSummaryBody, teacher: "王老师", subject: "英语" }], }, ]; expandedSummaryRecords = new Set(["summary-test"]); collapsedTeacherGroups = new Set(); const summaryHtml = renderGroupedRecords(currentRecords); if (!summaryHtml.includes(longSummaryBody)) { throw new Error("课程小结展开后应直接显示完整正文"); } if (summaryHtml.includes("展开全文") || summaryHtml.includes("summary-toggle") || summaryHtml.includes("summary-preview")) { throw new Error("课程小结展开后不应再渲染展开全文按钮或预览裁切"); } `, context, ); const adminElements = new Map(); function adminElement(id) { if (!adminElements.has(id)) { adminElements.set(id, domNode(id, { scrollIntoView() {}, })); } return adminElements.get(id); } const adminFetchUrls = []; const adminContext = { console, navigator: {}, Date, JSON, Map, Number, String, URLSearchParams, window: { location: { href: "" } }, confirm: () => true, alert: () => {}, document: { querySelector(selector) { return adminElement(selector.replace(/^#/, "")); }, querySelectorAll() { return []; }, addEventListener() {}, createElement(tag) { return adminElement(`created-${tag}-${adminElements.size}`); }, body: { appendChild() {} }, execCommand() { return true; }, }, fetch: async (url) => { adminFetchUrls.push(String(url)); if (String(url).startsWith("/api/students")) { return { ok: true, json: async () => ({ summary: { total: 1, debt: 0, warning: 0, normal: 1 }, count: 1, students: [ { student_id: "XS001", student: "甲", primary_entry_year: 2020, account_status: "正常", remaining: 12, remaining_duration: "12小时", payments: [{ date: "2026-06-01", hours: 12, duration: "12小时" }], note: "备注", }, ], }), }; } return { ok: true, json: async () => ({ ok: true, students_count: 1, active_teachers_count: 0, records_count: 0, course_summaries_count: 0, current_month_hours: 0, current_month_duration: "0小时", overview: {}, students: { summary: {}, low_remaining: [] }, teachers: {}, course_summaries: {}, tasks: {}, teaching: {}, period: {}, }), }; }, }; vm.createContext(adminContext); vm.runInContext(fs.readFileSync(path.join(staticDir, "admin.js"), "utf8"), adminContext); vm.runInContext("loadStudents()", adminContext); setTimeout(() => { if (!adminFetchUrls.some((url) => url.startsWith("/api/students"))) { throw new Error(`学生档案列表未调用 /api/students:${adminFetchUrls.join(",")}`); } const rowsHtml = adminElements.get("studentRows").innerHTML; if (!rowsHtml.includes("XS001") || !rowsHtml.includes("2020")) { throw new Error("学生档案列表未渲染学生ID和入学年份"); } vm.runInContext('openEditStudentEditor("XS001")', adminContext); const payload = vm.runInContext("buildStudentPayload()", adminContext); if (Object.prototype.hasOwnProperty.call(payload, "remaining")) { throw new Error("学生档案保存 payload 不应包含 remaining"); } assertEqual("学生档案剩余课时只读展示", adminElements.get("editRemaining").value, "12小时"); console.log("smoke test passed"); }, 0);