Files
work-hours-tracker/test/payroll.test.js
T
OpenClaw Agent 9efd9a3099
Build Android APK / Build Release APK (push) Canceled after 0s
Sync Release Notes / update-releases (push) Canceled after 0s
fix: 金额计算展示保留原始数据精度,去除 toFixed(1) 进位截断并升级至 v1.0.3
2026-09-16 03:04:10 +08:00

515 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 薪酬口径断言 —— 期望值全部写死在这个文件里。
//
// 与 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('金额格式化保留原始数据(分位不截断,不进位),null/undefined 当 0', () => {
assert.equal(P.fmt(undefined), '0.0');
assert.equal(P.money(null), '¥0');
assert.equal(P.money(1234.56), '¥1234.56');
assert.equal(P.money(2018.25), '¥2018.25');
assert.equal(P.signed(0), '+¥0');
assert.equal(P.signed(-5), '-¥5');
assert.equal(P.signed(-50.25), '-¥50.25');
assert.equal(P.signed(30), '+¥30');
});
test('moneyParts 保留原始数据小数位,千分位与小数部拆分对齐', () => {
assert.deepEqual(P.moneyParts(99.96), { sign: '', int: '99', frac: '.96' });
assert.deepEqual(P.moneyParts(2018.25), { sign: '', int: '2,018', frac: '.25' });
assert.deepEqual(P.moneyParts(-1234.56), { sign: '-', int: '1,234', frac: '.56' });
assert.deepEqual(P.moneyParts(0), { sign: '', int: '0', frac: '' });
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({}));
});