test(fe): 添加部分平仓比例刷新守卫 + 历史对账骨架(离线无DB) — closePercentInterestRefresh 守卫全部→部分平仓比例刷新链; historicalReconciliation 独立规格vs落库值差分; 全部18套件276测试通过
This commit is contained in:
@@ -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'/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user