import { describe, it, expect } from "vitest"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { inkindVsCashScenario, type InKindVsCashParams, } from "../../src/scenarios/micro/consumer/inkind_vs_cash"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); describe("Scenario Module 3B: Trợ cấp hiện vật vs Tiền mặt (In-kind vs Cash)", () => { const vietnameseCharRegex = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđĐ]/i; const illegalDashRegex = /[-–—]/; it("Metadata tuân thủ chuẩn FSPPM và thuộc domain bridge", () => { const meta = inkindVsCashScenario.meta; expect(meta.id).toBe("micro.consumer.inkind_vs_cash"); expect(meta.domain).toBe("bridge"); expect(meta.category).toBe("consumer"); expect(meta.chapter).toBe("PR Ch.3 • Hình 3.17"); expect(meta.title).toBe("Trợ cấp hiện vật và trợ cấp tiền mặt"); 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, tr. 67, 68, 91"); expect(meta.inputBanner?.length).toBe(4); }); it("Khai báo đầy đủ 4 presets chuẩn hóa (Jane Trust & Food Stamps)", () => { const presets = inkindVsCashScenario.presets; expect(presets).toBeDefined(); expect(presets?.jane_trust_distorted.params).toEqual({ alpha: 0.3, transfer: 80, }); expect(presets?.jane_trust_nonbinding.params).toEqual({ alpha: 0.3, transfer: 40, }); expect(presets?.food_stamps_distorted.params).toEqual({ alpha: 0.25, transfer: 70, }); expect(presets?.food_stamps_nonbinding.params).toEqual({ alpha: 0.25, transfer: 30, }); }); describe("Bất biến toán học (Model Invariants)", () => { it("Preset Jane distorted (T = 80 > T* ≈ 42.86): B(80, 100), C(54, 126), CE ≈ 72.27, DWL ≈ 7.73", () => { const params: InKindVsCashParams = { alpha: 0.3, transfer: 80 }; const res = inkindVsCashScenario.model(params); expect(res.bindingThresholdT).toBeCloseTo(42.8571, 3); expect(res.isDistorted).toBe(true); const { pointA, pointB, pointC } = res.analysis; expect(pointA.x).toBe(30); expect(pointA.y).toBe(70); expect(pointB.x).toBe(80); expect(pointB.y).toBe(100); expect(pointB.utility).toBeCloseTo(93.5248, 3); expect(pointB.mrs).toBeCloseTo(0.5357, 3); expect(pointC.x).toBe(54); expect(pointC.y).toBe(126); expect(pointC.utility).toBeCloseTo(97.72, 2); expect(pointC.utility).toBeGreaterThan(pointB.utility); expect(res.cashEquivalent).toBeCloseTo(72.27, 2); expect(res.deadweightLoss).toBeCloseTo(7.73, 2); }); it("Preset Jane nonbinding (T = 40 < T*): Không méo mó, B trùng C tại (42, 98), DWL = 0", () => { const params: InKindVsCashParams = { alpha: 0.3, transfer: 40 }; const res = inkindVsCashScenario.model(params); expect(res.isDistorted).toBe(false); const { pointB, pointC } = res.analysis; expect(pointB.x).toBeCloseTo(42, 4); expect(pointB.y).toBeCloseTo(98, 4); expect(pointC.x).toBeCloseTo(42, 4); expect(pointC.y).toBeCloseTo(98, 4); expect(pointB.utility).toBeCloseTo(pointC.utility, 4); expect(res.deadweightLoss).toBe(0); expect(res.cashEquivalent).toBe(40); }); it("Preset Food Stamps distorted (T = 70 > T* ≈ 33.33): B(70, 100), C(42.5, 127.5), CE ≈ 60.51, DWL ≈ 9.49", () => { const params: InKindVsCashParams = { alpha: 0.25, transfer: 70 }; const res = inkindVsCashScenario.model(params); expect(res.bindingThresholdT).toBeCloseTo(33.3333, 3); expect(res.isDistorted).toBe(true); const { pointB, pointC } = res.analysis; expect(pointB.x).toBe(70); expect(pointB.y).toBe(100); expect(pointC.x).toBe(42.5); expect(pointC.y).toBe(127.5); expect(pointC.utility).toBeGreaterThan(pointB.utility); expect(res.cashEquivalent).toBeCloseTo(60.51, 2); expect(res.deadweightLoss).toBeCloseTo(9.49, 2); }); it("Preset Food Stamps nonbinding (T = 30 < T*): B trùng C tại (32.5, 97.5), DWL = 0", () => { const params: InKindVsCashParams = { alpha: 0.25, transfer: 30 }; const res = inkindVsCashScenario.model(params); expect(res.isDistorted).toBe(false); const { pointB, pointC } = res.analysis; expect(pointB.x).toBeCloseTo(32.5, 4); expect(pointB.y).toBeCloseTo(97.5, 4); expect(pointC.x).toBeCloseTo(32.5, 4); expect(pointC.y).toBeCloseTo(97.5, 4); expect(res.deadweightLoss).toBe(0); expect(res.cashEquivalent).toBe(30); }); }); describe("Cấu trúc View và Bảng đối sánh 4 cột", () => { it("Bảng đối sánh có đủ 4 cột (Tiêu chí, Gốc A, Hiện vật B, Tiền mặt C)", () => { const params: InKindVsCashParams = { alpha: 0.3, transfer: 80 }; const result = inkindVsCashScenario.model(params); const view = inkindVsCashScenario.view(result, params); expect(view.comparison).toBeDefined(); expect(view.comparison?.headers.length).toBe(4); expect(view.comparison?.headers).toEqual([ "Tiêu chí", "Gốc (Điểm A)", "Hiện vật (Điểm B)", "Tiền mặt (Điểm C)", ]); const rows = view.comparison?.rows ?? []; expect(rows.length).toBe(4); rows.forEach((row) => { expect(row.cashValue).toBeDefined(); }); // Stats expect(view.stats.length).toBe(4); expect(view.stats.find((s) => s.id === "cash-equiv")).toBeDefined(); expect(view.stats.find((s) => s.id === "dwl")).toBeDefined(); // BridgeLinks sang Vĩ mô (IS:LM, MPC, số nhân) expect(view.bridgeLinks?.length).toBeGreaterThan(0); expect(view.bridgeLinks?.some((l) => l.domain === "macro")).toBe(true); }); }); describe("Kiểm tra Tự động Toàn diện Quy tắc Typography (Toàn bộ mã nguồn trong src/)", () => { it("Không có bất kỳ chuỗi tiếng Việt nào chứa ký tự gạch nối/ngang sai quy tắc trong toàn bộ src/", () => { const srcDir = path.resolve(__dirname, "../../src"); // Đệ quy tìm toàn bộ các file .ts và .tsx const findSourceFiles = (dir: string): string[] => { const entries = fs.readdirSync(dir, { withFileTypes: true }); const files: string[] = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...findSourceFiles(fullPath)); } else if ( entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) ) { files.push(fullPath); } } return files; }; const sourceFiles = findSourceFiles(srcDir); const violations: { file: string; line: number; text: string }[] = []; for (const filePath of sourceFiles) { const relativePath = path.relative(srcDir, filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); const lines = fileContent.split("\n"); lines.forEach((lineText: string, lineIdx: number) => { // Bỏ qua dòng comment đơn dòng nếu là comment kỹ thuật tiếng Anh const trimmed = lineText.trim(); if (trimmed.startsWith("//") && !vietnameseCharRegex.test(trimmed)) { return; } // 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(); if ( vietnameseCharRegex.test(cleanText) && illegalDashRegex.test(cleanText) ) { violations.push({ file: relativePath, 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 trong src/:\n${details}` ); } expect(violations.length).toBe(0); }); }); });