328 lines
14 KiB
JavaScript
328 lines
14 KiB
JavaScript
/**
|
||
* 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));
|
||
}
|
||
},
|
||
// EQD-6953:平仓页现于 unwindSwapTrade.js 之前加载 unwindBondCalc.js(见 SwapUnwind.cshtml),
|
||
// dataFormat 提交取整会引用其守卫;此处给同款行为的最小 mock(真实实现由 unwindBondCalc.test.js 覆盖)
|
||
UnwindBondCalc: {
|
||
hasValue(v) { return v !== null && v !== undefined && v !== '' && !isNaN(v); },
|
||
roundExitYtm(v) { return v; },
|
||
applyManualEdit(s) { return s; }
|
||
},
|
||
// 收付方向符号:纯函数零依赖,直接喂真实模块(映射冻结由 unwindLegSign.test.js 覆盖)
|
||
UnwindLegSign: require('../wwwroot/Scripts/app/swaptrade/unwindLegSign.js'),
|
||
_: {
|
||
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'/);
|
||
});
|
||
});
|