import { describe, it, expect } from "vitest"; import { rationingScenario, type RationingParams, } from "../../src/scenarios/micro/consumer/rationing"; describe("Scenario Module 1: Phân phối định lượng (Rationing)", () => { const vietnameseCharRegex = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđĐ]/i; const illegalDashRegex = /[-–—]/; it("Metadata tuân thủ chuẩn FSPPM và trích dẫn Pindyck & Rubinfeld", () => { const meta = rationingScenario.meta; expect(meta.id).toBe("micro.consumer.rationing"); expect(meta.domain).toBe("micro"); expect(meta.category).toBe("consumer"); expect(meta.chapter).toBe("PR Ch.3 • Câu 10a"); expect(meta.title).toBe("Phân phối định lượng hàng hóa"); expect(meta.notebook_id).toBe("e8a30630-0df2-4932-8740-9ffe7d8882bc"); expect(meta.source).toContain("Pindyck, R. S., & Rubinfeld, D. L. (2015)"); expect(meta.source).toContain("Chương 3, Câu 10a, tr. 107"); expect(meta.inputBanner).toBeDefined(); expect(meta.inputBanner?.length).toBe(5); }); it("Khai báo đầy đủ 3 presets chuẩn hóa", () => { const presets = rationingScenario.presets; expect(presets).toBeDefined(); expect(presets?.strict.params.quotaX).toBe(6); expect(presets?.mild.params.quotaX).toBe(8); expect(presets?.non_binding.params.quotaX).toBe(12); }); describe("Bất biến toán học (Model Invariants)", () => { it("Khi X̄ = 6 (Preset strict: Siết chặt): B(6, 14), U = 84, MRS ≈ 2.33, isBinding = true", () => { const params: RationingParams = { quotaX: 6 }; const res = rationingScenario.model(params); expect(res.optA.x).toBe(10); expect(res.optA.y).toBe(10); expect(res.optA.utility).toBe(100); expect(res.optA.mrs).toBeCloseTo(1.0, 4); expect(res.optB.x).toBe(6); expect(res.optB.y).toBe(14); expect(res.optB.utility).toBe(84); expect(res.optB.mrs).toBeCloseTo(14 / 6, 4); expect(res.optB.solutionType).toBe("corner_kink"); expect(res.isBinding).toBe(true); expect(res.deltaU).toBe(-16); expect(res.vertices.length).toBe(4); }); it("Khi X̄ = 8 (Preset mild: Hạn mức nhẹ): B(8, 12), U = 96, MRS = 1.5, isBinding = true", () => { const params: RationingParams = { quotaX: 8 }; const res = rationingScenario.model(params); expect(res.optB.x).toBe(8); expect(res.optB.y).toBe(12); expect(res.optB.utility).toBe(96); expect(res.optB.mrs).toBeCloseTo(1.5, 4); expect(res.isBinding).toBe(true); expect(res.deltaU).toBe(-4); }); it("Khi X̄ = 12 (Preset non_binding: Không ràng buộc): B(10, 10), U = 100, isBinding = false", () => { const params: RationingParams = { quotaX: 12 }; const res = rationingScenario.model(params); expect(res.optB.x).toBe(10); expect(res.optB.y).toBe(10); expect(res.optB.utility).toBe(100); expect(res.optB.mrs).toBeCloseTo(1.0, 4); expect(res.optB.solutionType).toBe("interior"); expect(res.isBinding).toBe(false); expect(res.deltaU).toBe(0); }); it("Ranh giới X̄ = 10: Điểm chuyển tiếp giữa ràng buộc và tự do", () => { const res = rationingScenario.model({ quotaX: 10 }); expect(res.optB.x).toBe(10); expect(res.optB.y).toBe(10); expect(res.optB.utility).toBe(100); expect(res.isBinding).toBe(false); }); }); describe("Cấu trúc View và Khung trực quan hóa", () => { it("Xuất đầy đủ các lớp đồ thị (regions, polylines, curves, guides, points, arrows)", () => { const params: RationingParams = { quotaX: 6 }; const result = rationingScenario.model(params); const view = rationingScenario.view(result, params); // Graph Spec expect(view.graph.xAxis.max).toBe(24); expect(view.graph.yAxis.max).toBe(24); expect(view.graph.regions?.length).toBeGreaterThan(0); expect(view.graph.polylines?.length).toBe(2); // orig & effective expect(view.graph.curves?.length).toBe(2); // U0 & UB expect(view.graph.guides?.length).toBe(3); // guide A, guide B, guide quota expect(view.graph.points?.length).toBe(2); // A & B expect(view.graph.arrows?.length).toBe(1); // shift A -> B khi binding // Stats expect(view.stats.length).toBe(4); expect(view.stats.find((s) => s.id === "opt-a")).toBeDefined(); expect(view.stats.find((s) => s.id === "opt-b")).toBeDefined(); expect(view.stats.find((s) => s.id === "delta-u")).toBeDefined(); expect(view.stats.find((s) => s.id === "mrs")).toBeDefined(); // Steps & Comparison expect(view.steps?.length).toBe(3); expect(view.comparison?.rows.length).toBe(3); expect(view.statusBanner).toBeDefined(); expect(view.statusBanner?.type).toBe("warning"); // BridgeLinks expect(view.bridgeLinks?.length).toBe(2); expect( view.bridgeLinks?.some( (l) => l.targetScenarioId === "micro.consumer.price_ceiling" ) ).toBe(true); }); it("Khi không ràng buộc, mũi tên dịch chuyển biến mất và statusBanner chuyển sang success", () => { const params: RationingParams = { quotaX: 12 }; const result = rationingScenario.model(params); const view = rationingScenario.view(result, params); expect(view.graph.arrows?.length).toBe(0); expect(view.statusBanner?.type).toBe("success"); }); }); describe("Quy tắc Typography tiếng Việt trong View", () => { it("Không chứa bất kỳ dấu gạch nối/ngang sai quy tắc nào trong các chuỗi tiếng Việt của View", () => { const params: RationingParams = { quotaX: 6 }; const result = rationingScenario.model(params); const view = rationingScenario.view(result, params); const inspectStrings = (obj: unknown, path: string): void => { if (typeof obj === "string") { // Bỏ qua công thức toán thuần túy nếu không có chữ tiếng Việt if (vietnameseCharRegex.test(obj)) { if (illegalDashRegex.test(obj)) { expect.fail( `Phát hiện chuỗi tiếng Việt chứa ký tự gạch nối sai quy tắc tại ${path}: "${obj}"` ); } } } else if (Array.isArray(obj)) { obj.forEach((item, idx) => inspectStrings(item, `${path}[${idx}]`)); } else if (obj !== null && typeof obj === "object") { Object.entries(obj).forEach(([key, val]) => { // Không kiểm tra các trường id, symbol toán if (key !== "id" && key !== "symbol") { inspectStrings(val, `${path}.${key}`); } }); } }; inspectStrings(view, "view"); }); }); });