test(fe): 添加部分平仓比例刷新守卫 + 历史对账骨架(离线无DB) — closePercentInterestRefresh 守卫全部→部分平仓比例刷新链; historicalReconciliation 独立规格vs落库值差分; 全部18套件276测试通过

This commit is contained in:
hjhan
2026-08-08 14:16:54 +08:00
parent 096d2609eb
commit fc01b766cd
5 changed files with 687 additions and 0 deletions
@@ -0,0 +1,318 @@
/**
* closePercentInterestRefresh.test.js — 部分平仓比例变更触发利息腿刷新守卫
* ============================================================================
* 目的:验证用户从「全部平仓」切换到「部分平仓」并修改比例时,
* getInterestList() 被调用且传递的 closePercent 值是正确的(口径A)。
*
* 测试策略:
* 1. 源码守卫(wiring guard):读 unwindSwapTrade.js 源码文本,断言关键方法
* 在比例变更后确实调用 getInterestList()。
* 2. 行为模拟(behavior mock):在 vm sandbox 中加载 unwindSwapTrade.js
* mock main.post 捕获 postData,验证不同 closePercent 值被正确传递。
* ============================================================================
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const unwindSrcPath = path.join(__dirname, '..', 'wwwroot', 'Scripts', 'app', 'swaptrade', 'unwindSwapTrade.js');
const unwindSrc = fs.readFileSync(unwindSrcPath, 'utf8');
// ====================================================================
// 第1部分:源码守卫 — getInterestList 调用链完整性
// ====================================================================
describe('部分平仓比例变更 → getInterestList 调用链守卫', () => {
describe('changeClosePercent 必须调用 getInterestList', () => {
test('changeClosePercent 方法体中调用 this.getInterestList()', () => {
const section = unwindSrc.match(/changeClosePercent\(\)[\s\S]*?\n\s*\},/);
expect(section).toBeTruthy();
expect(section[0]).toMatch(/this\.getInterestList\(\)/);
});
});
describe('changeCloseMethod 必须调用 getInterestList', () => {
test('changeCloseMethod 方法体中调用 this.getInterestList()', () => {
const section = unwindSrc.match(/changeCloseMethod\(\)[\s\S]*?\n\s*\},/);
expect(section).toBeTruthy();
expect(section[0]).toMatch(/this\.getInterestList\(\)/);
});
});
describe('changeCloseNotionalValue 必须调用 getInterestList', () => {
test('changeCloseNotionalValue 方法体中调用 this.getInterestList()', () => {
const section = unwindSrc.match(/changeCloseNotionalValue\(\)[\s\S]*?\n\s*\},/);
expect(section).toBeTruthy();
expect(section[0]).toMatch(/this\.getInterestList\(\)/);
});
});
describe('changeCloseQty 必须调用 getInterestList', () => {
test('changeCloseQty 方法体中调用 this.getInterestList()', () => {
const section = unwindSrc.match(/changeCloseQty\(\)[\s\S]*?\n\s*\},/);
expect(section).toBeTruthy();
expect(section[0]).toMatch(/this\.getInterestList\(\)/);
});
});
});
// ====================================================================
// 第2部分:行为模拟 — getInterestList 传递的 closePercent 正确性
// ============================================================================
function loadVueApp(model, mockPost) {
const code = fs.readFileSync(unwindSrcPath, 'utf8');
const stockEqvNotional = (v) => Number(Number(v || 0).toFixed(2));
const sandbox = {
module: { exports: {} },
exports: {},
console,
require,
window: { otcformat: { options: {} } },
otcformat: {
options: {},
trading: {
premiumRateP: { precision: 4 },
tradePrice: { precision: 4 },
notional: { precision: 6 },
StockEqvNotional: stockEqvNotional,
marginRateP: { precision: 4 },
umpriceP: { precision: 4 }
},
fixed6: (v) => Number(Number(v || 0).toFixed(6))
},
model: model,
isUseApproval: false,
Vue: function (options) {
// 返回一个模拟的 Vue 实例,暴露 methods 供测试调用
const instance = Object.assign({}, options);
instance.deal = JSON.parse(JSON.stringify(model));
instance.interestList = [];
instance.marginList = [];
instance.floatPosition = null;
instance.initPosiNetPrice = 0;
instance.oriClosePercent = 1;
instance.ratio = 1;
instance.shortRatio = 1;
instance.multiplier = model.StructureType === '普通债券类收益互换' ? 100 : 1;
// 绑定 methods 的 this
const boundMethods = {};
for (const key in options.methods) {
boundMethods[key] = (...args) => options.methods[key].apply(instance, args);
}
instance.methods = boundMethods;
// 供 methods 内部调用 this.xxx
for (const key in options.methods) {
instance[key] = boundMethods[key];
}
// 执行 created/mounted(会调 initDeal → getInterestList
if (options.created) options.created.call(instance);
// 暴露到 sandbox 供测试访问
sandbox.__vueInstance = instance;
return instance;
},
FastVue: {
vueDatePicker() { return {}; },
vueNumberInput() { return {}; }
},
swapPricePrecision: {
createVueInputComponent() { return {}; },
getCommonInputFormat() { return {}; },
normalizeCommon(type, value) { return value; },
formatCommon(type, value) { return value; },
getInputFormat(type, field, fallback) { return fallback; },
roundForSubmit(value) { return value; },
shiftDecimal(value) { return value; },
format(value) { return value; }
},
tradeHelper: { IsBond() { return false; } },
main: {
post(url, postData, opts) {
mockPost(url, postData);
return {
done(cb) {
// 模拟后端返回空利息列表
if (cb) cb({ obj: [] });
return this;
}
};
},
message() { }
},
SwapCalc: {
roundHalfAwayFromZero(value) { return value; },
calcCloseQtyByOriginalPercent(closePercent, oriClosePercent, positionQty) {
var ori = Number(oriClosePercent);
if (ori === 0) return 0;
if (Number(closePercent) >= ori) return Number(positionQty);
return Number(Number(positionQty) * (Number(closePercent) / ori).toFixed(6));
}
},
_: {
round(value, precision) {
return Number(Number(value || 0).toFixed(precision || 0));
}
}
};
sandbox.window.otcformat = sandbox.otcformat;
vm.runInNewContext(code, sandbox, { filename: unwindSrcPath });
return sandbox;
}
describe('getInterestList 传递的 closePercent 正确性', () => {
// 模拟一笔未平仓的交易:NotionalValue=100M, PosiNotionalValue=100M
const baseModel = {
ValueDate: '2026-07-27',
UnwindDate: '2026-07-27',
SwapTradeId: 1,
NotionalValue: 100000000,
PosiNotionalValue: 100000000,
NotionalQty: 1000000,
PositionQty: 1000000,
CloseType: 2,
CloseMethod: 1,
ClosePercent: 1,
CloseNotionalValue: 100000000,
CloseQty: 1000000,
StartDate: '2026-04-21',
StructureType: '',
TradeStartDate: '',
FlowEvents: [{
UnderlyingCode: '000001',
PosiGrossPrice: 10.0,
PosiNetPrice: 9.5,
PayDirection: 1,
PositionType: 1,
TradingAmountAvg: 10.0,
PosiQuantity: 1000000,
PosiNotionalValue: 100000000,
UnderlyingInstrumentType: 'stock',
PosiFeeType: 0,
PosiTradingFeeUnit: 0
}]
};
test('全部平仓 → 部分平仓(50%):getInterestList 传递 closePercent=0.5', () => {
const postCalls = [];
const model = JSON.parse(JSON.stringify(baseModel));
const sandbox = loadVueApp(model, (url, postData) => {
if (url === '/swaptrade2/GetUnwindInterestList') {
postCalls.push(JSON.parse(JSON.stringify(postData)));
}
});
const vue = sandbox.__vueInstance;
// 1. 初始化时 created() → setValueDate() → getInterestList() (closePercent=1)
expect(postCalls.length).toBeGreaterThanOrEqual(1);
expect(postCalls[0].closePercent).toBe(1);
// 2. 切换为部分平仓
vue.deal.CloseMethod = 2;
vue.changeCloseMethod();
// 切换后 closePercent 仍为 1= oriClosePercent),getInterestList 传 closePercent=1
expect(postCalls.length).toBeGreaterThanOrEqual(2);
expect(postCalls[postCalls.length - 1].closePercent).toBe(1);
// 3. 用户输入 50% → changeClosePercent
vue.deal.ClosePercent = 0.5;
vue.changeClosePercent();
// getInterestList 应传 closePercent=0.5
expect(postCalls.length).toBeGreaterThanOrEqual(3);
expect(postCalls[postCalls.length - 1].closePercent).toBe(0.5);
});
test('多次部分平仓后(已平30%):全部→部分(50%)closePercent=0.5 传递正确', () => {
const postCalls = [];
// 已平30%NotionalValue=100M, PosiNotionalValue=70M, oriClosePercent=0.7
const model = JSON.parse(JSON.stringify(baseModel));
model.PosiNotionalValue = 70000000;
model.PositionQty = 700000;
model.ClosePercent = 0.7;
model.CloseNotionalValue = 70000000;
model.CloseQty = 700000;
const sandbox = loadVueApp(model, (url, postData) => {
if (url === '/swaptrade2/GetUnwindInterestList') {
postCalls.push(JSON.parse(JSON.stringify(postData)));
}
});
const vue = sandbox.__vueInstance;
// oriClosePercent = 70M / 100M = 0.7
expect(vue.oriClosePercent).toBeCloseTo(0.7, 6);
// 切换为部分平仓
vue.deal.CloseMethod = 2;
vue.changeCloseMethod();
// closePercent 仍为 0.7= oriClosePercent),后端转为 B = 0.7*100M/70M = 1.0
const lastCall = postCalls[postCalls.length - 1];
expect(lastCall.closePercent).toBeCloseTo(0.7, 6);
expect(lastCall.notionalValue).toBe(100000000);
expect(lastCall.posiNotionalValue).toBe(70000000);
// 用户输入 50%(占期初口径A=0.5)
vue.deal.ClosePercent = 0.5;
vue.changeClosePercent();
// 后端收到 closePercent=0.5(A), notionalValue=100M, posiNotionalValue=70M
// 后端 ToRemainingClosePercent: B = 0.5 * 100M / 70M = 0.714...
const finalCall = postCalls[postCalls.length - 1];
expect(finalCall.closePercent).toBeCloseTo(0.5, 6);
expect(finalCall.notionalValue).toBe(100000000);
expect(finalCall.posiNotionalValue).toBe(70000000);
});
test('getInterestList postData 必须同时传 notionalValue 和 posiNotionalValue', () => {
const postCalls = [];
const model = JSON.parse(JSON.stringify(baseModel));
const sandbox = loadVueApp(model, (url, postData) => {
if (url === '/swaptrade2/GetUnwindInterestList') {
postCalls.push(postData);
}
});
expect(postCalls.length).toBeGreaterThan(0);
const postData = postCalls[0];
expect(postData).toHaveProperty('notionalValue');
expect(postData).toHaveProperty('posiNotionalValue');
expect(postData).toHaveProperty('closePercent');
expect(postData).toHaveProperty('tradeId');
expect(postData).toHaveProperty('valueDate');
});
});
// ====================================================================
// 第3部分:vue-number-input change 事件触发验证
// ============================================================================
describe('vue-number-input change 事件触发 onchange 机制', () => {
const baseJsPath = path.join(__dirname, '..', 'wwwroot', 'Scripts', 'fast', 'fastVue.base.js');
const baseSrc = fs.readFileSync(baseJsPath, 'utf8');
test('__change 函数调用 onchange 回调', () => {
// 验证 __change 函数确实调用 _options.onchange
expect(baseSrc).toMatch(/function\s+__change/);
expect(baseSrc).toMatch(/_options\.onchange\s*&&\s*_options\.onchange\(/);
});
test('__onInput 不直接调用 onchange(只在格式化/追加后缀)', () => {
// __onInput 只做格式化,不触发 onchange
const onInputMatch = baseSrc.match(/function\s+__onInput\(\)\s*\{[\s\S]*?\n\s*\}/);
expect(onInputMatch).toBeTruthy();
// __onInput 方法体不应调用 onchange
expect(onInputMatch[0]).not.toMatch(/onchange/);
});
test('change 事件绑定在元素上(jQuery .on("change", __change)', () => {
expect(baseSrc).toMatch(/\$\(.*\)\.on\(.*change.*__change/);
});
test('vueNumberInput 组件的 onchange 方法 emit input 事件', () => {
// 组件 onchange → $emit('input', result)
expect(baseSrc).toMatch(/onchange\s*\(value,\s*text,\s*isEnter\)/);
expect(baseSrc).toMatch(/\$emit\('input'/);
});
});