test(swap): 新增 ClosePercent 口径A产品契约测试,防止平仓比例被改为占剩余口径

新增前后端回归测试,锁定产品需求:ClosePercent 永远是占期初名义本金的比例。后端 CPC_001~010 装配测试验证 SwapDealService 真实执行 ToRemainingClosePercent/ToOriginalClosePercent 转换;前端 16 项接线测试验证 unwindSwapTrade.js 使用 NotionalValue 作为分母且 postData 携带 notionalValue 字段。测试背景:c9071a4e 曾将 ClosePercent 改为占剩余口径导致回归但现有测试全通过,本测试通过源码装配与契约验证弥补该缺口。
This commit is contained in:
hjhan
2026-07-27 13:18:32 +08:00
parent b6afab6a91
commit e188ba5038
2 changed files with 525 additions and 0 deletions
@@ -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 接线守卫', () => {
// ====================================================================
// 契约1oriClosePercent 必须用 PosiNotionalValue / NotionalValue 计算
// 不能硬编码为 1c9071a4e 的错误)
// ====================================================================
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/);
});
});
// ====================================================================
// 契约2calcCloseQtyByPercent 必须调用 SwapCalc.calcCloseQtyByOriginalPercent
// 不能直接 PositionQty × ClosePercentc9071a4e 的错误)
// ====================================================================
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/);
});
});
// ====================================================================
// 契约3CloseNotionalValue 必须用 NotionalValue 做乘数/分母(口径A
// 不能用 PosiNotionalValuec9071a4e 的错误)
// ====================================================================
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/);
});
});
// ====================================================================
// 契约4changeCloseQty 的 ClosePercent 必须乘以 oriClosePercent(口径A→B→A 转换)
// 不能直接 CloseQty / PositionQtyc9071a4e 的错误)
// ====================================================================
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/);
});
});
// ====================================================================
// 契约5getInterestList 必须传 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');
});
});
// ====================================================================
// 契约6CloseMethod 赋值对象必须是 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/);
});
});
});