diff --git a/UnitTestProject/Modules/SwapModule/ClosePercentProductContractTest.cs b/UnitTestProject/Modules/SwapModule/ClosePercentProductContractTest.cs new file mode 100644 index 00000000..ad44bead --- /dev/null +++ b/UnitTestProject/Modules/SwapModule/ClosePercentProductContractTest.cs @@ -0,0 +1,357 @@ +namespace YLErp.Modules.SwapModule +{ + /// + /// 平仓比例口径A(占期初名义本金)产品契约测试 + /// ================================================================================ + /// 产品需求(不可违背): + /// ClosePercent 永远是"占期初名义本金(NotionalValue)的比例",即口径A。 + /// 例:期初本金 5000 万,首次平 35%(ClosePercent=0.35),剩余 3250 万。 + /// 第二次想平掉剩余的 50%,ClosePercent 应 = 1625万/5000万 = 0.325(占期初), + /// 而非 0.50(占剩余)。 + /// + /// 前后端协作约定: + /// - 前端(unwindSwapTrade.js):ClosePercent 始终以 NotionalValue 为分母计算/显示 + /// - 前端传给后端:ClosePercent 为口径A + /// - 后端入口(SwapUnwind/ApplySwapTrade):ToRemainingClosePercent 将 A→B 供内部计算 + /// - 后端落库(SaveSwapDealInternal):ToOriginalClosePercent 将 B→A 还原存储 + /// - 后端 InitUnwind 默认值:CalcDefaultInitClosePercent = PosiNotionalValue/NotionalValue(口径A) + /// + /// 本测试守护的回归场景(c9071a4e 曾犯的错误): + /// 1. 前端把 oriClosePercent 硬编码为 1(应为 PosiNotionalValue/NotionalValue) + /// 2. 前端把 CloseNotionalValue 分母从 NotionalValue 改为 PosiNotionalValue + /// 3. 前端把 ClosePercent 分母从 NotionalValue 改为 PosiNotionalValue + /// 4. 前端把 getInterestList 的 notionalValue/posiNotionalValue 参数去掉 + /// 5. 前端把 calcCloseQtyByPercent 从 SwapCalc.calcCloseQtyByOriginalPercent 改为直接乘 + /// 6. 后端 InitUnwind 默认 ClosePercent 改为 1 而非剩余比例 + /// + /// 与既有测试的关系: + /// - ApplySwapTradeClosePercentBugTest:测 A→B 转换函数正确性(函数级) + /// - InitUnwindDefaultClosePercentTest:测默认值函数正确性(函数级) + /// - 本测试:测完整多步场景的口径A契约(场景级),补齐"装配测试"盲区 + /// ================================================================================ + [TestClass] + public class ClosePercentProductContractTest + { + // GLMS-20260701-0006 真实数据 + private const decimal OriginalNotional = 50_000_000m; // 期初名义本金(NotionalValue) + private const decimal FirstClosePercentA = 0.35m; // 第一次平仓35%(口径A) + private const decimal RemainingAfter1st = 32_500_000m; // 首次平35%后剩余(PosiNotionalValue) + // 第二次想平掉剩余的 50% → 平仓额=16,250,000 → ClosePercent(A)=1625万/5000万=0.325 + private const decimal SecondCloseNotional = 16_250_000m; + private const decimal SecondClosePercentA = 0.325m; // 口径A:占期初 + private const decimal SecondClosePercentB = 0.50m; // 口径B:占剩余 + + // ================================================================ + // 契约1:ClosePercent = CloseNotionalValue / NotionalValue(口径A) + // 如果有人把分母改成 PosiNotionalValue,此测试会红 + // ================================================================ + [TestMethod] + public void CPC_001_平仓比例必须用期初名义本金为分母_而非剩余名义本金() + { + // 正确:口径A = 平仓名义本金 / 期初名义本金 + decimal correctA = SecondCloseNotional / OriginalNotional; + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentA, correctA, 1e-10m, + "口径A:16,250,000 / 50,000,000 = 0.325"); + + // 错误:口径B = 平仓名义本金 / 剩余名义本金 + decimal buggyB = SecondCloseNotional / RemainingAfter1st; + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentB, buggyB, 1e-10m, + "口径B(错误):16,250,000 / 32,500,000 = 0.50"); + + // 两者必须不同——如果相同说明测试场景退化(首次平仓 remaining==original) + Assert.AreNotEqual(correctA, buggyB, + "口径A(0.325) 和 口径B(0.50) 在多次部分平仓后必须不同,否则测试场景退化"); + Console.WriteLine($"口径A={correctA}(正确),口径B={buggyB}(错误)"); + } + + // ================================================================ + // 契约2:CloseNotionalValue = ClosePercent × NotionalValue(口径A) + // 如果有人把乘数改成 PosiNotionalValue,此测试会红 + // ================================================================ + [TestMethod] + public void CPC_002_平仓名义本金必须用期初名义本金为乘数_而非剩余名义本金() + { + // 正确:口径A → CloseNotionalValue = ClosePercent(A) × NotionalValue + decimal correctNotional = SecondClosePercentA * OriginalNotional; + SwapDealTestFactory.AssertDecimalEqual(SecondCloseNotional, correctNotional, 1e-6m, + "口径A:0.325 × 50,000,000 = 16,250,000"); + + // 错误:口径B → CloseNotionalValue = ClosePercent(A) × PosiNotionalValue + // 如果前端传口径A的0.325但误用 PosiNotionalValue 做乘数 + decimal buggyNotional = SecondClosePercentA * RemainingAfter1st; + // 0.325 × 32,500,000 = 10,562,500 ≠ 16,250,000 + Assert.AreNotEqual(SecondCloseNotional, buggyNotional, + "口径A的0.325 × 剩余本金32,500,000 = 10,562,500 ≠ 16,250,000,乘数错了"); + Console.WriteLine($"正确={correctNotional},错误(用剩余)={buggyNotional}"); + } + + // ================================================================ + // 契约3:oriClosePercent = PosiNotionalValue / NotionalValue(不能硬编码为1) + // 如果有人把 oriClosePercent 改成 1,此测试会红 + // ================================================================ + [TestMethod] + public void CPC_003_最多可平比例必须为剩余除以期初_不能硬编码为1() + { + // 正确:oriClosePercent = PosiNotionalValue / NotionalValue + decimal correctOri = RemainingAfter1st / OriginalNotional; + SwapDealTestFactory.AssertDecimalEqual(0.65m, correctOri, 1e-10m, + "oriClosePercent = 32,500,000 / 50,000,000 = 0.65"); + + // 错误:硬编码为 1(c9071a4e 的错误) + decimal buggyOri = 1m; + Assert.AreNotEqual(correctOri, buggyOri, + "多次部分平仓后 oriClosePercent 必须小于 1,硬编码 1 会允许平超过剩余持仓"); + + // 首次平仓时 oriClosePercent 才等于 1(remaining == original) + decimal firstTimeOri = OriginalNotional / OriginalNotional; + SwapDealTestFactory.AssertDecimalEqual(1m, firstTimeOri, 1e-10m, + "首次平仓时 oriClosePercent = 1(remaining == original)"); + Console.WriteLine($"多次部分平仓后:oriClosePercent={correctOri}(≠1),首次:{firstTimeOri}(=1)"); + } + + // ================================================================ + // 契约4:CloseQty 必须经过 A→B 转换,不能直接 PositionQty × ClosePercent(A) + // 如果有人删除 calcCloseQtyByOriginalPercent 调用改为直接乘,此测试会红 + // ================================================================ + [TestMethod] + public void CPC_004_平仓数量必须经过口径A到B转换_不能直接乘() + { + // 场景:期初数量 32,500,000(=剩余数量),oriClosePercent=0.65 + // 用户输入 ClosePercent(A) = 0.325(想平剩余的 50%) + decimal positionQty = RemainingAfter1st; // 32,500,000 + decimal oriClosePercent = RemainingAfter1st / OriginalNotional; // 0.65 + + // 正确:CloseQty = PositionQty × (ClosePercent(A) / oriClosePercent) + // = 32,500,000 × (0.325 / 0.65) = 32,500,000 × 0.5 = 16,250,000 + decimal correctQty = positionQty * (SecondClosePercentA / oriClosePercent); + SwapDealTestFactory.AssertDecimalEqual(16_250_000m, correctQty, 1e-6m, + "正确:32,500,000 × (0.325/0.65) = 16,250,000"); + + // 错误:CloseQty = PositionQty × ClosePercent(A)(直接乘,不做转换) + // = 32,500,000 × 0.325 = 10,562,500 ❌ + decimal buggyQty = positionQty * SecondClosePercentA; + Assert.AreNotEqual(correctQty, buggyQty, + "直接乘会得到 10,562,500 而非 16,250,000,数量算少 35%"); + + // JS 浮点精度守卫:32500000×(0.5/0.65) 可能 = 24999999.999999996 + // SwapCalc.calcCloseQtyByOriginalPercent 用 roundHalfAwayFromZero 修复 + decimal jsFloatTrap = (decimal)((double)positionQty * ((double)SecondClosePercentA / (double)oriClosePercent)); + Console.WriteLine($"正确={correctQty},错误(直接乘)={buggyQty},JS浮点陷阱={jsFloatTrap}"); + } + + // ================================================================ + // 契约5:后端 A→B→A 往返转换必须还原(多次部分平仓场景) + // 守护 SwapUnwind/ApplySwapTrade 入口的 ToRemainingClosePercent + SaveSwapDealInternal 的 ToOriginalClosePercent + // ================================================================ + [TestMethod] + public void CPC_005_多次部分平仓_A到B到A往返转换必须还原原值() + { + // 第二次部分平仓:前端传 ClosePercent(A) = 0.325 + decimal closePercentA = SecondClosePercentA; + + // 后端入口:A → B + decimal closePercentB = SwapDealService.ToRemainingClosePercent( + closePercentA, OriginalNotional, RemainingAfter1st); + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentB, closePercentB, 1e-10m, + "A→B:0.325 × 50,000,000 / 32,500,000 = 0.50"); + + // 后端落库:B → A 还原 + decimal restoredA = SwapDealService.ToOriginalClosePercent( + closePercentB, OriginalNotional, RemainingAfter1st); + SwapDealTestFactory.AssertDecimalEqual(closePercentA, restoredA, 1e-10m, + "B→A 还原:0.50 × 32,500,000 / 50,000,000 = 0.325(必须等于原始A)"); + + Console.WriteLine($"A={closePercentA} → B={closePercentB} → A'={restoredA} ✅ 往返还原"); + } + + // ================================================================ + // 契约6:后端 InitUnwind 默认 ClosePercent = PosiNotionalValue / NotionalValue(口径A) + // 不能硬编码为 1(c9071a4e 前端硬编码1的错误在后端等价于此) + // ================================================================ + [TestMethod] + public void CPC_006_InitUnwind默认值必须为剩余除以期初_不能硬编码为1() + { + // 多次部分平仓后:期初 50M,剩余 32.5M + decimal defaultValue = SwapDealService.CalcDefaultInitClosePercent( + OriginalNotional, RemainingAfter1st); + + // 正确:0.65(占期初的"平剩余全部"比例) + SwapDealTestFactory.AssertDecimalEqual(0.65m, defaultValue, 1e-10m, + "CalcDefaultInitClosePercent(50M, 32.5M) = 0.65(口径A)"); + + // 不能是 1(硬编码错误) + Assert.AreNotEqual(1m, defaultValue, + "多次部分平仓后默认值不能为1,否则意味着'平掉原始全部'而非'平剩余全部'"); + + Console.WriteLine($"InitUnwind 默认 ClosePercent(A) = {defaultValue}(≠1)✅"); + } + + // ================================================================ + // 契约7:SwapUnwind 第二次部分平仓必须正确做 A→B 转换 + // 这是"装配测试"——验证后端入口确实执行了转换,而不只是函数本身正确 + // ================================================================ + [TestMethod] + public void CPC_007_SwapUnwind第二次部分平仓_入口必须将ClosePercent从A转为B() + { + // 模拟 GLMS-20260701-0006 第二次部分平仓 + var td = new trade + { + id = 2001, + TradeNumber = "CPC-TEST-007", + TradeType = "收益互换", + TradeStatus = "确认成交", + ValidState = "Valid", + StockEqvNotional = (double)RemainingAfter1st, // 32,500,000 + OriginalStockEqvNotional = (double)OriginalNotional, // 50,000,000 + Notional = (double)RemainingAfter1st, + TradeAmount = (double)RemainingAfter1st + }; + + var service = new TestableSwapDealService(td); + + // 前端传 ClosePercent = 0.325(口径A,占期初) + var unwindData = new UnwindData + { + SwapTradeId = td.id, + SwapRealizedPnL = 1000m, + SwapCloseAmount = 1000m, + CloseMethod = (int)CloseMethodEnum.部分平仓, + ClosePercent = SecondClosePercentA, // 0.325(口径A) + CloseQty = 16_250_000m, + CloseNotionalValue = SecondCloseNotional, // 16,250,000 + PositionQty = RemainingAfter1st, // 32,500,000 + NotionalValue = OriginalNotional, // 50,000,000(期初) + PosiNotionalValue = RemainingAfter1st, // 32,500,000(剩余) + ValueDate = new DateTime(2026, 7, 14), + UnwindDate = new DateTime(2026, 7, 15), + StartDate = new DateTime(2026, 7, 1) + }; + + service.SwapUnwind(unwindData); + + // 验证 SwapUnwind 被调用 + Assert.AreEqual(1, service.SaveSwapDealCalls.Count, "SwapUnwind 应调用 SaveSwapDeal"); + + // 验证传给 SaveSwapDeal 的 ClosePercent 已转为口径B + var savedData = service.SaveSwapDealCalls[0].data; + decimal expectedB = SwapDealService.ToRemainingClosePercent( + SecondClosePercentA, OriginalNotional, RemainingAfter1st); + + SwapDealTestFactory.AssertDecimalEqual(expectedB, savedData.ClosePercent, 1e-10m, + "SwapUnwind 应将 ClosePercent 从口径A(0.325)转为口径B(0.50)"); + + // 口径B 不等于口径A(验证转换确实发生了) + Assert.AreNotEqual(SecondClosePercentA, savedData.ClosePercent, + "口径B(0.50) 不应等于口径A(0.325),否则说明转换缺失"); + + Console.WriteLine($"SwapUnwind: 输入A={SecondClosePercentA} → 输出B={savedData.ClosePercent} ✅"); + } + + // ================================================================ + // 契约8:完整多步场景——3次平仓合计应等于期初本金 + // 首次35%(A) → 第二次平剩余50%(A=0.325) → 第三次全平剩余(A=0.325) + // 合计 CloseNotionalValue = 17.5M + 16.25M + 16.25M = 50M = 原始本金 + // ================================================================ + [TestMethod] + public void CPC_008_三次部分平仓合计本金必须等于期初名义本金() + { + decimal remaining = OriginalNotional; // 50,000,000 + decimal totalClosed = 0m; + + // Step1: 平 35%(首次,remaining == original, A = B = 0.35) + decimal step1A = 0.35m; + decimal step1Notional = step1A * OriginalNotional; // 17,500,000 + totalClosed += step1Notional; + remaining -= step1Notional; // 32,500,000 + + // Step2: 平剩余的 50% → A = 16,250,000 / 50,000,000 = 0.325 + decimal step2Notional = 16_250_000m; + decimal step2A = step2Notional / OriginalNotional; // 0.325 + // 后端 A→B 转换 + decimal step2B = SwapDealService.ToRemainingClosePercent( + step2A, OriginalNotional, remaining); + SwapDealTestFactory.AssertDecimalEqual(0.50m, step2B, 1e-10m, + "Step2: A=0.325 → B=0.50(平剩余50%)"); + totalClosed += step2Notional; + remaining -= step2Notional; // 16,250,000 + + // Step3: 全平剩余 → A = 16,250,000 / 50,000,000 = 0.325 + decimal step3Notional = remaining; + decimal step3A = step3Notional / OriginalNotional; // 0.325 + decimal step3B = SwapDealService.ToRemainingClosePercent( + step3A, OriginalNotional, remaining); + SwapDealTestFactory.AssertDecimalEqual(1.0m, step3B, 1e-10m, + "Step3: A=0.325 → B=1.0(全平剩余)"); + totalClosed += step3Notional; + remaining -= step3Notional; // 0 + + // 守恒:合计 = 期初 + SwapDealTestFactory.AssertDecimalEqual(OriginalNotional, totalClosed, 1e-6m, + "三次平仓合计必须 = 期初名义本金 50,000,000"); + SwapDealTestFactory.AssertDecimalEqual(0m, remaining, 1e-6m, + "三次平仓后剩余必须 = 0"); + + Console.WriteLine($"Step1: A=0.35, Notional=17,500,000"); + Console.WriteLine($"Step2: A=0.325→B=0.50, Notional=16,250,000"); + Console.WriteLine($"Step3: A=0.325→B=1.00, Notional=16,250,000"); + Console.WriteLine($"合计={totalClosed} = 期初{OriginalNotional} ✅"); + } + + // ================================================================ + // 契约9:GetUnwindInterestList 必须接收 notionalValue 和 posiNotionalValue + // 前端 getInterestList 传这两个参数给后端做 A→B 转换 + // 如果前端删掉这两个参数,后端 ToRemainingClosePercent 在 posiNotionalValue=0 时会跳过转换 + // ================================================================ + [TestMethod] + public void CPC_009_ToRemainingClosePercent_PosiNotionalValue为零时跳过转换_前端必须传值() + { + // 模拟前端不传 notionalValue/posiNotionalValue(默认0) + decimal result = SwapDealService.ToRemainingClosePercent( + SecondClosePercentA, notionalValue: 0, posiNotionalValue: 0); + + // posiNotionalValue <= 0 时直接返回原值(不转换) + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentA, result, 1e-10m, + "posiNotionalValue=0 时不做转换——所以前端必须传 notionalValue/posiNotionalValue"); + + // 正确:前端传值后转换正常 + decimal resultWithValues = SwapDealService.ToRemainingClosePercent( + SecondClosePercentA, OriginalNotional, RemainingAfter1st); + SwapDealTestFactory.AssertDecimalEqual(SecondClosePercentB, resultWithValues, 1e-10m, + "前端传值后:A=0.325 → B=0.50 ✅"); + + // 两者必须不同 + Assert.AreNotEqual(result, resultWithValues, + "传 vs 不传 notionalValue 结果不同——前端必须传,否则利息计算用错口径"); + + Console.WriteLine($"不传值(默认0):{result}(未转换,错误地用A算利息)"); + Console.WriteLine($"传值:{resultWithValues}(正确转换为B)"); + } + + // ================================================================ + // 契约10:首次平仓 A==B(退化场景,不应误报) + // 首次平仓时 NotionalValue == PosiNotionalValue,A=B,转换系数=1 + // 这是既有测试全绿的原因——必须用非退化场景才能捕获回归 + // ================================================================ + [TestMethod] + public void CPC_010_首次平仓A等于B_退化场景_不能作为唯一测试() + { + decimal closePercentA = 0.35m; + decimal firstRemaining = OriginalNotional; // 首次 remaining == original + + decimal convertedB = SwapDealService.ToRemainingClosePercent( + closePercentA, OriginalNotional, firstRemaining); + + // 首次平仓:A == B(转换系数 = 1) + SwapDealTestFactory.AssertDecimalEqual(closePercentA, convertedB, 1e-10m, + "首次平仓 remaining==original → A==B==0.35(退化场景)"); + + // 退化场景下即使不做转换结果也一样——这就是既有测试全绿的原因 + decimal noConversion = closePercentA; + Assert.AreEqual(noConversion, convertedB, + "退化场景:做不做转换结果一样 → 无法发现'转换缺失'的bug"); + + Console.WriteLine($"⚠ 退化场景:A={closePercentA} == B={convertedB}(首次平仓,无法暴露双重转换bug)"); + Console.WriteLine($"✅ 非退化场景见 CPC_005/007:A=0.325 ≠ B=0.50(多次部分平仓后才能暴露)"); + } + } +} diff --git a/YLErpWeb/fe-tests/closePercentContract.test.js b/YLErpWeb/fe-tests/closePercentContract.test.js new file mode 100644 index 00000000..2addeb24 --- /dev/null +++ b/YLErpWeb/fe-tests/closePercentContract.test.js @@ -0,0 +1,168 @@ +/** + * closePercentContract.test.js — 平仓比例口径A(占期初名义本金)前端接线守卫 + * ============================================================================ + * 产品需求(不可违背):ClosePercent 永远是"占期初名义本金(NotionalValue)的比例",即口径A。 + * + * 本文件是"接线测试"(wiring test):读 unwindSwapTrade.js 源码文本,断言关键逻辑 + * 仍然使用口径A的公式。如果有人把公式改成口径B(如 c9071a4e 曾犯的错误), + * 对应断言会立即变红。 + * + * 与 swapCalc.test.js 的区别: + * swapCalc.test.js 测 SwapCalc 纯函数本身正确性(零件级) + * 本文件测 unwindSwapTrade.js 确实在调用这些函数/使用正确公式(装配级) + * ============================================================================ + */ +const fs = require('fs'); +const path = require('path'); + +const unwindSrc = fs.readFileSync( + path.join(__dirname, '..', 'wwwroot', 'Scripts', 'app', 'swaptrade', 'unwindSwapTrade.js'), + 'utf8' +); + +describe('口径A产品契约:unwindSwapTrade.js 接线守卫', () => { + + // ==================================================================== + // 契约1:oriClosePercent 必须用 PosiNotionalValue / NotionalValue 计算 + // 不能硬编码为 1(c9071a4e 的错误) + // ==================================================================== + describe('oriClosePercent 必须为剩余/期初', () => { + test('源码中 oriClosePercent 必须包含 PosiNotionalValue / NotionalValue 公式', () => { + // 正确代码:this.oriClosePercent = ... PosiNotionalValue / ... NotionalValue + expect(unwindSrc).toMatch(/oriClosePercent.*PosiNotionalValue.*\/.*NotionalValue/s); + }); + + test('源码中 oriClosePercent 不能被硬编码为 1', () => { + // c9071a4e 的错误:this.deal.ClosePercent = 1 (直接覆盖) + // 检查 initDeal 中不存在 oriClosePercent = 1 的硬编码 + const initDealSection = unwindSrc.match(/initDeal\(\)[\s\S]*?\},/); + expect(initDealSection).toBeTruthy(); + // 不应出现 oriClosePercent = 1 或 ClosePercent = 1 的硬编码 + //(CloseMethod===1 时设置 ClosePercent=1 是允许的,但 oriClosePercent 不应被设为1) + expect(initDealSection[0]).not.toMatch(/oriClosePercent\s*=\s*1\b/); + }); + }); + + // ==================================================================== + // 契约2:calcCloseQtyByPercent 必须调用 SwapCalc.calcCloseQtyByOriginalPercent + // 不能直接 PositionQty × ClosePercent(c9071a4e 的错误) + // ==================================================================== + describe('calcCloseQtyByPercent 必须调用 SwapCalc', () => { + test('源码中 calcCloseQtyByPercent 必须调用 SwapCalc.calcCloseQtyByOriginalPercent', () => { + expect(unwindSrc).toContain('SwapCalc.calcCloseQtyByOriginalPercent'); + }); + + test('changeCloseMethod 的部分平仓分支必须调用 calcCloseQtyByPercent', () => { + // 正确代码:this.deal.CloseQty = this.calcCloseQtyByPercent(this.deal.ClosePercent); + expect(unwindSrc).toMatch(/calcCloseQtyByPercent\s*\(this\.deal\.ClosePercent\)/); + }); + + test('changeClosePercent 必须调用 calcCloseQtyByPercent', () => { + expect(unwindSrc).toMatch(/this\.deal\.CloseQty\s*=\s*this\.calcCloseQtyByPercent/); + }); + + test('changeCloseNotionalValue 必须调用 calcCloseQtyByPercent', () => { + expect(unwindSrc).toMatch(/this\.deal\.CloseQty\s*=\s*this\.calcCloseQtyByPercent/); + }); + }); + + // ==================================================================== + // 契约3:CloseNotionalValue 必须用 NotionalValue 做乘数/分母(口径A) + // 不能用 PosiNotionalValue(c9071a4e 的错误) + // ==================================================================== + describe('CloseNotionalValue 必须基于 NotionalValue(口径A)', () => { + test('changeCloseQty: CloseNotionalValue = ClosePercent × NotionalValue', () => { + // 正确:ClosePercent × parseFloat(this.deal.NotionalValue) + // 错误:ClosePercent × parseFloat(this.deal.PosiNotionalValue) + const changeCloseQtySection = unwindSrc.match(/changeCloseQty\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseQtySection).toBeTruthy(); + expect(changeCloseQtySection[0]).toMatch(/CloseNotionalValue.*NotionalValue/); + expect(changeCloseQtySection[0]).not.toMatch(/CloseNotionalValue.*PosiNotionalValue/); + }); + + test('changeClosePercent: CloseNotionalValue = ClosePercent × NotionalValue', () => { + const changeClosePercentSection = unwindSrc.match(/changeClosePercent\(\)[\s\S]*?\n\s*\},/); + expect(changeClosePercentSection).toBeTruthy(); + expect(changeClosePercentSection[0]).toMatch(/CloseNotionalValue.*NotionalValue/); + expect(changeClosePercentSection[0]).not.toMatch(/CloseNotionalValue.*PosiNotionalValue/); + }); + }); + + // ==================================================================== + // 契约4:changeCloseQty 的 ClosePercent 必须乘以 oriClosePercent(口径A→B→A 转换) + // 不能直接 CloseQty / PositionQty(c9071a4e 的错误) + // ==================================================================== + describe('changeCloseQty 的 ClosePercent 必须乘以 oriClosePercent', () => { + test('ClosePercent = (CloseQty/PositionQty) × oriClosePercent', () => { + // 正确:× ori(把占剩余比例转回占期初口径) + // 错误:不乘 ori(直接用占剩余比例作为 ClosePercent) + const changeCloseQtySection = unwindSrc.match(/changeCloseQty\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseQtySection).toBeTruthy(); + expect(changeCloseQtySection[0]).toMatch(/oriClosePercent/); + expect(changeCloseQtySection[0]).toMatch(/\*\s*ori/); + }); + + test('changeCloseNotionalValue 的 ClosePercent 必须除以 NotionalValue', () => { + // 正确:ClosePercent = CloseNotionalValue / NotionalValue + // 错误:ClosePercent = CloseNotionalValue / PosiNotionalValue + const changeCloseNotionalSection = unwindSrc.match(/changeCloseNotionalValue\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseNotionalSection).toBeTruthy(); + expect(changeCloseNotionalSection[0]).toMatch(/ClosePercent.*NotionalValue/); + expect(changeCloseNotionalSection[0]).not.toMatch(/ClosePercent.*PosiNotionalValue/); + }); + }); + + // ==================================================================== + // 契约5:getInterestList 必须传 notionalValue 和 posiNotionalValue 给后端 + // 后端 GetUnwindInterestList 需要这两个值做 A→B 转换 + // 如果删掉(c9071a4e 的错误),后端不转换,利息用错口径计算 + // ==================================================================== + describe('getInterestList 必须传 notionalValue/posiNotionalValue', () => { + test('postData 必须包含 notionalValue', () => { + expect(unwindSrc).toMatch(/notionalValue:\s*thisObj\.deal\.NotionalValue/); + }); + + test('postData 必须包含 posiNotionalValue', () => { + expect(unwindSrc).toMatch(/posiNotionalValue:\s*thisObj\.deal\.PosiNotionalValue/); + }); + + test('getInterestList 的 postData 不能只有 closePercent 而缺少 notionalValue', () => { + // 精确匹配 getInterestList 方法定义(以 getInterestList() { 开头,到 main.post 结束) + // 匹配模式:方法名+参数列表+花括号开始,一直到包含 main.post 的 postData 定义 + const methodMatch = unwindSrc.match( + /getInterestList\(\)\s*\{[\s\S]*?var\s+postData\s*=\s*\{[^}]*\}/ + ); + expect(methodMatch).toBeTruthy(); + const postData = methodMatch[0]; + expect(postData).toContain('notionalValue'); + expect(postData).toContain('posiNotionalValue'); + }); + }); + + // ==================================================================== + // 契约6:CloseMethod 赋值对象必须是 deal(不是 floatPosition) + // c9071a4e 在 changeClosePercent 中误赋值到 floatPosition.CloseMethod + // ==================================================================== + describe('CloseMethod 必须赋值给 deal', () => { + test('changeClosePercent 的 CloseMethod 必须赋值给 this.deal', () => { + const changeClosePercentSection = unwindSrc.match(/changeClosePercent\(\)[\s\S]*?\n\s*\},/); + expect(changeClosePercentSection).toBeTruthy(); + expect(changeClosePercentSection[0]).toMatch(/this\.deal\.CloseMethod\s*=/); + expect(changeClosePercentSection[0]).not.toMatch(/this\.floatPosition\.CloseMethod\s*=/); + }); + + test('changeCloseNotionalValue 的 CloseMethod 必须赋值给 this.deal', () => { + const changeCloseNotionalSection = unwindSrc.match(/changeCloseNotionalValue\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseNotionalSection).toBeTruthy(); + expect(changeCloseNotionalSection[0]).toMatch(/this\.deal\.CloseMethod\s*=/); + expect(changeCloseNotionalSection[0]).not.toMatch(/this\.floatPosition\.CloseMethod\s*=/); + }); + + test('changeCloseNotionalValue 必须设置 CloseMethod(不能删除)', () => { + // c9071a4e 完全删除了 changeCloseNotionalValue 中的 CloseMethod 判断 + const changeCloseNotionalSection = unwindSrc.match(/changeCloseNotionalValue\(\)[\s\S]*?\n\s*\},/); + expect(changeCloseNotionalSection).toBeTruthy(); + expect(changeCloseNotionalSection[0]).toMatch(/CloseMethod/); + }); + }); +});