import { describe, it, expect } from "vitest"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createViewport } from "../src/graph/useViewport"; import { resolvePointLabels } from "../src/graph/labelLayout"; import { defineScenario } from "../src/scenarios/types"; import katex from "katex"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); describe("Viewport Transform Primitives", () => { it("khởi tạo đúng kích thước và vùng đệm mặc định", () => { const vp = createViewport({ maxX: 20, maxY: 20, }); expect(vp.width).toBe(680); expect(vp.height).toBe(480); expect(vp.padding).toEqual({ top: 36, right: 44, bottom: 56, left: 64, }); expect(vp.plotWidth).toBe(680 - 64 - 44); // 572 expect(vp.plotHeight).toBe(480 - 36 - 56); // 388 expect(vp.maxX).toBe(20); expect(vp.maxY).toBe(20); }); it("chuyển đổi chính xác tuyệt đối các điểm biên tọa độ", () => { const vp = createViewport({ maxX: 20, maxY: 40, width: 680, height: 480, padding: { top: 36, right: 44, bottom: 56, left: 64 }, }); // Điểm gốc (0, 0) trong hệ kinh tế expect(vp.toScreenX(0)).toBe(64); expect(vp.toScreenY(0)).toBe(480 - 56); // 424 // Điểm cực đại (maxX, maxY) expect(vp.toScreenX(20)).toBe(680 - 44); // 636 expect(vp.toScreenY(40)).toBe(36); // Điểm chính giữa (10, 20) expect(vp.toScreenX(10)).toBe(64 + 572 / 2); // 350 expect(vp.toScreenY(20)).toBe(36 + 388 / 2); // 230 }); it("biến đổi qua lại toScreenX/toModelX và toScreenY/toModelY bảo toàn 100% giá trị", () => { const vp = createViewport({ maxX: 25, maxY: 50, width: 720, height: 500, padding: { top: 40, right: 50, bottom: 60, left: 70 }, }); const testPoints = [ { x: 0, y: 0 }, { x: 25, y: 50 }, { x: 12.5, y: 25 }, { x: 6.28, y: 14.159 }, { x: 19.999, y: 49.999 }, ]; for (const pt of testPoints) { const sx = vp.toScreenX(pt.x); const sy = vp.toScreenY(pt.y); const mx = vp.toModelX(sx); const my = vp.toModelY(sy); expect(mx).toBeCloseTo(pt.x, 10); expect(my).toBeCloseTo(pt.y, 10); } }); it("xử lý biên an toàn khi maxX hoặc maxY không hợp lệ", () => { const vp = createViewport({ maxX: 0, maxY: -5, }); expect(vp.maxX).toBe(1); expect(vp.maxY).toBe(1); expect(Number.isFinite(vp.toScreenX(0.5))).toBe(true); expect(Number.isFinite(vp.toScreenY(0.5))).toBe(true); }); it("gộp các điểm trùng tọa độ thành A ≡ B và tự động đẩy tách nhãn khi 2 điểm gần nhau", () => { const vp = createViewport({ maxX: 24, maxY: 24 }); // Khi 2 điểm A và B trùng nhau tại (10, 10) const merged = resolvePointLabels( [ { id: "pt-a", x: 10, y: 10, label: "A", sublabel: "(10, 10)", color: "#0ea5e9" }, { id: "pt-b", x: 10, y: 10, label: "B", sublabel: "(10.0, 10.0)", color: "#e11d48" }, ], [], vp, false ); expect(merged).toHaveLength(1); expect(merged[0].titleText).toBe("A ≡ B"); expect(merged[0].coordText).toBe(" (10, 10)"); // Khi 2 điểm A(10, 10) và B(9.5, 10.5) nằm gần nhau nhưng chưa trùng const separated = resolvePointLabels( [ { id: "pt-a", x: 10, y: 10, label: "A", sublabel: "(10, 10)", color: "#0ea5e9" }, { id: "pt-b", x: 9.5, y: 10.5, label: "B", sublabel: "(9.5, 10.5)", color: "#e11d48" }, ], [], vp, false ); expect(separated).toHaveLength(2); const distBetweenLabels = Math.hypot( separated[0].labelX - separated[1].labelX, separated[0].labelY - separated[1].labelY ); expect(distBetweenLabels).toBeGreaterThan(16); }); }); describe("Scenario Contract & Helpers", () => { it("defineScenario trả về đúng cấu hình không làm biến đổi dữ liệu", () => { const mockScenario = defineScenario({ meta: { id: "test.module", domain: "micro", category: "consumer", chapter: "PR Ch.3", title: "Kịch bản thử nghiệm", source: "Pindyck & Rubinfeld (2015)", notebook_id: "test_notebook", tags: ["test"], }, params: { x: { id: "x", label: "Giá trị X", min: 0, max: 10, step: 1, default: 5, }, }, model: (p: { x: number }) => ({ doubleX: p.x * 2 }), view: (r: { doubleX: number }) => ({ graph: { title: "Đồ thị thử nghiệm", xAxis: { label: "X", max: 10 }, yAxis: { label: "Y", max: 20 }, }, stats: [ { id: "res", label: "Kết quả", value: r.doubleX, color: "blue", }, ], }), explain: () => null, }); expect(mockScenario.meta.id).toBe("test.module"); expect(mockScenario.params.x.default).toBe(5); const result = mockScenario.model({ x: 7 }); expect(result.doubleX).toBe(14); }); it("KaTeX render công thức toán học hợp lệ", () => { const latex = "U(X, Y) = X \\cdot Y"; const rendered = katex.renderToString(latex, { throwOnError: false }); expect(rendered).toContain("katex"); expect(rendered).toContain("U(X, Y)"); }); }); describe("UI Typography Compliance (Quy tắc không dùng gạch nối trong tiếng Việt)", () => { it("không có ký tự gạch nối sai quy tắc trong mọi chuỗi tiếng Việt tại src/ui/ và src/App.tsx", () => { const uiDir = path.resolve(__dirname, "../src/ui"); const uiFiles = fs.readdirSync(uiDir).filter((f: string) => f.endsWith(".ts") || f.endsWith(".tsx")); const allFiles = uiFiles.map((f: string) => path.join(uiDir, f)); const appFile = path.resolve(__dirname, "../src/App.tsx"); if (fs.existsSync(appFile)) { allFiles.push(appFile); } const vietnameseCharRegex = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđĐ]/i; const illegalDashRegex = /[-–—]/; const violations: { file: string; line: number; text: string }[] = []; for (const filePath of allFiles) { const fileName = path.basename(filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); const lines = fileContent.split("\n"); lines.forEach((lineText: string, lineIdx: number) => { // Trích xuất các chuỗi ký tự trong dấu nháy hoặc trong thẻ JSX const stringLiterals = lineText.match(/(["'`])(?:(?=(\\?))\2.)*?\1|>([^<>{}]*)\"'`]|[\"<'\`]$/g, "").trim(); // Nếu có chữ tiếng Việt, kiểm tra xem có chứa gạch nối không if (vietnameseCharRegex.test(cleanText) && illegalDashRegex.test(cleanText)) { violations.push({ file: fileName, line: lineIdx + 1, text: cleanText, }); } } } }); } if (violations.length > 0) { const details = violations .map((v) => `[${v.file}:${v.line}] -> "${v.text}"`) .join("\n"); expect.fail( `Phát hiện chuỗi tiếng Việt chứa ký tự gạch nối sai quy tắc:\n${details}` ); } expect(violations.length).toBe(0); }); });