/** * 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'/); }); });