baseline: 薪酬逻辑收口到 shared/payroll.js 后的当前全量状态

这是第一个包含完整工作区的提交,也是后续「拆 SFC / 上 Vite」的回滚基线。

此前的提交均为 index.html 单文件历史快照,由 .bak 文件重建。

当前状态(提交前已实测):
- index.html 内联薪酬算法已删除,改调 WHTPayroll
- shared/payroll.js 为唯一薪酬实现,浏览器与 Node 共用
- 对外接口未变:aggregateMonth / isCutEdge / payDayKind 等保留同名薄包装

未入库内容见 .gitignore:依赖、Chrome 验证 profile、验证截图、
运行时 sqlite 数据与日志、时间戳备份、.trash。
This commit is contained in:
OpenClaw Agent
2026-09-05 14:35:00 +08:00
parent 7d48c4ac65
commit 2329955e32
94 changed files with 12770 additions and 237 deletions
+50
View File
@@ -0,0 +1,50 @@
// 重构护栏:index.html 里跑出来的数 == shared/payroll.js 算出来的数。
//
// 这层网的用途只有一个:**证明前端可以安全地改用纯模块**。
// 现在 index.html 还带着自己那份内联算法(grep WHTPayroll 为 0 处),
// 两份实现并存期间,本文件保证它们逐字段等价;等 index.html 真的换成调 WHTPayroll,
// 这些断言会自动变成「同一份代码比自己」,届时连同 app-harness.js 一起删掉。
//
// 失败怎么读:
// 报「app 与 pure 不一致」→ 两份实现漂了。先确认哪份是对的,别急着改基准。
// 报「index.html 里没找到内联 <script>」→ 前端结构已变,看 app-harness.js 顶部说明。
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { loadApp } = require('./lib/app-harness.js');
const { probeApp, probePure } = require('./lib/probe.js');
const { SCENARIOS, NOW } = require('./lib/scenarios.js');
/** 差异摘要:deepStrictEqual 的报文很长,先把「哪几个字段不一样」摘出来放最前面。 */
function diffKeys(a, b, path = '') {
const out = [];
const keys = new Set([...Object.keys(a || {}), ...Object.keys(b || {})]);
for (const k of keys) {
const p = path ? `${path}.${k}` : k;
const va = a ? a[k] : undefined;
const vb = b ? b[k] : undefined;
if (va && vb && typeof va === 'object' && typeof vb === 'object') out.push(...diffKeys(va, vb, p));
else if (JSON.stringify(va) !== JSON.stringify(vb)) out.push(`${p}: app=${JSON.stringify(va)} pure=${JSON.stringify(vb)}`);
}
return out;
}
for (const sc of SCENARIOS) {
test(`app==pure · ${sc.name}`, () => {
const { app } = loadApp({
records: sc.records, year: sc.year, month: sc.month,
settings: sc.settings, adjust: sc.adjust, now: NOW
});
const fromApp = probeApp(app);
const fromPure = probePure(sc, NOW);
const diff = diffKeys(fromApp, fromPure);
assert.deepStrictEqual(fromApp, fromPure, diff.length ? '字段差异:\n ' + diff.join('\n ') : undefined);
});
}
test('场景库不是空的(防止 SCENARIOS 被误清后整层网静默失效)', () => {
assert.ok(SCENARIOS.length >= 30, `场景数 ${SCENARIOS.length},少于 30 说明用例被删了`);
const names = SCENARIOS.map((s) => s.name);
assert.equal(new Set(names).size, names.length, '场景名重复,基准里会互相盖掉');
});
+58
View File
@@ -0,0 +1,58 @@
// 基准对照:把 30 个场景的全部薪酬输出与冻结快照逐字段比。
//
// 与 payroll.test.js 的分工:
// payroll.test.js 写死期望值,说明「为什么该是这个数」——防的是规则本身算错。
// 本文件 不判断对错,只判断有没有变——防的是重构悄悄改了数。
// 快照由 tools/gen-baseline.js 生成,默认只校验、写要带 --write(历史上它默认写,
// 等于 `node --test` 一跑就把网重新描一遍,算错也全绿;那个坑已经堵上了)。
//
// 红了怎么办:先看差在哪个字段,确认是「有意的业务变更」还是「重构漏了」。
// 前者才允许 node tools/gen-baseline.js --write,且要能逐条说清每处差异。
// 「测试红了就重新生成基准」= 把安全网剪断。
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { loadApp } = require('./lib/app-harness.js');
const { probeApp, probePure } = require('./lib/probe.js');
const { SCENARIOS, NOW } = require('./lib/scenarios.js');
const BASELINE = path.join(__dirname, 'baseline', 'payroll-baseline.json');
test('基准文件存在且能解析', () => {
assert.ok(fs.existsSync(BASELINE), `缺基准文件 ${BASELINE},先跑 node tools/gen-baseline.js --write`);
const raw = JSON.parse(fs.readFileSync(BASELINE, 'utf8'));
assert.equal(raw.frozenNow, NOW, '基准的冻结时刻与 scenarios.js 的 NOW 不一致,两边不可比');
assert.equal(raw.cases.length, raw.scenarioCount);
assert.ok(raw.cases.length > 0);
});
const baseline = JSON.parse(fs.readFileSync(BASELINE, 'utf8'));
const byName = new Map(baseline.cases.map((c) => [c.name, c.probe]));
test('基准场景集与 scenarios.js 完全对应(防止悄悄少测)', () => {
const now = SCENARIOS.map((s) => s.name).sort();
const old = baseline.cases.map((c) => c.name).sort();
assert.deepEqual(now, old, '场景增删后必须显式重写基准:node tools/gen-baseline.js --write');
});
for (const sc of SCENARIOS) {
test(`基准 · ${sc.name}`, () => {
const want = byName.get(sc.name);
assert.ok(want, `基准里没有场景「${sc.name}」`);
// index.html 现跑值 vs 冻结值
const { app } = loadApp({
records: sc.records, year: sc.year, month: sc.month,
settings: sc.settings, adjust: sc.adjust, now: NOW
});
assert.deepStrictEqual(probeApp(app), want, 'index.html 的输出与基准不一致');
// 纯模块现算值 vs 同一份冻结值。
// 这一条是为「index.html 拆掉之后」准备的:那天上面那句会失效,
// 而只要这句还在,payroll.js 被误改一样会被抓住。
assert.deepStrictEqual(probePure(sc, NOW), want, 'shared/payroll.js 的输出与基准不一致');
});
}
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
// 在 Node 里跑 index.html 内联的那坨 setup(),拿到它返回的全部 computed。
//
// 这个文件是**唯一**焊在 index.html 结构上的东西:它靠正则抠出内联 <script>,
// 一旦前端拆成 SFC 就会立刻失效。这是刻意的——它的职责只剩「重构前后行为一致」对照,
// 前端拆完那天连同 app-parity 测试一起删掉,而 shared/payroll.js 上的那套断言不受影响。
//
// 与 verify_payroll.js 里那份内联桩件的区别:
// 1. Date 可冻结 → payDayAPassed / isCurrentMonth / cellClass 这些吃「今天」的输出变成确定值,
// 否则基准 JSON 每天跑出来都不一样,根本没法当基准。
// 2. 会把 shared/payroll.js 先注入沙箱,让重构后的 index.html 找得到 WHTPayroll。
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const ROOT = path.join(__dirname, '..', '..');
const HTML = path.join(ROOT, 'index.html');
const PAYROLL = path.join(ROOT, 'shared', 'payroll.js');
/** 冻结「现在」:无参 new Date() 与 Date.now() 都回到固定时刻,带参构造照常。 */
function frozenDate(iso) {
const FIXED = new Date(iso).getTime();
return class FrozenDate extends Date {
constructor(...args) {
if (args.length === 0) super(FIXED);
else super(...args);
}
static now() { return FIXED; }
};
}
/** 极简 Vue 桩件:测试只需要能读写与求值,不需要真响应式。 */
function makeVue() {
const mounted = [];
const Vue = {
ref: (v) => ({ value: v }),
reactive: (o) => o,
computed: (fn) => ({ get value() { return fn(); } }),
watch: () => {},
onMounted: (fn) => mounted.push(fn),
createApp(opts) {
Vue._captured = opts.setup();
return { mount: () => Vue._captured };
}
};
return { Vue, mounted };
}
function makeStore(seed) {
const data = { ...seed };
return {
getItem: (k) => (k in data ? data[k] : null),
setItem: (k, v) => { data[k] = v; },
removeItem: (k) => { delete data[k]; },
_raw: () => data,
_records: () => JSON.parse(data.work_records || '{}'),
_settings: () => (data.work_settings ? JSON.parse(data.work_settings) : null),
_adjust: () => (data.work_month_adjust ? JSON.parse(data.work_month_adjust) : null)
};
}
/**
* 载入应用。
* @param {object} o
* @param {object} o.records 打卡记录,key 'YYYY-MM-DD'
* @param {number} o.year 当前查看年
* @param {number} o.month 当前查看月(0–11)
* @param {object} [o.settings] 覆盖设置,null/undefined 表示用默认
* @param {object} [o.adjust] 月度奖惩终稿,key 'YYYY-MM'
* @param {string} [o.now] 冻结的「现在」(ISO)
*/
function loadApp({ records = {}, year, month, settings = null, adjust = null, now = null }) {
const seed = { work_records: JSON.stringify(records) };
if (settings) seed.work_settings = JSON.stringify(settings);
if (adjust) seed.work_month_adjust = JSON.stringify(adjust);
const localStorage = makeStore(seed);
const { Vue, mounted } = makeVue();
const DateImpl = now ? frozenDate(now) : Date;
const sandbox = {
Vue, localStorage, console,
Date: DateImpl, JSON, Math, Number, RegExp, Object, Array, String, Boolean, Error,
navigator: {},
document: {
createElement: () => ({ style: {}, select() {}, click() {} }),
body: { appendChild() {}, removeChild() {} },
getElementById: () => null,
addEventListener() {}
},
confirm: () => true,
setTimeout: (fn) => { if (typeof fn === 'function') fn(); return 0; },
Blob: function () {},
URL: { createObjectURL: () => 'blob:x', revokeObjectURL() {} },
// 刻意不提供 window.WHTSync:测试跑「未登录/纯离线」路径,
// 顺带验证同步缺席时算钱逻辑完全不受影响。
location: { origin: 'http://test' },
fetch: () => Promise.reject(new Error('offline in test'))
};
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
// 先注入纯模块:重构后的 index.html 依赖它。重构前注入也无害(没人引用)。
if (fs.existsSync(PAYROLL)) {
vm.runInContext(fs.readFileSync(PAYROLL, 'utf8'), sandbox, { filename: 'shared/payroll.js' });
}
const src = fs.readFileSync(HTML, 'utf8');
const m = src.match(/<script>\s*([\s\S]*?)\s*<\/script>\s*<\/body>/);
if (!m) throw new Error('index.html 里没找到应用内联 <script>,harness 需要更新');
vm.runInContext(m[1], sandbox, { filename: 'index.html#app' });
mounted.forEach((fn) => fn()); // 触发 records / settings / adjust 载入
const app = Vue._captured;
app.currentYear.value = year;
app.currentMonth.value = month;
return { app, localStorage, sandbox };
}
module.exports = { loadApp, frozenDate, ROOT };
+164
View File
@@ -0,0 +1,164 @@
// 探针:把「一个月的全部薪酬输出」压成一个可比较的扁平对象。
//
// 两个实现必须产出结构完全相同的对象:
// probeApp(app) —— 从 index.html 跑出来的 setup() 返回值上读
// probePure(sc) —— 只用 shared/payroll.js 算
// 于是「重构有没有改变行为」就退化成一次 deepEqual。
'use strict';
const P = require('../../shared/payroll.js');
// 浮点噪声必须先掐掉:19.5 × 22 这类乘法在不同写法下可能差 1e-13,
// 那不是行为变化,却会让 deepEqual 全线报红。金额留 4 位足够(页面只显示 1 位)。
const r4 = (n) => {
const v = Number(n);
if (!Number.isFinite(v)) return v === undefined ? null : String(v);
return Math.round(v * 1e4) / 1e4;
};
const STAT_KEYS = [
'h1', 'h2', 'totalHours', 'workDays', 'dayShifts', 'nightShifts', 'restDays', 'leaveDays',
'foundCount', 'missedCount', 'otherPenalties',
'fullBonus', 'foundBonus', 'missedPenalty', 'adjustNet', 'reviewNet',
'rawReview', 'reviewAmount', 'reviewDelta', 'basePay'
];
const CFG_KEYS = [
'rate', 'fullDays', 'fullBonusPerHour', 'foundBonus', 'missedPenalty',
'cutoff', 'payDayA', 'payDayB', 'dayHours', 'nightHours', 'defaultShift'
];
function pickStats(s) {
const out = {};
for (const k of STAT_KEYS) out[k] = r4(s[k]);
out.finalized = s.finalized === true;
out.adjustStatus = s.adjustStatus === undefined ? null : s.adjustStatus;
return out;
}
function pickCfg(c) {
const out = {};
for (const k of CFG_KEYS) out[k] = typeof c[k] === 'string' ? c[k] : r4(c[k]);
return out;
}
const pickPenalties = (list) =>
Array.prototype.map.call(list, (d) => ({ day: d.day, reason: d.reason, amount: r4(d.amount) }));
/**
* 拉回宿主 realm。
* index.html 是在 vm 沙箱里跑的,它造出来的数组/对象带的是**沙箱的** Array.prototype,
* 而 deepStrictEqual 会连原型一起比 —— 不做这一步,所有场景都会因为原型不同而全红,
* 报错还长得像「结构相同但不相等」,非常容易误判成算法出了问题(实测踩过)。
*/
const hostify = (o) => JSON.parse(JSON.stringify(o));
/** 从跑起来的 index.html 应用实例上读。 */
function probeApp(app) {
const s = app.monthStats.value;
const p = app.prevStats.value;
const cfg = app.cfg.value;
const days = new Date(app.currentYear.value, app.currentMonth.value + 1, 0).getDate();
const allDays = Array.from({ length: days }, (_, i) => i + 1);
return hostify({
cfg: pickCfg(cfg),
cur: pickStats(s),
prev: pickStats(p),
prevLabel: { year: app.prevLabel.value.year, month: app.prevLabel.value.month },
nextLabel: { year: app.nextLabel.value.year, month: app.nextLabel.value.month },
flows: {
thisA: r4(app.payoutThisA.value),
thisB: r4(app.payoutThisB.value),
nextB: r4(app.payoutNextB.value),
earnedTotal: r4(app.earnedTotal.value),
cashTotal: r4(app.cashTotal.value),
carry: r4(app.earnedSplit.value.carry),
paid: r4(app.earnedSplit.value.paid),
floored: app.earnedFloored.value === true
},
penalties: pickPenalties(app.penaltyDetails.value),
calendar: {
days,
cutEdge: allDays.filter((d) => app.isCutEdge(d)),
payDayA: allDays.filter((d) => app.payDayKind(d) === 'a'),
payDayB: allDays.filter((d) => app.payDayKind(d) === 'b'),
penaltyFlag: allDays.filter((d) => app.hasPenalty(d))
},
misc: {
fullDaysLeft: app.fullDaysLeft.value,
payDayAPassed: app.payDayAPassed.value === true,
isCurrentMonth: app.isCurrentMonth.value === true,
recordCount: app.recordCount.value,
boardInt: String(app.boardMain.value.int),
boardFrac: String(app.boardMain.value.frac),
boardSign: String(app.boardMain.value.sign),
hourOptions: app.hourOptions.join(','),
daysInMonth: app.daysInMonth.value,
firstDayOfWeek: app.firstDayOfWeek.value
}
});
}
/** 只用 shared/payroll.js 算,不碰 Vue、不碰 index.html。 */
function probePure(sc, nowIso) {
const cfg = P.normalizeSettings(sc.settings || {});
const ctx = { records: sc.records || {}, monthAdjust: sc.adjust || {}, cfg };
const cur = P.aggregateMonth(sc.year, sc.month, ctx);
const prevL = P.prevMonthOf(sc.year, sc.month);
const nextL = P.nextMonthOf(sc.year, sc.month);
const prev = P.aggregateMonth(prevL.year, prevL.month, ctx);
const flows = P.flowsOf({ cur, prev, cfg });
const now = nowIso ? new Date(nowIso) : new Date();
const days = P.daysInMonth(sc.year, sc.month);
const allDays = Array.from({ length: days }, (_, i) => i + 1);
const records = sc.records || {};
const prefix = P.monthPrefix(sc.year, sc.month);
const hasPenalty = (day) => {
const rec = records[prefix + P.pad2(day)];
return !!rec && rec.status === 'work' &&
(Number(rec.contraband_missed || 0) > 0 || Number(rec.other_penalty || 0) > 0);
};
const parts = P.moneyParts(flows.earnedTotal);
return {
cfg: pickCfg(cfg),
cur: pickStats(cur),
prev: pickStats(prev),
prevLabel: { year: prevL.year, month: prevL.month },
nextLabel: { year: nextL.year, month: nextL.month },
flows: {
thisA: r4(flows.thisA),
thisB: r4(flows.thisB),
nextB: r4(flows.nextB),
earnedTotal: r4(flows.earnedTotal),
cashTotal: r4(flows.cashTotal),
carry: r4(flows.carry),
paid: r4(flows.split.paid),
floored: flows.floored === true
},
penalties: pickPenalties(P.penaltyDetails(sc.year, sc.month, ctx)),
calendar: {
days,
cutEdge: allDays.filter((d) => P.isCutEdge(d, cfg)),
payDayA: allDays.filter((d) => P.payDayKind(d, cfg) === 'a'),
payDayB: allDays.filter((d) => P.payDayKind(d, cfg) === 'b'),
penaltyFlag: allDays.filter(hasPenalty)
},
misc: {
fullDaysLeft: P.fullDaysLeft(cur, cfg),
payDayAPassed: P.payDayAPassed(sc.year, sc.month, cfg, now) === true,
isCurrentMonth: P.isCurrentMonth(sc.year, sc.month, now) === true,
recordCount: Object.keys(records).length,
boardInt: String(parts.int),
boardFrac: String(parts.frac),
boardSign: String(parts.sign),
hourOptions: P.hourOptions.join(','),
daysInMonth: days,
firstDayOfWeek: P.firstDayOfWeek(sc.year, sc.month)
}
};
}
module.exports = { probeApp, probePure, r4, STAT_KEYS };
+229
View File
@@ -0,0 +1,229 @@
// 测试场景库 —— payroll 模块、index.html 基准、重构后对照三方共用同一批数据。
//
// 为什么单独一个文件:基准快照与重构后的实测必须喂**完全相同**的输入,
// 否则对不上时分不清是「代码变了」还是「测试数据变了」。
'use strict';
const W = (h, extra) => Object.assign({
status: 'work', shift_type: 'day', hours: h,
contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: ''
}, extra || {});
const FINAL = (mk, amount) => ({
[mk]: { status: 'final', finalAmount: amount, note: '', finalizedAt: '2026-10-01T09:00:00.000Z' }
});
const DRAFT = (mk, amount) => ({
[mk]: { status: 'draft', finalAmount: amount, note: '', finalizedAt: '' }
});
/** 满勤月:1–N 日全上白班 8h */
function fullMonth(days, hours) {
const rec = {};
for (let d = 1; d <= days; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(hours === undefined ? 8 : hours);
return rec;
}
// 冻结的「现在」:不冻结的话 payDayAPassed / isCurrentMonth / 今日高亮
// 每天跑出来都不一样,基准 JSON 根本没法比。
// 取 2026-09-20 是刻意的:已过 payDayA(22)? 没过 —— 正好覆盖「上半月待发」这一支。
const NOW = '2026-09-20T10:00:00+08:00';
/**
* 每个场景都会被喂给三方:纯模块、重构前的 index.html、重构后的 index.html。
* 覆盖目标:12 个薪酬 computed 的全部分支。
*/
const SCENARIOS = [
{
name: '基础分段·上下半月各2天无奖惩',
records: { '2026-09-03': W(8), '2026-09-14': W(8), '2026-09-20': W(10), '2026-09-28': W(10) },
year: 2026, month: 8
},
{
name: '边界·15日算上半月/16日算下半月',
records: { '2026-09-15': W(12), '2026-09-16': W(12) },
year: 2026, month: 8
},
{
name: '夜班跨天·15日夜班10h整段进上半月',
records: { '2026-09-15': W(10, { shift_type: 'night' }) },
year: 2026, month: 8
},
{
name: '满勤达标·27天×8h',
records: fullMonth(27),
year: 2026, month: 8
},
{
name: '差1天不满勤·26天×8h',
records: fullMonth(26),
year: 2026, month: 8
},
{
name: '奖惩四项归集·未定稿只记录',
records: {
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
},
year: 2026, month: 8
},
{
name: '奖惩已定稿-60',
records: {
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
},
year: 2026, month: 8, adjust: FINAL('2026-09', -60)
},
{
name: '草稿不生效·draft+30',
records: { '2026-09-05': W(8, { contraband_found: 3 }) },
year: 2026, month: 8, adjust: DRAFT('2026-09', 30)
},
{
name: '审核改数·累计+30只认+20',
records: { '2026-09-05': W(8, { contraband_found: 3 }) },
year: 2026, month: 8, adjust: FINAL('2026-09', 20)
},
{
name: '跨月结转·8月未定稿',
records: {
'2026-08-10': W(8),
'2026-08-18': W(10), '2026-08-22': W(10),
'2026-08-27': W(10, { contraband_found: 5, contraband_missed: 1 }),
'2026-09-02': W(8), '2026-09-09': W(8)
},
year: 2026, month: 8
},
{
name: '跨月结转·8月已定稿+30',
records: {
'2026-08-10': W(8),
'2026-08-18': W(10), '2026-08-22': W(10),
'2026-08-27': W(10, { contraband_found: 5, contraband_missed: 1 }),
'2026-09-02': W(8), '2026-09-09': W(8)
},
year: 2026, month: 8, adjust: FINAL('2026-08', 30)
},
{
name: '跨年边界·2026年1月取2025年12月',
records: {
'2025-12-20': W(10), '2025-12-28': W(10, { contraband_found: 2 }),
'2026-01-05': W(8)
},
year: 2026, month: 0
},
{
name: '跨年边界·2025-12已定稿+20',
records: {
'2025-12-20': W(10), '2025-12-28': W(10, { contraband_found: 2 }),
'2026-01-05': W(8)
},
year: 2026, month: 0, adjust: FINAL('2025-12', 20)
},
{
name: '处罚超过下半月工资·未定稿',
records: { '2026-09-20': W(6, { contraband_missed: 10, other_penalty: 200 }) },
year: 2026, month: 8
},
{
name: '处罚超过下半月工资·定稿-400触发地板0',
records: { '2026-09-20': W(6, { contraband_missed: 10, other_penalty: 200 }) },
year: 2026, month: 8, adjust: FINAL('2026-09', -400)
},
{
name: '休息请假不计工时',
records: {
'2026-09-03': W(8),
'2026-09-04': { status: 'rest' },
'2026-09-05': { status: 'leave' },
'2026-09-06': { status: 'rest' }
},
year: 2026, month: 8
},
{
name: '脏数据·其他扣款负数夹为0',
records: { '2026-09-20': W(8, { other_penalty: -500 }) },
year: 2026, month: 8
},
{
name: '月份隔离·不串月',
records: { '2026-08-05': W(12), '2026-10-05': W(12), '2026-09-05': W(8) },
year: 2026, month: 8
},
{
name: '自定义设置·时薪30/满勤20/单价2/截止10/发薪25与5',
records: (() => {
const rec = {};
for (let d = 1; d <= 20; d++) rec[`2026-09-${String(d).padStart(2, '0')}`] = W(10);
return rec;
})(),
year: 2026, month: 8,
settings: { rate: 30, fullDays: 20, fullBonusPerHour: 2, cutoff: 10, payDayA: 25, payDayB: 5 }
},
{
name: '非法设置全部回落默认',
records: { '2026-09-05': W(8) },
year: 2026, month: 8,
settings: { rate: null, fullDays: 0, fullBonusPerHour: undefined, cutoff: 99, foundBonus: -5, missedPenalty: NaN }
},
{
name: '处罚明细排序与原因',
records: {
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' }),
'2026-09-08': W(8, { contraband_missed: 2 }),
'2026-09-12': W(8, { contraband_missed: 1, other_penalty: 30 }),
'2026-09-15': W(8)
},
year: 2026, month: 8
},
{
name: '空数据不崩',
records: {},
year: 2026, month: 8
},
{
name: '越界旧值原样保留·14h与4h',
records: { '2026-09-05': W(14), '2026-09-06': W(4), '2026-09-07': W(8.5) },
year: 2026, month: 8
},
{
name: '用户实测场景·3天22h含查获1件未定稿',
records: { '2026-09-01': W(8), '2026-09-02': W(8), '2026-09-03': W(6, { contraband_found: 1 }) },
year: 2026, month: 8
},
{
name: '用户实测场景·同上已定稿+10',
records: { '2026-09-01': W(8), '2026-09-02': W(8), '2026-09-03': W(6, { contraband_found: 1 }) },
year: 2026, month: 8, adjust: FINAL('2026-09', 10)
},
{
name: '满勤+查获5件未定稿·满勤计奖惩不计',
records: Object.assign(fullMonth(27), { '2026-09-05': W(8, { contraband_found: 5 }) }),
year: 2026, month: 8
},
{
name: '满勤+查获5件已定稿+50·两项全计',
records: Object.assign(fullMonth(27), { '2026-09-05': W(8, { contraband_found: 5 }) }),
year: 2026, month: 8, adjust: FINAL('2026-09', 50)
},
{
name: '历史月份·看8月(payDayA 已过)',
records: { '2026-08-05': W(8), '2026-08-20': W(10) },
year: 2026, month: 7
},
{
name: '未来月份·看10月(payDayA 未过)',
records: { '2026-10-05': W(8), '2026-10-20': W(10) },
year: 2026, month: 9
},
{
name: '班次默认工时越界·白班2h夜班20h',
records: {},
year: 2026, month: 8,
settings: { dayHours: 2, nightHours: 20 }
}
];
module.exports = { W, FINAL, DRAFT, fullMonth, SCENARIOS, NOW };
+512
View File
@@ -0,0 +1,512 @@
// 薪酬口径断言 —— 期望值全部写死在这个文件里。
//
// 与 baseline.test.js 的分工,这条界线很重要:
// baseline = 「现在算出来的还是不是原来那个数」,它不知道什么是对的,只知道有没有变。
// 本文件 = 「这个数为什么该是这个数」,每条都能对着业务规则念出来。
// 只有 baseline 的话,一开始就算错的规则会被永久固化成「正确」。所以两者都要有。
//
// 口径来源:shared/payroll.js 顶部注释里那份用户确认过的规则,不是从代码反推的。
// 时薪 19.5 | 满勤门槛 27 天 | 满勤奖 1 元/工时 | 查获 +10/件 | 漏查 -20/件
// 上半月截止 15 日 | payDayA=22 发本月上半月工时 | payDayB=次月 10 号发下半月工时+满勤奖+已定稿奖惩
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const P = require('../shared/payroll.js');
const { W, FINAL, DRAFT, fullMonth } = require('./lib/scenarios.js');
const CFG = P.normalizeSettings({});
const ctxOf = (records, adjust, cfg) => ({ records, monthAdjust: adjust || {}, cfg: cfg || CFG });
const agg = (records, adjust, y = 2026, m = 8, cfg) => P.aggregateMonth(y, m, ctxOf(records, adjust, cfg));
/** 本月 + 上月 + 现金流,一次算齐(跨月口径必须两头同源,见 flowsOf 注释)。 */
function flows(records, adjust, y = 2026, m = 8, cfg = CFG) {
const ctx = ctxOf(records, adjust, cfg);
const cur = P.aggregateMonth(y, m, ctx);
const pl = P.prevMonthOf(y, m);
const prev = P.aggregateMonth(pl.year, pl.month, ctx);
return { cur, prev, f: P.flowsOf({ cur, prev, cfg }) };
}
// ───────────────────────── 默认规则值 ─────────────────────────
test('默认规则值就是用户确认的那套', () => {
assert.equal(CFG.rate, 19.5);
assert.equal(CFG.fullDays, 27);
assert.equal(CFG.fullBonusPerHour, 1);
assert.equal(CFG.foundBonus, 10);
assert.equal(CFG.missedPenalty, 20);
assert.equal(CFG.cutoff, 15);
assert.equal(CFG.payDayA, 22);
assert.equal(CFG.payDayB, 10);
assert.equal(CFG.dayHours, 8);
assert.equal(CFG.nightHours, 10);
assert.equal(CFG.defaultShift, 'day');
});
test('工时可选 6–12h 步进 0.5,共 13 档', () => {
assert.equal(P.hourOptions.length, 13);
assert.equal(P.hourOptions[0], 6);
assert.equal(P.hourOptions.at(-1), 12);
assert.deepEqual(P.hourOptions, [6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10, 10.5, 11, 11.5, 12]);
});
// ───────────────────────── 上下半月分段 ─────────────────────────
test('15 日算上半月、16 日算下半月(截止日本身归上半月)', () => {
const s = agg({ '2026-09-15': W(12), '2026-09-16': W(12) });
assert.equal(s.h1, 12);
assert.equal(s.h2, 12);
assert.equal(s.totalHours, 24);
assert.equal(s.basePay, 24 * 19.5); // 468
});
test('夜班跨天:工时整段归开始打卡那一天,不按自然日切开', () => {
// 15 日夜班 10h 实际干到 16 日,但 10h 全进上半月
const s = agg({ '2026-09-15': W(10, { shift_type: 'night' }) });
assert.equal(s.h1, 10);
assert.equal(s.h2, 0);
assert.equal(s.nightShifts, 1);
assert.equal(s.dayShifts, 0);
});
test('月份隔离:相邻月记录不串进来', () => {
const s = agg({ '2026-08-05': W(12), '2026-10-05': W(12), '2026-09-05': W(8) });
assert.equal(s.totalHours, 8);
assert.equal(s.workDays, 1);
});
test('休息/请假不计工时也不计出勤天数', () => {
const s = agg({
'2026-09-03': W(8),
'2026-09-04': { status: 'rest' },
'2026-09-05': { status: 'leave' },
'2026-09-06': { status: 'rest' }
});
assert.equal(s.totalHours, 8);
assert.equal(s.workDays, 1);
assert.equal(s.restDays, 2);
assert.equal(s.leaveDays, 1);
});
// ───────────────────────── 满勤奖 ─────────────────────────
test('满勤达标:27 天 × 8h → 满勤奖 = 总工时 × 1 = 216', () => {
const s = agg(fullMonth(27));
assert.equal(s.workDays, 27);
assert.equal(s.totalHours, 216);
assert.equal(s.fullBonus, 216);
assert.equal(s.adjustNet, 216); // 无奖惩时 adjustNet 就是满勤奖
assert.equal(P.fullDaysLeft(s, CFG), 0);
});
test('差 1 天不满勤:26 天 → 满勤奖归零,不按比例给', () => {
const s = agg(fullMonth(26));
assert.equal(s.workDays, 26);
assert.equal(s.totalHours, 208);
assert.equal(s.fullBonus, 0);
assert.equal(s.adjustNet, 0);
assert.equal(P.fullDaysLeft(s, CFG), 1);
});
test('满勤只看出勤天数,不受奖惩审核影响', () => {
// 27 天满勤 + 查获 5 件未定稿:满勤奖照给,奖惩按 0
const s = agg(Object.assign(fullMonth(27), { '2026-09-05': W(8, { contraband_found: 5 }) }));
assert.equal(s.fullBonus, 216);
assert.equal(s.rawReview, 50); // 记录在案
assert.equal(s.reviewNet, 0); // 未定稿不发
assert.equal(s.adjustNet, 216); // 只含满勤奖
});
// ───────────────────────── 奖惩与审核定稿 ─────────────────────────
test('奖惩四项归集:查获 +10/件、漏查 -20/件、其他扣款按日填', () => {
const s = agg({
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
});
assert.equal(s.foundCount, 3);
assert.equal(s.missedCount, 2);
assert.equal(s.otherPenalties, 50);
assert.equal(s.foundBonus, 30);
assert.equal(s.missedPenalty, 40);
assert.equal(s.rawReview, 30 - 40 - 50); // -60
});
test('未定稿一律按 0:累计 -60 记着,但不进应得也不进发放', () => {
const { cur, f } = flows({
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
});
assert.equal(cur.rawReview, -60);
assert.equal(cur.finalized, false);
assert.equal(cur.reviewAmount, 0);
assert.equal(cur.reviewDelta, 0); // 未定稿不谈差额
assert.equal(cur.adjustNet, 0);
assert.equal(f.earnedTotal, 468); // 24h × 19.5,奖惩不掺和
});
test('已定稿 -60:按审核终稿计入,差额为 0', () => {
const recs = {
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2 }),
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' })
};
const { cur, f } = flows(recs, FINAL('2026-09', -60));
assert.equal(cur.finalized, true);
assert.equal(cur.reviewAmount, -60);
assert.equal(cur.reviewDelta, 0);
assert.equal(cur.adjustNet, -60);
assert.equal(f.earnedTotal, 468 - 60); // 408
});
test('审核改数:累计 +30 但组长只批 +20 → 认 20,差额 -10', () => {
const { cur } = flows({ '2026-09-05': W(8, { contraband_found: 3 }) }, FINAL('2026-09', 20));
assert.equal(cur.rawReview, 30);
assert.equal(cur.reviewAmount, 20);
assert.equal(cur.reviewDelta, -10);
assert.equal(cur.adjustNet, 20);
});
test('草稿不生效:status=draft 即使带金额也按 0', () => {
const { cur } = flows({ '2026-09-05': W(8, { contraband_found: 3 }) }, DRAFT('2026-09', 30));
assert.equal(cur.adjustStatus, 'draft');
assert.equal(cur.finalized, false);
assert.equal(cur.reviewAmount, 0);
assert.equal(cur.adjustNet, 0);
});
test('已知怪癖钉死:final + finalAmount:null 会被当成「已定稿 ¥0」', () => {
// Number(null)===0 且 isFinite(0) 为真 → finalized 判定命中。
// UI 走不到(writeAdjust 会拦),只有导入脏备份碰得到。
// 重构阶段保持原行为,改它属于业务变更,要单独提。
const { cur } = flows({ '2026-09-05': W(8, { contraband_found: 3 }) }, FINAL('2026-09', null));
assert.equal(cur.finalized, true);
assert.equal(cur.reviewAmount, 0);
assert.equal(cur.reviewDelta, -30); // 累计 +30 被抹成 0
assert.equal(cur.adjustNet, 0);
});
// ───────────────────────── 发薪流与地板 ─────────────────────────
test('两笔发薪:payDayA 只发本月上半月工时,payDayB 发上月下半月+上月奖惩', () => {
const { f } = flows({
'2026-09-03': W(8), '2026-09-14': W(8), // 上半月 16h
'2026-09-20': W(10), '2026-09-28': W(10) // 下半月 20h
});
assert.equal(f.thisA, 16 * 19.5); // 312
assert.equal(f.thisB, 0); // 8 月没记录
assert.equal(f.nextB, 20 * 19.5); // 390,次月 10 号发
assert.equal(f.earnedTotal, 36 * 19.5); // 702 归属制
assert.equal(f.cashTotal, f.thisA + f.thisB); // 312 收付制
});
test('跨月结转:上月下半月工时次月才进账,上月未定稿奖惩不进', () => {
const CROSS = {
'2026-08-10': W(8),
'2026-08-18': W(10), '2026-08-22': W(10),
'2026-08-27': W(10, { contraband_found: 5, contraband_missed: 1 }),
'2026-09-02': W(8), '2026-09-09': W(8)
};
const a = flows(CROSS);
assert.equal(a.prev.h2, 30);
assert.equal(a.prev.adjustNet, 0); // 8 月未定稿
assert.equal(a.f.thisB, 30 * 19.5); // 585
assert.equal(a.f.cashTotal, 16 * 19.5 + 585); // 897
const b = flows(CROSS, FINAL('2026-08', 30));
assert.equal(b.prev.adjustNet, 30);
assert.equal(b.f.thisB, 585 + 30); // 615
assert.equal(b.f.cashTotal, 312 + 615); // 927
// 9 月自己那份没变:定稿的是 8 月
assert.equal(b.f.earnedTotal, a.f.earnedTotal);
});
test('结转 ≡ 次月实际到账(carry 与 nextB 同一个数,不留账目缺口)', () => {
const { f } = flows(fullMonth(27));
assert.equal(f.carry, f.nextB);
assert.equal(f.split.carry, f.nextB);
assert.equal(f.split.paid, f.thisA);
});
test('处罚吃穿下半月工资:到账被 ¥0 兜住,earnedTotal 允许为负并升 floored 旗', () => {
const recs = { '2026-09-20': W(6, { contraband_missed: 10, other_penalty: 200 }) };
const undecided = flows(recs);
assert.equal(undecided.cur.rawReview, -400);
assert.equal(undecided.f.nextB, 117); // 未定稿 → 处罚不生效,6h×19.5 照发
assert.equal(undecided.f.floored, false);
const finalized = flows(recs, FINAL('2026-09', -400));
assert.equal(finalized.cur.adjustNet, -400);
assert.equal(finalized.f.nextB, 0); // max(0, 117-400) → 地板
assert.equal(finalized.f.floored, true);
assert.equal(finalized.f.earnedTotal, 117 - 400); // -283,归属制照实说
// 地板兜住之后 paid+carry 会大于 earnedTotal,页面必须明说,别让用户自己对不上
assert.ok(finalized.f.split.paid + finalized.f.carry > finalized.f.earnedTotal);
});
test('earnedTotal 恒等于 basePay + adjustNet(三项加总严丝合缝)', () => {
for (const [recs, adj] of [
[fullMonth(27), null],
[fullMonth(27), FINAL('2026-09', 50)],
[{ '2026-09-05': W(8, { contraband_missed: 4 }) }, FINAL('2026-09', -80)],
[{}, null]
]) {
const { cur, f } = flows(recs, adj);
assert.equal(f.earnedTotal, cur.basePay + cur.adjustNet);
assert.equal(cur.adjustNet, cur.fullBonus + cur.reviewNet);
}
});
// ───────────────────────── 跨年 ─────────────────────────
test('跨年边界:2026 年 1 月的上月是 2025 年 12 月', () => {
assert.deepEqual(P.prevMonthOf(2026, 0), { year: 2025, month: 11 });
assert.deepEqual(P.nextMonthOf(2026, 11), { year: 2027, month: 0 });
const recs = {
'2025-12-20': W(10), '2025-12-28': W(10, { contraband_found: 2 }),
'2026-01-05': W(8)
};
const a = flows(recs, null, 2026, 0);
assert.equal(a.prev.h2, 20);
assert.equal(a.f.thisB, 20 * 19.5); // 390
const b = flows(recs, FINAL('2025-12', 20), 2026, 0);
assert.equal(b.f.thisB, 390 + 20); // 跨年定稿也认
});
test('闰年天数与月首星期', () => {
assert.equal(P.daysInMonth(2028, 1), 29);
assert.equal(P.daysInMonth(2026, 1), 28);
assert.equal(P.daysInMonth(2026, 8), 30);
assert.equal(P.firstDayOfWeek(2026, 8), 2); // 2026-09-01 周二
});
test('日期键拼装', () => {
assert.equal(P.pad2(3), '03');
assert.equal(P.pad2(30), '30');
assert.equal(P.monthPrefix(2026, 8), '2026-09-');
assert.equal(P.keyOf(2026, 8, 5), '2026-09-05');
assert.equal(P.monthKeyOf(2026, 0), '2026-01');
});
// ───────────────────────── 脏数据兜底 ─────────────────────────
test('其他扣款为负时夹到 0,不能变成倒给钱', () => {
const s = agg({ '2026-09-20': W(8, { other_penalty: -500 }) });
assert.equal(s.otherPenalties, 0);
assert.equal(s.rawReview, 0);
});
test('sn 兜底:空串/null 回落默认,负数回落默认,0 是有效值', () => {
assert.equal(P.sn('', 9), 9);
assert.equal(P.sn(null, 9), 9);
assert.equal(P.sn(undefined, 9), 9);
assert.equal(P.sn(-1, 9), 9);
assert.equal(P.sn(NaN, 9), 9);
assert.equal(P.sn(0, 9), 0); // 关键:0 不能被当成「空」
assert.equal(P.sn('7.5', 9), 7.5);
});
test('clampHours 夹到 6–12 并对齐 0.5 步进', () => {
assert.equal(P.clampHours(2), 6);
assert.equal(P.clampHours(20), 12);
assert.equal(P.clampHours(8.3), 8.5);
assert.equal(P.clampHours(8.2), 8);
assert.equal(P.clampHours(NaN, 7), 7);
});
test('非法设置全部回落/夹紧,不让计算变 0 或 NaN', () => {
const c = P.normalizeSettings({
rate: null, fullDays: 0, fullBonusPerHour: undefined,
cutoff: 99, foundBonus: -5, missedPenalty: NaN,
payDayA: 0, payDayB: 99, dayHours: 2, nightHours: 20, defaultShift: 'x'
});
assert.equal(c.rate, 19.5);
assert.equal(c.fullDays, 27); // 0 天满勤没意义 → 回落
assert.equal(c.fullBonusPerHour, 1);
assert.equal(c.foundBonus, 10);
assert.equal(c.missedPenalty, 20);
assert.equal(c.cutoff, 28); // 夹到上限
assert.equal(c.payDayA, 1); // 夹到下限,否则日历判定永不命中
assert.equal(c.payDayB, 31);
assert.equal(c.dayHours, 6);
assert.equal(c.nightHours, 12);
assert.equal(c.defaultShift, 'day');
});
test('越界的旧打卡记录原样参与计算,不被设置的夹紧规则改写', () => {
// clampHours 只作用于设置里的「默认工时」,不碰已保存记录
const s = agg({ '2026-09-05': W(14), '2026-09-06': W(4), '2026-09-07': W(8.5) });
assert.equal(s.h1, 26.5);
assert.equal(s.basePay, 26.5 * 19.5);
});
test('空数据不崩,全部归零', () => {
const { cur, f } = flows({});
assert.equal(cur.totalHours, 0);
assert.equal(cur.workDays, 0);
assert.equal(cur.basePay, 0);
assert.equal(f.earnedTotal, 0);
assert.equal(f.cashTotal, 0);
assert.equal(f.floored, false);
});
// ───────────────────────── 自定义设置 ─────────────────────────
test('自定义设置全程生效:时薪30/满勤20天/满勤奖2/截止10日', () => {
const cfg = P.normalizeSettings({ rate: 30, fullDays: 20, fullBonusPerHour: 2, cutoff: 10, payDayA: 25, payDayB: 5 });
const recs = {};
for (let d = 1; d <= 20; d++) recs[`2026-09-${String(d).padStart(2, '0')}`] = W(10);
const { cur, f } = flows(recs, null, 2026, 8, cfg);
assert.equal(cur.h1, 100); // 1–10 日
assert.equal(cur.h2, 100); // 11–20 日
assert.equal(cur.workDays, 20);
assert.equal(cur.fullBonus, 200 * 2); // 400
assert.equal(cur.basePay, 200 * 30); // 6000
assert.equal(f.thisA, 100 * 30); // 3000
assert.equal(f.nextB, 100 * 30 + 400); // 3400
assert.equal(f.earnedTotal, 6400);
});
// ───────────────────────── 处罚明细 ─────────────────────────
test('处罚明细按日排序、原因拼接、金额<=0 不列', () => {
const list = P.penaltyDetails(2026, 8, ctxOf({
'2026-09-25': W(8, { other_penalty: 50, penalty_reason: '迟到' }),
'2026-09-08': W(8, { contraband_missed: 2 }),
'2026-09-12': W(8, { contraband_missed: 1, other_penalty: 30 }),
'2026-09-15': W(8), // 无处罚 → 不列
'2026-09-18': { status: 'rest', contraband_missed: 9 } // 非上班日 → 不看
}));
assert.deepEqual(list, [
{ day: 8, reason: '漏查2件', amount: 40 },
{ day: 12, reason: '漏查1件·其他扣款', amount: 50 },
{ day: 25, reason: '迟到', amount: 50 }
]);
});
// ───────────────────────── 日历判定 ─────────────────────────
test('分界虚线正好 7 格:cutoff-6 … cutoff', () => {
const edge = (cfg) => Array.from({ length: 31 }, (_, i) => i + 1).filter((d) => P.isCutEdge(d, cfg));
assert.deepEqual(edge(CFG), [9, 10, 11, 12, 13, 14, 15]);
assert.deepEqual(edge(P.normalizeSettings({ cutoff: 10 })), [4, 5, 6, 7, 8, 9, 10]);
});
test('发薪日角标 a/b 各认各的日子', () => {
assert.equal(P.payDayKind(22, CFG), 'a');
assert.equal(P.payDayKind(10, CFG), 'b');
assert.equal(P.payDayKind(15, CFG), null);
});
test('payDayAPassed:当月看日子,历史月一律已发,未来月一律未发', () => {
const NOW = new Date('2026-09-20T10:00:00+08:00'); // 20 日 < payDayA 22
assert.equal(P.payDayAPassed(2026, 8, CFG, NOW), false); // 当月未到
assert.equal(P.payDayAPassed(2026, 7, CFG, NOW), true); // 8 月
assert.equal(P.payDayAPassed(2025, 11, CFG, NOW), true); // 去年 12 月
assert.equal(P.payDayAPassed(2026, 9, CFG, NOW), false); // 10 月
assert.equal(P.payDayAPassed(2027, 0, CFG, NOW), false); // 明年 1 月
const LATER = new Date('2026-09-22T00:30:00+08:00');
assert.equal(P.payDayAPassed(2026, 8, CFG, LATER), true); // 到日子了
});
test('isCurrentMonth / isSameDay 只认传进来的 now', () => {
const NOW = new Date('2026-09-20T10:00:00+08:00');
assert.equal(P.isCurrentMonth(2026, 8, NOW), true);
assert.equal(P.isCurrentMonth(2026, 7, NOW), false);
assert.equal(P.isSameDay(2026, 8, 20, NOW), true);
assert.equal(P.isSameDay(2026, 8, 21, NOW), false);
});
// ───────────────────────── 格式化 ─────────────────────────
test('金额格式化保留 1 位,null/undefined 当 0', () => {
assert.equal(P.fmt(undefined), '0.0');
assert.equal(P.money(null), '¥0.0');
assert.equal(P.money(1234.56), '¥1234.6');
assert.equal(P.signed(0), '+¥0.0');
assert.equal(P.signed(-5), '-¥5.0');
assert.equal(P.signed(30), '+¥30.0');
});
test('moneyParts 从 toFixed(1) 的结果上切,不另算整数部', () => {
// 99.96 若用 Math.floor 另算会得到 99,与 money() 的 ¥100.0 打架
assert.deepEqual(P.moneyParts(99.96), { sign: '', int: '100', frac: '.0' });
assert.deepEqual(P.moneyParts(-1234.56), { sign: '-', int: '1,234', frac: '.6' });
assert.deepEqual(P.moneyParts(0), { sign: '', int: '0', frac: '.0' });
assert.deepEqual(P.moneyParts(1234567.8), { sign: '', int: '1,234,567', frac: '.8' });
});
test('日历格子工时拆分:只认整数与 .5', () => {
assert.equal(P.hInt(8.5), 8);
assert.equal(P.hFrac(8.5), '.5');
assert.equal(P.hFrac(8), '');
assert.equal(P.hFrac(8.25), ''); // 非 .5 不显示,避免半个槽位错位
});
// ───────────────────────── 草稿 ─────────────────────────
test('newDraft 按班次给默认工时', () => {
assert.equal(P.newDraft('day', CFG).hours, 8);
assert.equal(P.newDraft('night', CFG).hours, 10);
assert.equal(P.newDraft('night', CFG).shift_type, 'night');
assert.equal(P.newDraft('day', CFG).status, 'work');
assert.equal(P.newDraft('day', P.normalizeSettings({ dayHours: 12 })).hours, 12);
});
test('normalizeDraft:非正扣款回落 null,非上班日清空全部奖惩', () => {
assert.equal(P.normalizeDraft({ status: 'work', other_penalty: -5 }).other_penalty, null);
assert.equal(P.normalizeDraft({ status: 'work', other_penalty: 0 }).other_penalty, null);
assert.equal(P.normalizeDraft({ status: 'work', other_penalty: '30' }).other_penalty, 30);
assert.deepEqual(
P.normalizeDraft({ status: 'rest', contraband_found: 3, contraband_missed: 1, other_penalty: 50, penalty_reason: 'x' }),
{ status: 'rest', contraband_found: 0, contraband_missed: 0, other_penalty: null, penalty_reason: '' }
);
});
test('normalizeDraft 是原地改(调用方持有的就是待落库那份)', () => {
const d = { status: 'work', other_penalty: -1 };
assert.equal(P.normalizeDraft(d), d);
assert.equal(d.other_penalty, null);
});
// ───────────────────────── 纯度:不改入参 ─────────────────────────
test('算钱函数不改 records / monthAdjust(硬性约束 2)', () => {
const records = {
'2026-09-05': W(8, { contraband_found: 3 }),
'2026-09-20': W(8, { contraband_missed: 2, other_penalty: -9 }),
'2026-09-04': { status: 'rest' }
};
const adjust = FINAL('2026-09', -60);
const settings = { rate: 21, cutoff: 12 };
const snap = JSON.stringify({ records, adjust, settings });
const cfg = P.normalizeSettings(settings);
const ctx = ctxOf(records, adjust, cfg);
P.aggregateMonth(2026, 8, ctx);
P.aggregateMonth(2026, 7, ctx);
P.penaltyDetails(2026, 8, ctx);
P.flowsOf({ cur: P.aggregateMonth(2026, 8, ctx), prev: P.aggregateMonth(2026, 7, ctx), cfg });
P.getMonthAdjust(adjust, 2026, 8);
assert.equal(JSON.stringify({ records, adjust, settings }), snap);
});
test('normalizeSettings 返回新对象,不写回原 settings', () => {
const settings = { rate: 30 };
const c = P.normalizeSettings(settings);
assert.notEqual(c, settings);
assert.deepEqual(settings, { rate: 30 }); // 没被塞进默认值
assert.equal(c.fullDays, 27);
});
test('normalizeSettings 不传参也能用', () => {
assert.deepEqual(P.normalizeSettings(), P.normalizeSettings({}));
assert.deepEqual(P.normalizeSettings(null), P.normalizeSettings({}));
});