import { describe, it, expect } from "vitest"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { cornerSolutionScenario, type CornerSolutionParams, } from "../../src/scenarios/micro/consumer/corner_solution"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); describe("Scenario Module Corner Solution: Trục tọa độ và Đỉnh gãy khúc", () => { const defaultParams: CornerSolutionParams = { exampleCase: 1, yogurtPrice: 2.0, iceCreamTaste: 0.85, trustFund: 80, trustAlpha: 0.30, foodStamp: 70, stampAlpha: 0.25, }; it("1. presets rỗng hoặc undefined; subCases dài đúng 3 và đổi badge thành Tình huống", () => { const presets = cornerSolutionScenario.presets; const isPresetsEmpty = !presets || Object.keys(presets).length === 0; expect(isPresetsEmpty).toBe(true); const subCases = cornerSolutionScenario.subCases; expect(subCases).toBeDefined(); expect(subCases?.length).toBe(3); expect(subCases![0].badge).toBe("Tình huống 1"); expect(subCases![1].badge).toBe("Tình huống 2"); expect(subCases![2].badge).toBe("Tình huống 3"); // Chỉ có đúng 2 thanh trượt nhìn thấy theo từng tình huống const pDefs = Object.values(cornerSolutionScenario.params); const visibleCase1 = pDefs.filter((p) => p.visibleWhen?.({ ...defaultParams, exampleCase: 1 })); const visibleCase2 = pDefs.filter((p) => p.visibleWhen?.({ ...defaultParams, exampleCase: 2 })); const visibleCase3 = pDefs.filter((p) => p.visibleWhen?.({ ...defaultParams, exampleCase: 3 })); expect(visibleCase1.length).toBe(2); expect(visibleCase1.map((p) => p.id)).toEqual(["yogurtPrice", "iceCreamTaste"]); expect(visibleCase2.length).toBe(2); expect(visibleCase2.map((p) => p.id)).toEqual(["trustFund", "trustAlpha"]); expect(visibleCase3.length).toBe(2); expect(visibleCase3.map((p) => p.id)).toEqual(["foodStamp", "stampAlpha"]); }); describe("2. Tình huống 1: Kem và Sữa chua (Hình 3.15)", () => { it("Py = 2.0 và Py = 1.5 vẫn ở góc B(20, 0); Py = 0.8 cho nghiệm nội điểm X > 0, Y > 0", () => { // Py = 2.0 const resPy2 = cornerSolutionScenario.model({ ...defaultParams, exampleCase: 1, yogurtPrice: 2.0, iceCreamTaste: 0.85, }); expect(resPy2.exampleCase).toBe(1); if (resPy2.exampleCase === 1) { expect(resPy2.opt.x).toBeCloseTo(20, 3); expect(resPy2.opt.y).toBeCloseTo(0, 3); expect(resPy2.isCorner).toBe(true); expect(resPy2.thresholdPriceYogurt).toBeCloseTo(18 / 17, 3); } // Py = 1.5 (giảm giá nhẹ không đổi hành vi, vẫn ở góc) const resPy15 = cornerSolutionScenario.model({ ...defaultParams, exampleCase: 1, yogurtPrice: 1.5, iceCreamTaste: 0.85, }); if (resPy15.exampleCase === 1) { expect(resPy15.opt.x).toBeCloseTo(20, 3); expect(resPy15.opt.y).toBeCloseTo(0, 3); expect(resPy15.isCorner).toBe(true); } // Py = 0.8 (giảm sâu vượt ngưỡng P* ≈ 1.059, chuyển sang nghiệm nội điểm) const resPy08 = cornerSolutionScenario.model({ ...defaultParams, exampleCase: 1, yogurtPrice: 0.8, iceCreamTaste: 0.85, }); if (resPy08.exampleCase === 1) { expect(resPy08.opt.x).toBeGreaterThan(0); expect(resPy08.opt.y).toBeGreaterThan(0); expect(resPy08.isCorner).toBe(false); } }); it("Giá sữa chua ngưỡng P* xấp xỉ 1.059 khi a = 0.85", () => { const res = cornerSolutionScenario.model({ ...defaultParams, exampleCase: 1, yogurtPrice: 2.0, iceCreamTaste: 0.85, }); if (res.exampleCase === 1) { // MRS_B = (0.85/0.15)*(10/30) = 17/9 ≈ 1.8889 // P* = Px / MRS_B = 2 / (17/9) = 18/17 ≈ 1.0588 expect(res.thresholdPriceYogurt).toBeCloseTo(1.0588, 3); } }); }); describe("3. Tình huống 2: Quỹ học tập của Jane (Hình 3.17)", () => { it("alpha = 0.30, T = 80: B = (80, 100), C ≈ (54, 126), U_C > U_B > U_A, CE ≈ 72.27", () => { const res = cornerSolutionScenario.model({ ...defaultParams, exampleCase: 2, trustFund: 80, trustAlpha: 0.30, }); expect(res.exampleCase).toBe(2); if (res.exampleCase === 2) { expect(res.opt.x).toBe(80); expect(res.opt.y).toBe(100); expect(res.isCorner).toBe(true); expect(res.analysis.pointC.x).toBeCloseTo(54, 1); expect(res.analysis.pointC.y).toBeCloseTo(126, 1); expect(res.analysis.pointC.utility).toBeGreaterThan(res.opt.utility); expect(res.opt.utility).toBeGreaterThan(res.analysis.pointA.utility); expect(res.cashEquivalent).toBeCloseTo(72.27, 1); expect(res.lossValue).toBeCloseTo(80 - 72.27, 1); } }); it("T = 30: B trùng C (hiện vật tương đương tiền mặt, không méo mó)", () => { const res = cornerSolutionScenario.model({ ...defaultParams, exampleCase: 2, trustFund: 30, trustAlpha: 0.30, }); if (res.exampleCase === 2) { expect(res.isCorner).toBe(false); expect(res.opt.x).toBeCloseTo(res.analysis.pointC.x, 3); expect(res.opt.y).toBeCloseTo(res.analysis.pointC.y, 3); expect(res.cashEquivalent).toBe(30); expect(res.lossValue).toBe(0); } }); }); describe("4. Tình huống 3: Tem phiếu thực phẩm (PR Ch.3)", () => { it("alpha = 0.25, T = 70: B = (70, 100), C ≈ (42.5, 127.5), CE ≈ 60.51", () => { const res = cornerSolutionScenario.model({ ...defaultParams, exampleCase: 3, foodStamp: 70, stampAlpha: 0.25, }); expect(res.exampleCase).toBe(3); if (res.exampleCase === 3) { expect(res.opt.x).toBe(70); expect(res.opt.y).toBe(100); expect(res.isCorner).toBe(true); expect(res.analysis.pointC.x).toBeCloseTo(42.5, 1); expect(res.analysis.pointC.y).toBeCloseTo(127.5, 1); expect(res.cashEquivalent).toBeCloseTo(60.51, 1); expect(res.lossValue).toBeCloseTo(70 - 60.51, 1); } }); }); describe("5. Thống nhất ngữ pháp thị giác giữa cả 3 tình huống", () => { it("buildView của cả 3 tình huống có đúng 1 điểm được đánh dấu là lựa chọn thực tế", () => { const params1 = { ...defaultParams, exampleCase: 1 }; const res1 = cornerSolutionScenario.model(params1); const view1 = cornerSolutionScenario.view(res1, params1); const params2 = { ...defaultParams, exampleCase: 2 }; const res2 = cornerSolutionScenario.model(params2); const view2 = cornerSolutionScenario.view(res2, params2); const params3 = { ...defaultParams, exampleCase: 3 }; const res3 = cornerSolutionScenario.model(params3); const view3 = cornerSolutionScenario.view(res3, params3); const actualPts1 = view1.graph.points?.filter((p) => p.id === "actual-choice"); const actualPts2 = view2.graph.points?.filter((p) => p.id === "actual-choice"); const actualPts3 = view3.graph.points?.filter((p) => p.id === "actual-choice"); expect(actualPts1).toHaveLength(1); expect(actualPts2).toHaveLength(1); expect(actualPts3).toHaveLength(1); }); it("legend có cùng quy ước 4 tên ở cả 3 tình huống khi ở giải pháp góc", () => { const params1 = { ...defaultParams, exampleCase: 1, yogurtPrice: 2.0 }; const res1 = cornerSolutionScenario.model(params1); const view1 = cornerSolutionScenario.view(res1, params1); const params2 = { ...defaultParams, exampleCase: 2, trustFund: 80 }; const res2 = cornerSolutionScenario.model(params2); const view2 = cornerSolutionScenario.view(res2, params2); const params3 = { ...defaultParams, exampleCase: 3, foodStamp: 70 }; const res3 = cornerSolutionScenario.model(params3); const view3 = cornerSolutionScenario.view(res3, params3); const expectedNames = [ "Ngân sách thực tế", "Ngân sách tham chiếu", "Phần bị chặn", "Đường bàng quan", ]; expect(view1.graph.legends?.map((l) => l.label)).toEqual(expectedNames); expect(view2.graph.legends?.map((l) => l.label)).toEqual(expectedNames); expect(view3.graph.legends?.map((l) => l.label)).toEqual(expectedNames); }); it("stats card dùng cùng 4 ô ở cả 3 tình huống", () => { const cases = [1, 2, 3]; for (const c of cases) { const p = { ...defaultParams, exampleCase: c }; const res = cornerSolutionScenario.model(p); const view = cornerSolutionScenario.view(res, p); expect(view.stats).toHaveLength(4); expect(view.stats[0].label).toBe("Lựa chọn thực tế"); expect(view.stats[1].label).toBe("Có ở góc không?"); expect(view.stats[2].label).toBe("Điều gì đang chặn"); } }); }); describe("6. Quét chuỗi tiếng Việt trong các file corner_solution không có dấu gạch", () => { 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 corner_solution/", () => { const dir = path.resolve(__dirname, "../../src/scenarios/micro/consumer/corner_solution"); const files = ["index.ts", "explain.tsx"].map((f) => path.join(dir, f)); const vietnameseCharRegex = /[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđĐ]/i; const illegalDashRegex = /[-–—]/; const violations: { file: string; line: number; text: string }[] = []; for (const filePath of files) { const fileName = path.basename(filePath); const fileContent = fs.readFileSync(filePath, "utf-8"); const lines = fileContent.split("\n"); lines.forEach((lineText: string, lineIdx: number) => { const stringLiterals = lineText.match(/(["'`])(?:(?=(\\?))\2.)*?\1|>([^<>{}]*)\"'`]|[\"<'\`]$/g, "").trim(); 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); }); }); });