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,72 @@
# 历史对账 + 基准差分测试骨架
## 一句话结论
**这套骨架在测试运行时不需要任何数据库连接。** 唯一触碰数据库的地方是
`tools/exportHistoricalSwapEvents.js`——一个**开发期一次性导出脚本**(把真实
`swap_event.data` 灌进 fixture),它**文件名非 `*.test.js`jest 不会执行它**
因此 CI 跑 `npm test` 时零 DB 依赖。
## 文件清单
| 文件 | 运行时是否要 DB | 说明 |
|------|----------------|------|
| `historicalReconciliation.test.js` | **否** | 离线对账:独立规格 vs fixture 中记录的落库值 |
| `fixtures/historical_swap_events.json` | **否** | 历史数据夹具(当前为手搓代表性样本,可由导出脚本替换为真实数据) |
| `swapCalc.test.js`(既有) | **否** | `swapCalc.js` 参考实现 vs C# 金标准 `FC_001~009` 差分 |
| `tools/exportHistoricalSwapEvents.js` | **是(仅此一处,开发期)** | 一次性导出真实历史,不属于测试路径 |
## 设计原则(回应"重构怕引入新 Bug"的顾虑)
1. **先冻后改**:用特征测试把"当前(可能含历史 bug 的)行为"钉死。任何重构只要改变了
金额产出,测试立即变红,**逼你查根因,绝不自动 re-baseline**(避免 `01d7f0c5` 式静默掩盖)。
2. **已知差异显式冻结**`swapLongShort.js``SwapMarginAmount` 用裸 `InterestPrincipal`
(无符号),而 `unwindSwapTrade.js``InterestDirection` 求符号——这是真实的代码不一致。
它在 fixture 里以 `knownDiscrepancies.SwapMarginAmount` 记录"差异量",测试只在该**差异量变化**
时报红,既不掩盖也不误报。
3. **独立规格不信任任何既有实现**`referenceOracle` 是按 `unwindSwapTrade.js:329` 独立重写的
纯函数,作为"应然"基准。若生产代码与独立规格分叉,说明要么生产错了、要么规格写错了——必须人工裁决。
## 如何扩展到"4 个生产 calcCloseAmount 的差分"
当前骨架用的是**独立重写的规格**,并未直接驱动那 4 个组件里的 `calcCloseAmount`
原因是这 4 个文件在模块顶层就访问 `window.otcformat` / `model` / `swapPricePrecision`
等全局(见 `unwindSwapTrade.js:1-2`),在 jest 的 `node` 环境下直接 `require` 会在加载期抛错。
要把它们纳入差分,**唯一的代码改动**是"提取纯函数"(行为不变,非统一):
```js
// 在组件文件里,把 calcCloseAmount 的金额聚合核心抽成导出纯函数
function calcUnwindCloseAmountCore(deal, floatPosition, interestList, marginList) {
// ...原 :329-367 的金额聚合逻辑搬过来,去掉 this / formatSwapAmount 展示归一...
}
// 原方法改为 2 行包装,保持行为完全一致:
calcCloseAmount() {
const r = calcUnwindCloseAmountCore(this.deal, this.floatPosition, this.interestList, this.marginList);
Object.assign(this.deal, r);
}
module.exports = { calcUnwindCloseAmountCore }; // 仅新增导出,不改逻辑
```
提取后 jest 即可 `require` 并差分,且**不连 DB、不碰业务逻辑**。这一步务必配合一条
"提取前后产出一致"的快照测试,确保提取本身没改行为。
> 注意:`swapCalc.js` 目前**未接入生产**(其 `calcUnwind/calcIncome` 注释明写"未接入"),
> 且它**不计算 `SwapMarginAmount`**(利息腿 `InterestPrincipal` 那一项),因此不能直接拿它
> 替换 4 个生产函数。它目前只作为"参考金标准"被 `swapCalc.test.js` 的 `FC_*` 用例校验。
## 运行
```bash
cd YLErpWeb/fe-tests
npm i # 安装 jest / mysql2(可选,仅导出脚本用)
npx jest historicalReconciliation.test.js
```
要注入真实历史(可选,需 DB):
```bash
MYSQL_HOST=... MYSQL_USER=... MYSQL_PASS=... MYSQL_DB=... \
node tools/exportHistoricalSwapEvents.js --from 2026-01-01 --limit 200
npx jest historicalReconciliation.test.js # 此时跑的是真实历史对账
```
@@ -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'/);
});
});
@@ -0,0 +1,85 @@
{
"meta": {
"schemaVersion": 1,
"source": "hand-crafted representative samples (replace/extend via tools/exportHistoricalSwapEvents.js against real DB)",
"note": "每条 record 的 inputs 是前端 calcCloseAmount 实际会收到的形态(deal/floatPosition/interestList/marginList)expected 是落库/记录值。运行时无需 DB。",
"knownDiscrepancies": "swapLongShort.js 的 SwapMarginAmount 用裸 InterestPrincipal(无符号),而 unwindSwapTrade.js 用 InterestDirection 求符号。该差异被冻结在 knownDiscrepancies 中,仅当差异量变化时才报红。"
},
"records": [
{
"id": "UNWIND_STD",
"desc": "标准平仓(unwind):含浮动盈亏 + 利息腿 + 预付金腿",
"eventType": 2,
"deal": { "CloseQty": 1000 },
"floatPosition": { "FloatPnlSum": 50000, "PositionType": 1, "PayDirection": 1, "TradingFee": 20 },
"interestList": [
{ "InterestClosePnL": 1200, "InterestDirection": 1, "InterestPrincipal": 0 }
],
"marginList": [
{ "InterestClosePnL": 300, "InterestDirection": 1, "InterestPrincipal": 50000 }
],
"expected": {
"SwapCloseAmount": 51500,
"SwapRealizedPnL": 51500,
"SwapMarginRebatePnl": 300,
"SwapMarginAmount": -50000
}
},
{
"id": "INCOME_STD",
"desc": "收益结算(income):用 CloseNotionalValue 计价,利息腿+预付金腿",
"eventType": 3,
"deal": { "CloseQty": 0, "CloseNotionalValue": 10200 },
"floatPosition": { "FloatPnlSum": 30000, "PositionType": 1, "PayDirection": 2, "TradingFee": 0 },
"interestList": [
{ "InterestClosePnL": 800, "InterestDirection": 1, "InterestPrincipal": 0 }
],
"marginList": [
{ "InterestClosePnL": 200, "InterestDirection": 2, "InterestPrincipal": 40000 }
],
"expected": {
"SwapCloseAmount": 31000,
"SwapRealizedPnL": 31000,
"SwapMarginRebatePnl": 200,
"SwapMarginAmount": 40000
}
},
{
"id": "LONGSHORT_UNWIND",
"desc": "多空组合平仓(unwindLongShort):从0起,不含浮动盈亏(FloatPnlSum=0)",
"eventType": 4,
"deal": { "CloseQty": 0 },
"floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 },
"interestList": [],
"marginList": [
{ "InterestClosePnL": 150, "InterestDirection": 1, "InterestPrincipal": 20000 }
],
"expected": {
"SwapCloseAmount": 150,
"SwapRealizedPnL": 150,
"SwapMarginRebatePnl": 150,
"SwapMarginAmount": -20000
}
},
{
"id": "LONGSHORT_SWAP",
"desc": "多空组合互换(swapLongShort)SwapMarginAmount 用裸 InterestPrincipal(无符号) —— 已知差异",
"eventType": 5,
"deal": { "CloseQty": 0 },
"floatPosition": { "FloatPnlSum": 0, "PositionType": 1, "PayDirection": 1, "TradingFee": 0 },
"interestList": [],
"marginList": [
{ "InterestClosePnL": 100, "InterestDirection": 1, "InterestPrincipal": 18000 }
],
"expected": {
"SwapCloseAmount": 100,
"SwapRealizedPnL": 100,
"SwapMarginRebatePnl": 100,
"SwapMarginAmount": 18000
},
"knownDiscrepancies": {
"SwapMarginAmount": 36000
}
}
]
}
@@ -0,0 +1,105 @@
/**
* historicalReconciliation.test.js — 历史对账 + 独立规格差分骨架(离线,无需数据库)
* ============================================================================
* 运行:cd YLErpWeb/fe-tests && npx jest historicalReconciliation.test.js
*
* 数据库边界(关键):
* - 本测试运行时【零数据库依赖】。历史数据来自 fixtures/historical_swap_events.json
* 该 fixture 可离线提交进仓库。
* - 唯一的数据库触碰是 tools/exportHistoricalSwapEvents.js —— 开发期一次性导出脚本,
* 把真实 swap_event.data 灌进上面的 fixture。它不属于 npm test 路径,CI 不执行。
*
* 设计要点:
* - referenceOracle:基于 unwindSwapTrade.js:329 的 calcCloseAmount 核心【独立重写】的纯函数
* "规格"),不信任任何既有实现。用于校验"当前公式产出" == "落库/记录值"。
* - 对已知差异字段(如 swapLongShort.js 的 SwapMarginAmount 无符号)通过
* record.knownDiscrepancies 冻结现状:仅当"差异量"变化时才报红,既不掩盖也不误报。
* - 这是特征测试(characterization):先把现状冻住,未来任何重构只要改变了产出,
* 测试立即变红,逼你查根因(绝不自动 re-baseline)。
*/
const fs = require('fs');
const path = require('path');
const round2 = v => Math.round((Number(v) || 0) * 100) / 100;
// 独立规格:等价于 unwindSwapTrade.js:329-367 的 calcCloseAmount 金额聚合核心。
// 注意:这里只看 SwapCloseAmount / SwapRealizedPnL / SwapMarginRebatePnl / SwapMarginAmount
// 四个金额字段;TradingAmount / FeeAvg 等展示字段不在对账范围内(且 swapCalc 也未实现)。
function referenceOracle({ floatPosition, interestList, marginList }) {
const fp = floatPosition || {};
const interestLegs = interestList || [];
const marginLegs = marginList || [];
let closeAmount = round2(fp.FloatPnlSum || 0);
let realizedPnL = closeAmount;
let marginRebatePnl = 0;
let marginAmount = 0;
interestLegs.forEach(x => {
const amt = round2(x.InterestClosePnL || 0);
closeAmount = round2(closeAmount + amt);
realizedPnL = round2(realizedPnL + amt);
});
marginLegs.forEach(x => {
const amt = round2(x.InterestClosePnL || 0);
// 与 unwindSwapTrade.js:355 同口径:InterestDirection==1 ? -1 : 1
const interestRatio = x.InterestDirection == 1 ? -1 : 1;
closeAmount = round2(closeAmount + amt);
marginRebatePnl = round2(marginRebatePnl + amt);
realizedPnL = round2(realizedPnL + amt);
marginAmount = round2(marginAmount + (Number(x.InterestPrincipal) || 0) * interestRatio);
});
return {
SwapCloseAmount: closeAmount,
SwapRealizedPnL: realizedPnL,
SwapMarginRebatePnl: marginRebatePnl,
SwapMarginAmount: marginAmount,
};
}
const TOL = 1e-6;
function expectClose(actual, expected, msg) {
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(TOL, msg || '');
}
const fixturePath = path.join(__dirname, 'fixtures', 'historical_swap_events.json');
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
const RECON_FIELDS = ['SwapCloseAmount', 'SwapRealizedPnL', 'SwapMarginRebatePnl', 'SwapMarginAmount'];
describe('历史对账:当前公式产出 == 落库/记录值(离线,无 DB)', () => {
fixture.records.forEach(rec => {
test(`[${rec.id}] ${rec.desc}`, () => {
const got = referenceOracle(rec);
const exp = rec.expected || {};
const tolerated = rec.knownDiscrepancies || {};
RECON_FIELDS.forEach(field => {
if (!(field in exp)) return; // 记录未提供该字段则跳过
const actual = got[field];
const recorded = exp[field];
if (tolerated[field] != null) {
// 冻结已知差异:仅当"差异量"本身变化时才报红(行为漂移告警)
const deltaNow = Math.abs(actual - recorded);
expectClose(deltaNow, tolerated[field],
`${field} 已知差异量应稳定为 ${tolerated[field]}(当前 ${deltaNow}`);
} else {
expectClose(actual, recorded, `${field} 应等于落库值`);
}
});
});
});
});
// 该 describe 仅做"骨架自洽性"校验:确保 fixture 里的手搓样本与独立规格自洽,
// 否则说明 fixture 写错(不是生产 bug)。真实历史差异由上面对账 + 导出脚本覆盖。
describe('骨架自洽:fixture 样本与独立规格一致(非生产断言)', () => {
test('UNWIND_STD 全部字段自洽', () => {
const rec = fixture.records.find(r => r.id === 'UNWIND_STD');
const got = referenceOracle(rec);
expectClose(got.SwapCloseAmount, 51500);
expectClose(got.SwapMarginAmount, -50000);
});
});
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* exportHistoricalSwapEvents.js — 一次性历史数据导出(开发期工具,⚠ 需要数据库连接)
* ============================================================================
* 用途:把生产/测试库里真实的 swap_event 记录导出成
* fe-tests/fixtures/historical_swap_events.json
* 供 historicalReconciliation.test.js 离线对账。
*
* 数据库边界:本脚本【是唯一】触碰数据库的地方,但它【不属于 npm test 路径】
* (文件名非 *.test.jsjest testMatch 不会执行它)。CI 跑测试时零 DB 依赖。
*
* 运行(在 YLErpWeb/fe-tests 下):
* npm i mysql2 # 仅开发期依赖,不进生产
* MYSQL_HOST=... MYSQL_USER=... MYSQL_PASS=... MYSQL_DB=... \
* node tools/exportHistoricalSwapEvents.js [--from 2026-01-01] [--to 2026-08-08] [--limit 200]
*
* 导出后请把结果提交进仓库(fixture 文件本身不含任何凭据)。
*/
'use strict';
const fs = require('fs');
const path = require('path');
async function main() {
let mysql;
try {
mysql = require('mysql2/promise');
} catch (e) {
console.error('缺少 mysql2,请先 `npm i mysql2`(仅开发期依赖)');
process.exit(2);
}
const cfg = {
host: process.env.MYSQL_HOST,
user: process.env.MYSQL_USER,
password: process.env.MYSQL_PASS,
database: process.env.MYSQL_DB,
};
if (!cfg.host || !cfg.user || !cfg.database) {
console.error('请提供环境变量 MYSQL_HOST / MYSQL_USER / MYSQL_PASS / MYSQL_DB');
process.exit(2);
}
const args = process.argv.slice(2);
const getArg = (name, dflt) => {
const i = args.indexOf(name);
return i >= 0 && args[i + 1] ? args[i + 1] : dflt;
};
const from = getArg('--from', '1970-01-01');
const to = getArg('--to', '9999-12-31');
const limit = parseInt(getArg('--limit', '200'), 10);
const conn = await mysql.createConnection(cfg);
// event_type: 2=平仓(unwind) 3=结息(income) 4=多空平仓 5=多空互换(按实际枚举调整)
const [rows] = await conn.execute(
`SELECT id, event_type, data, create_time
FROM swap_event
WHERE event_type IN (2,3,4,5)
AND create_time BETWEEN ? AND ?
ORDER BY create_time DESC
LIMIT ?`,
[from, to, limit]
);
await conn.end();
const records = [];
for (const row of rows) {
let data = {};
try { data = JSON.parse(row.data || '{}'); } catch (e) { continue; }
// swap_event.data 是后端序列化的 UnwindData,字段需按实际结构映射。
// 下面仅抽取对账所需的最小字段;不同 event_type 的字段名以实际库结构为准。
const deal = data.deal || data.Deal || {};
const floatPosition = data.floatPosition || data.FloatPosition || {};
const interestList = data.interestList || data.InterestList || [];
const marginList = data.marginList || data.MarginList || [];
records.push({
id: `EVT_${row.id}`,
desc: `导出记录 event_type=${row.event_type} @ ${row.create_time}`,
eventType: row.event_type,
deal,
floatPosition,
interestList,
marginList,
expected: {
SwapCloseAmount: Number(deal.SwapCloseAmount != null ? deal.SwapCloseAmount : floatPosition.SwapCloseAmount),
SwapRealizedPnL: Number(deal.SwapRealizedPnL != null ? deal.SwapRealizedPnL : floatPosition.SwapRealizedPnL),
SwapMarginAmount: Number(deal.SwapMarginAmount != null ? deal.SwapMarginAmount : floatPosition.SwapMarginAmount),
},
});
}
const out = {
meta: {
schemaVersion: 1,
source: `exported from ${cfg.database} (${from}..${to}, limit ${limit})`,
note: '由 tools/exportHistoricalSwapEvents.js 生成;运行时无需 DB。',
},
records,
};
const outPath = path.join(__dirname, '..', 'fixtures', 'historical_swap_events.json');
fs.writeFileSync(outPath, JSON.stringify(out, null, 2));
console.log(`已导出 ${records.length} 条记录 → ${outPath}`);
}
main().catch(e => { console.error(e); process.exit(1); });