Payroll Settlement Sheet — Test Coverage (batch2/sheet)
Status: design approved 2026-07-01. Feeds an implementation plan (writing-plans). Branch: test/batch2-sheet off develop (revised 2026-07-03 — test/unit-coverage-batch1 was found to be 423 commits behind develop and missing the sheets/workbook/ submodule split entirely; only its overtime-adapter fix commit is cherry-picked forward).
1. Goal
Add frontend unit tests and Playwright e2e tests for the 급여정산 (payroll settlement) sheet feature, targeting silent-breakage logic (money, dates, sort, domain→sheet mapping). Fix the adapter bugs found during investigation and pin the correct behavior with tests. Backend work is explicitly out of scope for this batch.
2. Scope
In
- Frontend unit tests (Vitest + happy-dom, co-located
*.test.ts) across four areas: money compute (frontend), domain adapters (5), interaction, engine. - New Playwright e2e specs for journeys not covered by the existing 01–20.
- Fix the 5 read-only domain adapters' drift bugs + correct-behavior tests.
- Small behavior-preserving refactors to make private logic testable.
Out (handoff / later)
- Backend unit tests (
bankersRound,evaluateFormula, commit-split) — not this author's role. Backend money correctness is only indirectly exercised by e2e #23. - The multi-tenancy leak fix in
resolver.ts/sheet-cell.router.ts/sheet-row.router.ts— report only (Section 8), handed to a backend owner.
3. Background (subsystems + current coverage)
The "sheet" is three subsystems:
- Read-only domain tabs —
apps/hr-system/src/sheets/*.adapter.ts(overtime, education, resignation, sports, yearend) built viamakeReadOnlySheet+ a pureSheetAdapter. CLAUDE.md flags these as prime silent-breakage targets. - Workbook (settlement core) —
sheets/workbook/+workbook-adapter.ts(102KB): formula/summary/subtotal/sort/filter/commit. - Backend resolver —
packages/api/src/routers/sheet-rows/(out of scope).
Existing coverage: 20 Playwright e2e specs, 9 SDK→API integration tests, ~8 frontend unit tests, 1 backend unit test. Money/date/sort/mapping logic is largely uncovered.
4. Guardrails
- Silent-breakage first — test logic that produces a wrong-but-plausible result without throwing. Not coverage numbers.
- Behavior, not implementation — assert return values / rendered strings / emitted events. No asserting private state or "was called".
- Runner AND build must be green — every file passes both
pnpm --filter @hereby/hr-system exec vitest run <file>andpnpm --filter @hereby/hr-system build(tsc). Vitest does not type-check. - cellRenderers are
(row, col, rowIdx) => string— always call with the full 3-arg signature in adapter tests, or the build breaks even when the runner is green. - Real enums — adapter label maps must be asserted against the real
@hereby/coreenums (OvertimeCategory,OvertimeStatus,SubsidyStatus,ResignationStatus,SettlementStatus,IncentiveCategory), not hand-rolled string maps.
5. Prerequisite refactors (behavior-preserving, land first in PR-A)
exportthe private pure fns inworkbook-adapter.ts:evalColumnFilter,pasteNormalizeIsoDate,projectRowAmount,buildColumnsFromLayout.- Consolidate date normalization — one pure helper
sheets/workbook/date-normalize.ts;pasteNormalizeIsoDateanddrafts.ts::_normalizeToIsoDatedelegate to it (removes a 3-way duplication; resolver's copy stays backend/out-of-scope but is noted). - Extract pure cores so slice methods delegate (and become directly testable):
compareByColumn(a, b, col, dir)←filters-sort.ts::sortByColumnsummaryValue(col, rows)←payroll.ts::summaryFordetectFillSeries(values)←fill-handle.ts::_detectFillSeries→fill-series.ts
Each refactor is verified by running the existing tests + build before adding new tests.
6. Deliverables — unit tests
PR-A — Money (frontend) + Domain adapters (+ E1 adapter fixes)
Tier 1 — money (frontend):
| File | Target | Key cases |
|---|---|---|
sheets/workbook-adapter.filter.test.ts | evalColumnFilter | 11 operators × amount/date/string coercion; between boundaries; isEmpty/isNotEmpty; null/undefined per operator |
sheets/workbook/date-normalize.test.ts | consolidated date helper | yyyy-mm-dd, yyyy/m/d, m/d/yyyy (auto-flip >12), m/d/yy pivot (00–69→2000s, 70–99→1900s); zero-pad; invalid (Feb 30, month 13) |
sheets/workbook-adapter.amount.test.ts | projectRowAmount + subtotal gen | overtime/deduction hook vs row.amount fallback; NaN; multi-config subtotal sums; >=2 threshold |
sheets/workbook/filters-sort.test.ts | compareByColumn | asc/desc sign; null-last; Korean locale (가/나/다); 'abc'→NaN coercion |
sheets/workbook/payroll.test.ts | summaryValue, isPayrollLedger, aggregateSourceLabel | per-column sum/count/distinct rule; subtotal-row exclusion; thousands format; ledger 3-way heuristic |
sheets/workbook-adapter.columns.test.ts | buildColumnsFromLayout | format/kind override; render heuristic; width default; identity field path |
Tier 2 — domain adapters (5). Each file asserts: buildDefaultColumns field bindings, cellRenderers (3-arg), searchPredicate, label maps vs real @hereby/core enums.
sheets/overtime.adapter.test.ts(extend batch1's),education.adapter.test.ts,resignation.adapter.test.ts,sports.adapter.test.ts,yearend.adapter.test.ts.
E1 fixes shipped in PR-A (with the tests above proving correctness):
- overtime:
CAT_LABEL(weekend→realsaturday; addHOLIDAY_NIGHT);STATUS_LABEL(addrejected/report_*; drop fictionaldraft/paid); fix시급/할증률/수당액bound to non-existenthourlyRate/multiplier/calculatedPay. - yearend:
STATUS_LABELaddcalculated/applied. - sports:
sdk.sportsIncentive.listdoes not exist (onlyfixedIncentive);TYPE_LABELvs realIncentiveCategory. Repoint to the real service or mark clearly if the tab is dead; fix under aFIXME(타입 복구 중 발견)if the server proc is missing. - education/resignation: date field typed
stringbut SDK returnsDate— fix typing. - Prefer removing
type Sdk = anywhere it hid these; keep SDK live types.
PR-B — Interaction + Engine (frontend)
Tier 3 — interaction:
| File | Target | Key cases |
|---|---|---|
sheets/workbook/fill-series.test.ts | detectFillSeries | numeric step; day-by-day date; single value; non-constant step; comma numbers; null |
sheets/workbook/import-export.test.ts | _parseTsvImport, csv escape | explicit 0 unitCount (bug: coerced to 1); non-numeric totalAmount; RFC-4180 quoting |
sheets/workbook/cell-edit.test.ts | _isCellEditableForRow | column-kind gate; legacy field heuristics (shared by bulk-edit) |
sheets/workbook/clipboard.test.ts | _resolveEmployeeForPaste | employeeNumber exact → name case-insensitive fallback |
sheets/workbook/treatment.test.ts | transposedRows/matrixRows, session split | per-employee subtotal; matrix grid; Math.round therapist split; latest-date tie-break |
Tier 4 — engine:
| File | Target | Key cases |
|---|---|---|
sheet/virtualizer.test.ts (new) | computeWindow, shouldVirtualize, scrollOffsetForRow | first/last off-by-one; spacer heights; threshold=500 boundary; totalRows<=0 |
sheet/render.test.ts (new) | buildColumnStyleCache, defaultRenderers, formatAmount | frozen-left cumulative math; 1.5h→'2' rounding regression; formula-null N/A vs 0; escapeHtml present |
sheet/selection.test.ts (extend) | extendToRange, selectionToTsv, selectionRect | range math; TSV shape |
sheet/engine.test.ts (new, selective) | moveSelection, fillSelection | edge clamp; axis (down vs right); no-op dedup preserves undo |
7. Deliverables — Playwright e2e (PR-C, packages/test/e2e/tests/21+)
Critical journeys only (avoid the ice-cream-cone). Seed via API, isolate per spec, clean up via deleteSheetsByNamePrefix, prefer waitForResponse over timeouts.
21-payroll-confirm-cancel— 급여대장 row → 💰 급여 확정 → breakdown modal → confirm → locked → 🔓 확정 취소.22-filter-sort— column filter rule → live filter → header sort → persists across reload.23-formula-commit-amount— formula column → runCompute → commit → assert rendered amount against a hand-computed value (transitively covers backend rounding/eval).24-paste-fill— paste a TSV block → fill-down.25-carry-forward-apply— spec 14 covers preview/empty; add the apply path.26-export— eCount XLSX / CSV download assertion.
8. Handoff report (backend — NOT fixed in this batch)
Multi-tenancy leak. resolver.ts::loadSourceRecords filters by organizationId for only e_sign_records; the other 14 source kinds (allowance/deduction/overtime/ fixed_incentive/leave/resignation/attendance/offsite/ward_daily/salary_grade/ grade_promotion/incentive_preset/employment_contract/year_end_settlement) are fetched by bare id = ANY($1). sheet-cell.router.ts::set and sheet-row.router.ts::create/ batchCreate accept client-supplied ref*RecordId without ownership validation → an org-A admin with an org-B record UUID can render org-B amounts into an org-A sheet. resolver.test.ts already pins this for e_sign only. Recommended fix (separate security PR): validate ref ownership at write-time in the routers AND add organizationId to all 14 read-time loaders; extend resolver.test.ts one case per kind.
9. Verification
- Per file:
pnpm --filter @hereby/hr-system exec vitest run <file>+pnpm --filter @hereby/hr-system build. - Batch close:
pnpm lint:check && pnpm build. - e2e:
make db-start && make db-seed, thenpnpm test:e2e(Playwright auto-launches API + hr-system).
10. Sequencing
PR-A (refactors §5 + Tier 1 + Tier 2 + E1 fixes) → PR-B (Tier 3 + Tier 4) → PR-C (e2e 21–26). Multi-tenancy = separate security PR by backend owner.
11. Open risks
- Some interaction helpers read
this; if a pure core cannot be cleanly extracted, test by instantiating the slice with a minimal stubbedthis(documented per file). - e2e #23's hand-computed amount must use seeded contract values; if backend rounding is wrong, the spec fails → that is a backend handoff, not a spec bug.
- sports adapter may be dead code (no real SDK service); confirm before "fixing" vs removing the tab.