근태 현황 단위 테스트 (PR-U) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: admin·team-manager의 근태 현황 로직에 characterization 기반 단위 테스트를 도입하고, 테스트 불가한 고위험 인라인 로직을 helpers 모듈로 점진 추출한다.
Architecture: 스펙 docs/superpowers/specs/2026-07-03-attendance-frontend-tests-design.md §4의 Track U. 절차는 항상 「① 인스턴스(characterization)로 현행 고정 → ② 순수 함수 추출(의존성 인자 주입) → ③ 같은 테스트 green 유지 + 추출 함수 직접 엣지 테스트 추가」. Alpine 컴포넌트의 공개 메서드 이름(HTML 바인딩 표면)은 절대 바꾸지 않는다 — app.ts에는 위임 호출만 남는다.
Tech Stack: Vitest + happy-dom (양 앱 vitest.config.ts 기구성), TypeScript strict, Biome.
Global Constraints
- 모든 태스크 완료 기준은 러너와 빌드 둘 다 green:
pnpm --filter @hereby/admin exec vitest run <file>(또는 tm) 그리고pnpm --filter @hereby/admin build(=tsc --noEmit && vite build). Vitest는 타입체크를 안 한다 (레포 규칙). - Characterization 테스트에는 반드시 이 주석을 단다:
// characterization: locks current behavior, not proven-correct output - 테스트 안에 for/if 금지 — 케이스는
it.each/describe.each로 전개 (no-logic-in-tests). - 테스트 이름은 영어
it("should …"), 한국어 도메인 명사는 그대로 사용 가능. 설명 주석은 한국어 허용 (선례:scheduler-helpers.test.ts). - 시간 의존 테스트는
vi.useFakeTimers()+vi.setSystemTime()으로 고정하고afterEach에서vi.useRealTimers(). 날짜는 kr-holidays 지원 범위(2024–2027) 안에서만 고른다. - 절대
app.init()을 호출하지 않는다 (네트워크·store 폴링). 테스트 대상 메서드가 내부에서 로더를 부르면 그 로더만 인스턴스에서 no-op으로 교체한다 (예:app.loadEmployeeAttendance = async () => {};). - 발견된 버그는 고치지 않는다 — characterization으로 고정하고 KI 번호를 주석에 남긴다 (스펙 §2 결정).
- enum은
@hereby/core에서 import (LeaveStatus,LeaveType,OffsiteWorkStatus,OvertimeStatus,OvertimeCategory). - Biome 포맷: 2-space, double quotes, 세미콜론, 100자 폭. 커밋 전
pnpm lint:check(필요 시pnpm lint:fix). - 커밋 메시지: Conventional Commits 영어 제목 + 한국어 본문(무엇을·왜). 각 태스크 끝마다 커밋 (작은 diff 유지 — admin app.ts는 활성 파일).
File Structure
apps/admin/src/
test-support/harness.ts Task 1 store 스텁 + createAdminApp()
test-support/harness.test.ts Task 1 스모크
attendance/edit-hours.ts(.test.ts) Task 2 근태 수정 재계산·합계
attendance/secom-match.ts(.test) Task 3 지문 ±5분 대조
attendance/date-preset.ts(.test) Task 4 기간 프리셋 (tm과 동일 사본)
app-attendance-labels.test.ts Task 5 enum→라벨 전수 (인스턴스)
attendance/offsite.ts(.test) Task 7+11 일괄결재 step 규칙 + byDate 매핑
attendance/month-nav.ts(.test) Task 9 월 범위/이동
app-attendance-misc.test.ts Task 9 pageWindow·연차 통계 (인스턴스)
attendance/display.ts(.test) Task 10 날짜 라벨·(+N)·red·수정 diff
attendance/embed-url.ts(.test) Task 11 iframe URL 생성
attendance/forms.ts Task 12 select boolean 강제 변환
app-shift-form.test.ts Task 12 KI-1 characterization
app-embed-contract.test.ts Task 14 postMessage/URL 리터럴 계약
apps/team-manager/src/
test-support/harness.ts(.test.ts) Task 1
attendance/date-preset.ts(.test) Task 4 admin과 동일 사본
attendance/date-preset-sync.test.ts Task 4 양앱 동기화 가드
app-attendance-labels.test.ts Task 6 enum→라벨 전수 (인스턴스)
attendance/approval-steps.ts(.test) Task 8 일괄결재 step 규칙 (연장·외근 공용)
attendance/pagination.ts(.test) Task 13 "N-M건" 라벨·totalPages
app-helpers.test.ts (보강) Task 13 URL/모드 파싱·dashRange·formatKRW
app-embed-contract.test.ts Task 14 URL 파라미터 계약 (tm측)
views/tabs/overview.html (수정) Task 13 인라인 수식 → 헬퍼 호출
views/tabs/overtime.html (수정) Task 13 〃app.ts 위임 수정은 각 태스크에 명시. 두 app.ts 외 프로덕션 파일 수정은 Task 13의 뷰 2개가 전부다.
스펙 대비 이행 편차 (승인 필요 없음, 기록용):
- §4.3 "postMessage 리터럴 5종을 양쪽 앱에서 고정" — tm의 송신부(
_notifyEmbedNav,goToFull,_notifyDocApprovalChanged)는 전부window.parent !== window가드가 있어 happy-dom(parent===window)에서 도달 불가, 수신부(back)는init()embedded 분기 안에서 바인딩된다. 따라서 5종 리터럴 pinning은 admin 계약 파일에서 전담(수신 4종 dispatch + 송신 back)하고, tm측 계약 파일은 URL 파라미터 계약을 전담한다. tm측 postMessage 배선은 E2E PR-E2(E2-3)가 커버. - §4.1 #12 "isActive 변환 :5769·:5904" — 표현식이 sdk 호출 페이로드 안에 인라인이라,
coerceSelectBoolean순수 함수로 추출해 위임 (Task 12).
Task 0: 브랜치 준비 + 문서 이월
Files: 없음 (git 조작만)
- [ ] Step 1: develop에서 분기하고 스펙·계획 문서를 가져온다
스펙/계획 문서는 현재 test/batch2-sheet 브랜치에만 있다. develop에는 없으므로 파일 단위로 가져온다 (cherry-pick 아님 — batch2-sheet의 무관한 커밋 유입 방지).
git checkout develop && git pull origin develop
git checkout -b test/attendance-unit
git checkout test/batch2-sheet -- \
docs/superpowers/specs/2026-07-03-attendance-frontend-tests-design.md \
docs/superpowers/plans/2026-07-06-attendance-unit-tests.md
# checkout -- <paths>가 index에 이미 스테이징함 — git add docs/ 금지(untracked esign 문서 유입 방지)
git commit -m "docs: carry attendance test spec and PR-U plan onto working branch
근태 현황 테스트 스펙(§4 Track U)과 구현 계획을 작업 브랜치로 이월.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"- [ ] Step 2: 베이스라인 확인
pnpm --filter @hereby/admin test && pnpm --filter @hereby/team-manager testExpected: 기존 테스트 전부 PASS (admin: kr-holidays, scheduler-helpers / tm: app-helpers, kr-holidays-sync). 실패 시 중단하고 원인 기록 — 이후 실패가 회귀인지 판별 불가해진다.
Task 1: 인스턴스 테스트 하네스 (store 스텁 + 팩토리 스모크)
두 팩토리 모두 객체 리터럴 프로퍼티 sdk: Alpine.store("hereby").sdk(admin app.ts:119 / tm app.ts:779)를 호출 시점에 즉시 평가한다. 스텁 없이 팩토리를 부르면 Cannot read properties of undefined (reading 'sdk')로 죽는다. 이 태스크가 모든 인스턴스 태스크의 선행 조건이다.
Files:
- Create:
apps/admin/src/test-support/harness.ts - Create:
apps/admin/src/test-support/harness.test.ts - Create:
apps/team-manager/src/test-support/harness.ts - Create:
apps/team-manager/src/test-support/harness.test.ts
Interfaces:
Produces:
createAdminApp(): ReturnType<typeof adminAppComponent>/createTmApp(): ReturnType<typeof appComponent>— store 스텁 등록 후 팩토리 호출.herebyStoreStub(): Stores["hereby"]— org tz Asia/Seoul 고정(현행 KI-5formatTimeOnly하드코딩과 일관),t(key)=key(i18n 키 자체가 기대값이 된다),sdk={}.이후 모든 인스턴스 태스크가 이 두 함수를 consume한다.
[ ] Step 1: admin 하네스 작성
// apps/admin/src/test-support/harness.ts
import Alpine from "alpinejs";
import type { Stores } from "alpinejs";
import { adminAppComponent } from "../app";
type HerebyStore = Stores["hereby"];
/**
* 인스턴스 테스트용 최소 hereby store 스텁.
* - org 타임존은 Asia/Seoul 고정 — 컴포넌트의 formatTimeOnly가 이미 Seoul을
* 하드코딩(KI-5)하므로 characterization 기준을 그와 일치시킨다.
* - t()는 키를 그대로 돌려준다 → i18n 키 문자열이 곧 기대값.
* - sdk는 빈 객체 — 네트워크 메서드를 부르는 테스트는 금지(호출 시 명시적 크래시가 낫다).
*/
export function herebyStoreStub(): HerebyStore {
const tz = "Asia/Seoul";
const iso = (d: Date | string) =>
new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date(d));
const stub = {
sdk: {},
authenticated: true,
requiresPasswordChange: false,
timezone: tz,
locale: "ko-KR",
currentUser: null,
isLoading: false,
hasPermission: () => true,
todayLocal: () => iso(new Date()),
formatDateISO: iso,
formatDate: iso,
formatDateTime: (d: Date | string) => iso(d),
t: (key: string) => key,
tEnum: (_enumName: string, value: string) => value,
};
return stub as unknown as HerebyStore;
}
/** store 스텁을 등록한 뒤 admin Alpine 컴포넌트 인스턴스를 만든다. init()은 호출하지 않는다. */
export function createAdminApp() {
Alpine.store("hereby", herebyStoreStub());
return adminAppComponent();
}tm은 동일 구조로 팩토리만 다르다:
// apps/team-manager/src/test-support/harness.ts
import Alpine from "alpinejs";
import type { Stores } from "alpinejs";
import { appComponent } from "../app";
type HerebyStore = Stores["hereby"];
/** admin 하네스와 동일 스텁 (Asia/Seoul, t=identity, sdk={}). */
export function herebyStoreStub(): HerebyStore {
const tz = "Asia/Seoul";
const iso = (d: Date | string) =>
new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date(d));
const stub = {
sdk: {},
authenticated: true,
requiresPasswordChange: false,
timezone: tz,
locale: "ko-KR",
currentUser: null,
isLoading: false,
hasPermission: () => true,
todayLocal: () => iso(new Date()),
formatDateISO: iso,
formatDate: iso,
formatDateTime: (d: Date | string) => iso(d),
t: (key: string) => key,
tEnum: (_enumName: string, value: string) => value,
};
return stub as unknown as HerebyStore;
}
/** store 스텁 등록 후 team-manager 컴포넌트 인스턴스 생성. init() 호출 금지. */
export function createTmApp() {
Alpine.store("hereby", herebyStoreStub());
return appComponent();
}- [ ] Step 2: 스모크 테스트 작성
// apps/admin/src/test-support/harness.test.ts
import { describe, expect, it } from "vitest";
import { createAdminApp } from "./harness";
describe("createAdminApp (instance harness)", () => {
it("should build the component object with the store stub satisfying the sdk read", () => {
const app = createAdminApp();
expect(app.activeTab).toBe("employees");
expect(typeof app.pageWindow).toBe("function");
expect(typeof app.attnEditRecalcFromTimes).toBe("function");
});
});// apps/team-manager/src/test-support/harness.test.ts
import { describe, expect, it } from "vitest";
import { createTmApp } from "./harness";
describe("createTmApp (instance harness)", () => {
it("should build the component object with the store stub satisfying the sdk read", () => {
const app = createTmApp();
// happy-dom 기본 URL(pathname "/") → initialTabFromUrl()이 overview 폴백
expect(app.activeTab).toBe("overview");
expect(typeof app.pageRange).toBe("function");
});
});- [ ] Step 3: 실행 — admin 쪽 import 크래시 여부가 관건
pnpm --filter @hereby/admin exec vitest run src/test-support/harness.test.ts
pnpm --filter @hereby/team-manager exec vitest run src/test-support/harness.test.tsExpected: PASS. admin app.ts는 top-level에서 TinyMCE 코어+플러그인+CSS를 import한다(utils/tinymce.ts:2-12) — happy-dom에서 크래시하면 아래 fallback을 적용:
fallback (TinyMCE import 크래시 시에만): apps/admin/vitest.config.ts에 setup 파일을 등록하고 TinyMCE 래퍼를 모듈 경계에서 mock한다 (3rd-party SDK는 mock 허용 대상).
// apps/admin/src/test-support/setup.ts (fallback 시에만 생성)
import { vi } from "vitest";
// TinyMCE는 happy-dom에서 로드 불가(사유를 관찰된 에러로 교체) — 모듈 경계 mock.
vi.mock("../utils/tinymce", () => ({
initTinyMCE: async () => {},
destroyTinyMCE: () => {},
getContent: () => "",
setContent: () => {},
insertAtCursor: () => {},
}));// apps/admin/vitest.config.ts 의 test 블록에 추가 (fallback 시에만)
setupFiles: ["./src/test-support/setup.ts"],- [ ] Step 4: 빌드 검증
pnpm --filter @hereby/admin build && pnpm --filter @hereby/team-manager buildExpected: 둘 다 성공.
- [ ] Step 5: Commit
git add apps/admin/src/test-support apps/team-manager/src/test-support
git commit -m "test(attendance): add instance-test harness with hereby store stub
두 앱 팩토리가 호출 시점에 Alpine.store(\"hereby\").sdk를 평가하므로,
인스턴스 테스트 전 등록할 최소 store 스텁과 createAdminApp/createTmApp
하네스를 추가. org tz는 Asia/Seoul 고정(KI-5 현행과 일치), t()는 키 반환.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 2: admin 근태 수정 시간 재계산 + 합계 검증 추출
대상: attnEditRecalcFromTimes(app.ts:2784-2825), attnEditSum(:2737), attnEditExpectedHours(:2752), attnEditMismatch(:2768). 한 달 새 3회 재작성된 최다 churn — 야간 22–06 경계·익일 퇴근·휴게 주간 우선 차감이 급여 원천이다.
Files:
- Create:
apps/admin/src/attendance/edit-hours.ts - Create:
apps/admin/src/attendance/edit-hours.test.ts - Modify:
apps/admin/src/app.ts:2737-2825(위임으로 교체)
Interfaces:
Produces:
editHoursSum(hours: Record<string, number>): numberexpectedHoursFromTimes(checkIn: string, checkOut: string, breakHours: number): number | nullscheduledSplitFromTimes(checkIn: string, checkOut: string, breakHours: number, isHoliday: boolean): { regular: number; night: number; holidayRegular: number; holidayNight: number } | null
[ ] Step 1: characterization 테스트 작성 (인스턴스 대상)
// apps/admin/src/attendance/edit-hours.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { createAdminApp } from "../test-support/harness";
type AdminApp = ReturnType<typeof createAdminApp>;
function withForm(app: AdminApp, checkIn: string, checkOut: string, breakH: number) {
app.attendanceEditForm.checkIn = checkIn;
app.attendanceEditForm.checkOut = checkOut;
app.attendanceEditForm.hours.break = breakH;
}
describe("attnEditRecalcFromTimes", () => {
// characterization: locks current behavior, not proven-correct output
let app: AdminApp;
beforeEach(() => {
app = createAdminApp();
app.attendanceEditRecord = { isHoliday: false };
});
it("should compute regular 8h for a 09:00-18:00 day with 1h break", () => {
withForm(app, "09:00", "18:00", 1);
app.attnEditRecalcFromTimes();
expect(app.attendanceEditForm.hours.regular).toBe(8);
expect(app.attendanceEditForm.hours.night).toBe(0);
expect(app.attendanceEditForm.hours.holidayRegular).toBe(0);
expect(app.attendanceEditForm.hours.holidayNight).toBe(0);
});
it("should keep the 4 overtime fields untouched (소정 4구분만 재계산)", () => {
withForm(app, "09:00", "18:00", 1);
app.attendanceEditForm.hours.overtime = 1.5;
app.attendanceEditForm.hours.nightOvertime = 0.5;
app.attendanceEditForm.hours.holidayOvertime = 2;
app.attendanceEditForm.hours.holidayNightOvertime = 0.3;
app.attnEditRecalcFromTimes();
expect(app.attendanceEditForm.hours.overtime).toBe(1.5);
expect(app.attendanceEditForm.hours.nightOvertime).toBe(0.5);
expect(app.attendanceEditForm.hours.holidayOvertime).toBe(2);
expect(app.attendanceEditForm.hours.holidayNightOvertime).toBe(0.3);
});
it("should split an overnight 21:00-06:00 shift into 1h-break day and 8h night", () => {
// 21:00-22:00 주간 60분(휴게 60분 차감 → 0), 22:00-06:00 야간 480분
withForm(app, "21:00", "06:00", 1);
app.attnEditRecalcFromTimes();
expect(app.attendanceEditForm.hours.regular).toBe(0);
expect(app.attendanceEditForm.hours.night).toBe(8);
});
it("should route hours into holiday fields and zero weekday fields on a holiday", () => {
app.attendanceEditRecord = { isHoliday: true };
withForm(app, "09:00", "18:00", 1);
app.attendanceEditForm.hours.regular = 3; // 사전값 — 0으로 밀리는지 확인
app.attnEditRecalcFromTimes();
expect(app.attendanceEditForm.hours.holidayRegular).toBe(8);
expect(app.attendanceEditForm.hours.holidayNight).toBe(0);
expect(app.attendanceEditForm.hours.regular).toBe(0);
expect(app.attendanceEditForm.hours.night).toBe(0);
});
it("should clamp to 0 when break exceeds worked minutes", () => {
withForm(app, "10:00", "12:00", 3);
app.attnEditRecalcFromTimes();
expect(app.attendanceEditForm.hours.regular).toBe(0);
expect(app.attendanceEditForm.hours.night).toBe(0);
});
it("should do nothing when checkOut is empty", () => {
withForm(app, "09:00", "", 1);
app.attendanceEditForm.hours.regular = 5;
app.attnEditRecalcFromTimes();
expect(app.attendanceEditForm.hours.regular).toBe(5);
});
});
describe("attnEditSum / attnEditExpectedHours / attnEditMismatch", () => {
// characterization: locks current behavior, not proven-correct output
let app: AdminApp;
beforeEach(() => {
app = createAdminApp();
});
it("should sum the 8 fields excluding break, rounded to 0.1", () => {
Object.assign(app.attendanceEditForm.hours, {
regular: 7.7,
overtime: 1.1,
night: 0.1,
break: 99, // 합계에서 제외
});
expect(app.attnEditSum()).toBe(8.9);
});
it("should return null expected hours when a time is missing", () => {
withForm(app, "", "18:00", 0);
expect(app.attnEditExpectedHours()).toBeNull();
});
it("should treat identical check-in/out as a full 24h day", () => {
// characterization: locks current behavior, not proven-correct output
// mins<=0 → +24h 규칙이라 09:00-09:00 은 24시간으로 계산된다.
withForm(app, "09:00", "09:00", 0);
expect(app.attnEditExpectedHours()).toBe(24);
});
it("should compute overnight expected hours across midnight", () => {
withForm(app, "22:00", "06:00", 0);
expect(app.attnEditExpectedHours()).toBe(8);
});
it("should not flag a mismatch at exactly 0.1h difference", () => {
withForm(app, "09:00", "18:00", 1); // expected 8.0
Object.assign(app.attendanceEditForm.hours, { regular: 8.1 });
expect(app.attnEditMismatch()).toBe(false);
});
it("should flag a mismatch above 0.1h difference", () => {
withForm(app, "09:00", "18:00", 1); // expected 8.0
Object.assign(app.attendanceEditForm.hours, { regular: 7.8 });
expect(app.attnEditMismatch()).toBe(true);
});
});- [ ] Step 2: 실행 — 현행 고정 확인
pnpm --filter @hereby/admin exec vitest run src/attendance/edit-hours.test.tsExpected: PASS (characterization은 현행이 스펙). 실패하는 케이스가 있으면 기대값을 관찰된 실제 출력으로 수정하고 그 케이스에 발견 사실을 주석으로 남긴다 — 코드를 고치지 않는다.
- [ ] Step 3: 순수 함수 추출
// apps/admin/src/attendance/edit-hours.ts
/**
* 근태 수정 모달의 시간 재계산·합계 검증 — app.ts에서 추출한 순수 함수.
* 소정 4구분(일반/야간/휴일일반/휴일야간)만 계산하고 초과 4종은 건드리지 않는다.
*/
/** 8개 구분 시간의 합(휴게 제외, 소수점 1자리). */
export function editHoursSum(h: Record<string, number>): number {
const sum =
(h.regular || 0) +
(h.overtime || 0) +
(h.night || 0) +
(h.nightOvertime || 0) +
(h.holidayRegular || 0) +
(h.holidayOvertime || 0) +
(h.holidayNight || 0) +
(h.holidayNightOvertime || 0);
return Math.round(sum * 10) / 10;
}
/** 출퇴근 시간으로부터 기대 근무시간(휴게 제외). 미입력이면 null. 동시각은 24h(익일 규칙). */
export function expectedHoursFromTimes(
checkIn: string,
checkOut: string,
breakHours: number,
): number | null {
if (!(checkIn && checkOut)) {
return null;
}
const [sh, sm] = checkIn.split(":").map(Number);
const [eh, em] = checkOut.split(":").map(Number);
let mins = eh * 60 + em - (sh * 60 + sm);
if (mins <= 0) {
mins += 24 * 60; // 익일 퇴근
}
const net = mins / 60 - (breakHours || 0);
return Math.round(Math.max(0, net) * 10) / 10;
}
/** 소정 4구분 자동 계산 — 야간 22~06, 휴게는 주간 우선 차감. 미입력이면 null. */
export function scheduledSplitFromTimes(
checkIn: string,
checkOut: string,
breakHours: number,
isHoliday: boolean,
): { regular: number; night: number; holidayRegular: number; holidayNight: number } | null {
if (!(checkIn && checkOut)) {
return null;
}
const [sh, sm] = checkIn.split(":").map(Number);
const [eh, em] = checkOut.split(":").map(Number);
const start = sh * 60 + sm;
let end = eh * 60 + em;
if (end <= start) {
end += 24 * 60; // 익일 퇴근
}
let nightMin = 0;
for (let m = start; m < end; m++) {
const hod = Math.floor(m / 60) % 24;
if (hod >= 22 || hod < 6) {
nightMin++;
}
}
const totalMin = end - start;
const dayMin = totalMin - nightMin;
let breakMin = (breakHours || 0) * 60;
const dayNet = Math.max(0, dayMin - breakMin);
breakMin = Math.max(0, breakMin - dayMin);
const nightNet = Math.max(0, nightMin - breakMin);
const r1 = (n: number) => Math.round((n / 60) * 10) / 10;
if (isHoliday) {
return { regular: 0, night: 0, holidayRegular: r1(dayNet), holidayNight: r1(nightNet) };
}
return { regular: r1(dayNet), night: r1(nightNet), holidayRegular: 0, holidayNight: 0 };
}app.ts 위임 (원본 4개 메서드 본문을 다음으로 교체 — docstring 주석은 유지):
attnEditSum(): number {
return editHoursSum(this.attendanceEditForm.hours);
},
attnEditExpectedHours(): number | null {
const f = this.attendanceEditForm;
return expectedHoursFromTimes(f.checkIn, f.checkOut, f.hours.break || 0);
},
attnEditMismatch(): boolean {
const expected = this.attnEditExpectedHours();
if (expected === null) {
return false;
}
return Math.abs(this.attnEditSum() - expected) > 0.1;
},(attnEditCanSave는 그대로 둔다.)
attnEditRecalcFromTimes() {
const f = this.attendanceEditForm;
const split = scheduledSplitFromTimes(
f.checkIn,
f.checkOut,
f.hours.break || 0,
Boolean(this.attendanceEditRecord?.isHoliday),
);
if (!split) {
return;
}
Object.assign(f.hours, split);
},app.ts 상단 import 추가:
import {
editHoursSum,
expectedHoursFromTimes,
scheduledSplitFromTimes,
} from "./attendance/edit-hours";- [ ] Step 4: 추출 함수 직접 엣지 테스트 추가 (같은 테스트 파일 하단)
import { editHoursSum, expectedHoursFromTimes, scheduledSplitFromTimes } from "./edit-hours";
describe("scheduledSplitFromTimes (extracted)", () => {
it.each([
// [in, out, break, holiday, regular, night, holidayRegular, holidayNight]
["09:00", "18:00", 1, false, 8, 0, 0, 0],
["21:00", "06:00", 1, false, 0, 8, 0, 0],
["22:00", "06:00", 0, false, 0, 8, 0, 0],
["05:00", "07:00", 0, false, 1, 1, 0, 0], // 06시 경계 양쪽 1h씩
["09:00", "18:00", 1, true, 0, 0, 8, 0],
["10:00", "12:00", 3, false, 0, 0, 0, 0], // 휴게 > 근무
])(
"should split %s-%s break %sh holiday=%s into (%s, %s, %s, %s)",
(cin, cout, brk, holiday, regular, night, hReg, hNight) => {
expect(scheduledSplitFromTimes(cin, cout, brk, holiday)).toEqual({
regular,
night,
holidayRegular: hReg,
holidayNight: hNight,
});
},
);
it("should return null when a time is missing", () => {
expect(scheduledSplitFromTimes("", "18:00", 1, false)).toBeNull();
});
});
describe("editHoursSum / expectedHoursFromTimes (extracted)", () => {
it("should survive float accumulation (0.1+0.2)", () => {
expect(editHoursSum({ regular: 0.1, overtime: 0.2 })).toBe(0.3);
});
it("should subtract break from the overnight span", () => {
expect(expectedHoursFromTimes("22:00", "06:00", 1)).toBe(7);
});
});- [ ] Step 5: 검증
pnpm --filter @hereby/admin exec vitest run src/attendance/edit-hours.test.ts \
&& pnpm --filter @hereby/admin buildExpected: 전체 PASS + 빌드 성공. characterization 블록이 추출 후에도 green = 위임이 동작 보존을 증명.
- [ ] Step 6: Commit
git add apps/admin/src/attendance/edit-hours.ts apps/admin/src/attendance/edit-hours.test.ts apps/admin/src/app.ts
git commit -m "test(admin): extract attendance edit-hours calc with characterization tests
근태 수정 모달의 소정 4구분 재계산·합계 검증을 attendance/edit-hours.ts로
추출. 인스턴스 characterization으로 현행(야간 22-06, 휴게 주간 우선 차감,
동시각=24h, 초과 4종 불변)을 먼저 고정한 뒤 위임으로 교체.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 3: admin 지문 ±5분 대조 추출
대상: _localMinutes(:2547), _secomMinutes(:2563), secomMatchEvent(:2576), secomCheckoutGuess(:2657). 자정 경계·nextDay 판이 틀리면 지문 경고 오탐/미탐 — 관리자 오도.
Files:
- Create:
apps/admin/src/attendance/secom-match.ts - Create:
apps/admin/src/attendance/secom-match.test.ts - Modify:
apps/admin/src/app.ts:2547-2709(위임)
Interfaces:
Produces:
SECOM_MATCH_MINUTES = 5interface SecomDeps { formatDateISO(value: string): string; formatTimeOnly(value: string): string }localMinutes(target: unknown, deps: SecomDeps): number | nullsecomMinutes(dt: string): number | nullmatchSecomEvent(record: Record<string, unknown>, which: "in" | "out", events: Record<string, unknown>[], deps: SecomDeps): Record<string, unknown> | nullcheckoutGuess(record: Record<string, unknown>, events: Record<string, unknown>[], deps: SecomDeps): Record<string, unknown> | null(attnMethod 게이트는 컴포넌트에 잔류)
[ ] Step 1: characterization 테스트 (인스턴스)
// apps/admin/src/attendance/secom-match.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { createAdminApp } from "../test-support/harness";
type AdminApp = ReturnType<typeof createAdminApp>;
// UTC 저장값 ↔ Seoul 로컬: "2026-06-23T22:00:00.000Z" = 6/24 07:00 KST
const IN_UTC = "2026-06-23T22:00:00.000Z";
// "2026-06-24T15:02:00.000Z" = 6/25 00:02 KST — 익일 퇴근 케이스
const OUT_UTC_NEXT_DAY = "2026-06-24T15:02:00.000Z";
describe("secomMatchEvent", () => {
// characterization: locks current behavior, not proven-correct output
let app: AdminApp;
beforeEach(() => {
app = createAdminApp();
app.attendanceEmployee = { attendanceMethod: "secom" };
});
it("should match a check_in fingerprint within 5 minutes", () => {
app.secomEvents = [{ eventType: "check_in", eventDatetime: "20260624070300" }];
const hit = app.secomMatchEvent({ date: "2026-06-24", actualCheckIn: IN_UTC }, "in");
expect(hit?.eventDatetime).toBe("20260624070300");
});
it("should match at exactly 5 minutes and reject at 6", () => {
app.secomEvents = [{ eventType: "check_in", eventDatetime: "20260624070500" }];
expect(app.secomMatchEvent({ date: "2026-06-24", actualCheckIn: IN_UTC }, "in")).not.toBeNull();
app.secomEvents = [{ eventType: "check_in", eventDatetime: "20260624070600" }];
expect(app.secomMatchEvent({ date: "2026-06-24", actualCheckIn: IN_UTC }, "in")).toBeNull();
});
it("should accept unknown event types but reject the opposite type", () => {
app.secomEvents = [{ eventType: "unknown", eventDatetime: "20260624070200" }];
expect(app.secomMatchEvent({ date: "2026-06-24", actualCheckIn: IN_UTC }, "in")).not.toBeNull();
app.secomEvents = [{ eventType: "check_out", eventDatetime: "20260624070200" }];
expect(app.secomMatchEvent({ date: "2026-06-24", actualCheckIn: IN_UTC }, "in")).toBeNull();
});
it("should match an after-midnight checkout against the next-day prefix", () => {
app.secomEvents = [{ eventType: "check_out", eventDatetime: "20260625000400" }];
const hit = app.secomMatchEvent(
{ date: "2026-06-24", actualCheckOut: OUT_UTC_NEXT_DAY },
"out",
);
expect(hit?.eventDatetime).toBe("20260625000400");
});
it("should pick the nearest event when several are in tolerance", () => {
app.secomEvents = [
{ eventType: "check_in", eventDatetime: "20260624070400" }, // diff 4
{ eventType: "check_in", eventDatetime: "20260624070100" }, // diff 1 → 승자
];
const hit = app.secomMatchEvent({ date: "2026-06-24", actualCheckIn: IN_UTC }, "in");
expect(hit?.eventDatetime).toBe("20260624070100");
});
it("should return null when the row has no target time", () => {
expect(app.secomMatchEvent({ date: "2026-06-24" }, "in")).toBeNull();
});
});
describe("secomCheckoutGuess", () => {
// characterization: locks current behavior, not proven-correct output
let app: AdminApp;
beforeEach(() => {
app = createAdminApp();
app.attendanceEmployee = { attendanceMethod: "secom" };
});
it("should suggest a checkout fingerprint near the scheduled checkout", () => {
// scheduledCheckOut "2026-06-24T09:00:00.000Z" = 6/24 18:00 KST
app.secomEvents = [{ eventType: "check_out", eventDatetime: "20260624180200" }];
const guess = app.secomCheckoutGuess({
date: "2026-06-24",
scheduledCheckOut: "2026-06-24T09:00:00.000Z",
});
expect(guess?.eventDatetime).toBe("20260624180200");
});
it("should return null when a checkout is already recorded", () => {
app.secomEvents = [{ eventType: "check_out", eventDatetime: "20260624180200" }];
const guess = app.secomCheckoutGuess({
date: "2026-06-24",
scheduledCheckOut: "2026-06-24T09:00:00.000Z",
actualCheckOut: "2026-06-24T09:10:00.000Z",
});
expect(guess).toBeNull();
});
it("should return null for non-secom employees (컴포넌트 게이트)", () => {
app.attendanceEmployee = { attendanceMethod: "app" };
app.secomEvents = [{ eventType: "check_out", eventDatetime: "20260624180200" }];
expect(
app.secomCheckoutGuess({
date: "2026-06-24",
scheduledCheckOut: "2026-06-24T09:00:00.000Z",
}),
).toBeNull();
});
});- [ ] Step 2: 실행 — PASS 확인 (characterization)
pnpm --filter @hereby/admin exec vitest run src/attendance/secom-match.test.tsExpected: PASS. (불일치 시 기대값을 관찰값으로 교정 — 코드 수정 금지.)
- [ ] Step 3: 추출
apps/admin/src/attendance/secom-match.ts — 원본 로직을 그대로 옮기되 Alpine.store("hereby").formatDateISO / this.formatTimeOnly / this.secomEvents / this.SECOM_MATCH_MINUTES를 인자·상수로 치환:
// apps/admin/src/attendance/secom-match.ts
/**
* 지문(SECOM) ±5분 대조 — app.ts에서 추출.
* eventDatetime은 Seoul 로컬 wall-clock("YYYYMMDDHHmmss"), 출퇴근 시각은 UTC 저장.
* 둘 다 org-로컬 분 단위 정수로 변환해 차이를 비교한다. 포맷터는 주입(deps).
*/
export const SECOM_MATCH_MINUTES = 5;
export interface SecomDeps {
/** UTC 값 → org-로컬 "YYYY-MM-DD" */
formatDateISO(value: string): string;
/** UTC 값 → org-로컬 "HH:MM" (24h) */
formatTimeOnly(value: string): string;
}
export function localMinutes(target: unknown, deps: SecomDeps): number | null {
if (!target) {
return null;
}
const iso = deps.formatDateISO(target as string);
const hm = deps.formatTimeOnly(target as string);
if (!(iso && hm)) {
return null;
}
const [y, mo, d] = iso.split("-").map(Number);
const [h, mi] = hm.split(":").map(Number);
return Math.floor(Date.UTC(y, mo - 1, d, h, mi) / 60000);
}
export function secomMinutes(dt: string): number | null {
if (dt.length < 12) {
return null;
}
const y = Number(dt.slice(0, 4));
const mo = Number(dt.slice(4, 6));
const d = Number(dt.slice(6, 8));
const h = Number(dt.slice(8, 10));
const mi = Number(dt.slice(10, 12));
return Math.floor(Date.UTC(y, mo - 1, d, h, mi) / 60000);
}
function nearestEvent(
events: Record<string, unknown>[],
targetMin: number,
compact: string,
nextDay: string,
allowNextDay: boolean,
wantType: string,
): Record<string, unknown> | null {
let best: Record<string, unknown> | null = null;
let bestDiff = Number.POSITIVE_INFINITY;
for (const ev of events) {
const type = String(ev.eventType || "unknown");
if (type !== wantType && type !== "unknown") {
continue;
}
const dt = String(ev.eventDatetime || "");
if (!(dt.startsWith(compact) || (allowNextDay && dt.startsWith(nextDay)))) {
continue;
}
const em = secomMinutes(dt);
if (em === null) {
continue;
}
const diff = Math.abs(em - targetMin);
if (diff <= SECOM_MATCH_MINUTES && diff < bestDiff) {
best = ev;
bestDiff = diff;
}
}
return best;
}
function dayPrefixes(dateIso: string): { compact: string; nextDay: string } {
const compact = dateIso.replace(/-/g, "");
const nextDay = new Date(new Date(dateIso).getTime() + 86400000)
.toISOString()
.slice(0, 10)
.replace(/-/g, "");
return { compact, nextDay };
}
/** 해당 행의 출근/퇴근 시각과 ±5분 내 지문(가장 가까운 1건). */
export function matchSecomEvent(
r: Record<string, unknown>,
which: "in" | "out",
events: Record<string, unknown>[],
deps: SecomDeps,
): Record<string, unknown> | null {
const target =
which === "in"
? (r.actualCheckIn as string) || (r.checkIn as string)
: (r.actualCheckOut as string) || (r.checkOut as string);
const tMin = localMinutes(target, deps);
if (tMin === null) {
return null;
}
const { compact, nextDay } = dayPrefixes(String(r.date || "").slice(0, 10));
const wantType = which === "in" ? "check_in" : "check_out";
return nearestEvent(events, tMin, compact, nextDay, which === "out", wantType);
}
/** 퇴근 미입력 + 소정 퇴근 ±5분 지문 — 추정 퇴근 후보. */
export function checkoutGuess(
r: Record<string, unknown>,
events: Record<string, unknown>[],
deps: SecomDeps,
): Record<string, unknown> | null {
if (r.actualCheckOut || r.checkOut) {
return null;
}
const sMin = localMinutes(r.scheduledCheckOut, deps);
if (sMin === null) {
return null;
}
const { compact, nextDay } = dayPrefixes(String(r.date || "").slice(0, 10));
return nearestEvent(events, sMin, compact, nextDay, true, "check_out");
}주의 — nearestEvent/dayPrefixes로 중복을 접는 것은 허용된 정리지만, nextDay 허용 규칙이 원본과 동일해야 한다: 원본 secomMatchEvent는 which === "out"일 때만 nextDay를 허용하고, secomCheckoutGuess는 항상 허용한다. 위 코드가 그 규칙을 보존한다 (allowNextDay 인자).
app.ts 위임:
_secomDeps() {
const store = Alpine.store("hereby");
return {
formatDateISO: (v: string) => store.formatDateISO(v),
formatTimeOnly: (v: string) => this.formatTimeOnly(v),
};
},
secomMatchEvent(
r: Record<string, unknown>,
which: "in" | "out",
): Record<string, unknown> | null {
return matchSecomEvent(r, which, this.secomEvents, this._secomDeps());
},
secomCheckoutGuess(r: Record<string, unknown>): Record<string, unknown> | null {
if (this.attnMethod() !== "secom") {
return null;
}
return checkoutGuess(r, this.secomEvents, this._secomDeps());
},_localMinutes/_secomMinutes 컴포넌트 메서드와 SECOM_MATCH_MINUTES 프로퍼티는 삭제하고, 다른 참조가 없는지 확인한다: rg -n "_localMinutes|_secomMinutes|SECOM_MATCH_MINUTES" apps/admin/src/. (secomShowMatch/secomShowNoMatch/secomMatchTip/secomCheckoutGuessTip는 위임된 secomMatchEvent/secomCheckoutGuess를 그대로 쓰므로 무수정.) import 추가: import { checkoutGuess, matchSecomEvent } from "./attendance/secom-match";
- [ ] Step 4: 추출 함수 직접 테스트 추가 (같은 파일 하단, Seoul 고정 deps)
import { localMinutes, matchSecomEvent, secomMinutes } from "./secom-match";
const seoulDeps = {
formatDateISO: (v: string) =>
new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date(v)),
formatTimeOnly: (v: string) =>
new Date(v).toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Asia/Seoul",
}),
};
describe("localMinutes / secomMinutes (extracted)", () => {
it("should convert a UTC timestamp to org-local minute integer", () => {
// 6/23 22:00Z = 6/24 07:00 KST → 지문 "20260624070000"과 같은 분값이어야 한다
expect(localMinutes(IN_UTC, seoulDeps)).toBe(secomMinutes("20260624070000"));
});
it("should return null for a malformed fingerprint stamp", () => {
expect(secomMinutes("2026062")).toBeNull();
});
});
describe("matchSecomEvent (extracted)", () => {
it("should not allow next-day prefix for check-in matching", () => {
const events = [{ eventType: "check_in", eventDatetime: "20260625070000" }];
expect(
matchSecomEvent({ date: "2026-06-24", actualCheckIn: IN_UTC }, "in", events, seoulDeps),
).toBeNull();
});
});- [ ] Step 5: 검증 + Commit
pnpm --filter @hereby/admin exec vitest run src/attendance/secom-match.test.ts \
&& pnpm --filter @hereby/admin build
git add apps/admin/src/attendance/secom-match.ts apps/admin/src/attendance/secom-match.test.ts apps/admin/src/app.ts
git commit -m "test(admin): extract secom fingerprint matching with characterization tests
지문 ±5분 대조(_localMinutes/secomMatchEvent/checkoutGuess)를
attendance/secom-match.ts로 추출, 포맷터는 deps 주입. 자정 경계·nextDay
prefix·최근접 선택·±5 경계를 characterization으로 고정.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 4: 날짜 프리셋 미러 추출 + 양앱 동기화 가드
대상: admin resolveDatePresetRange(:7006-7045) ↔ tm resolveDatePresetRange(:1455-1494) — 의도적 동일 사본. tm setDashAttDatePreset(:1513-1523)의 미래 클램프 포함. 한쪽만 고치면 두 화면의 "이번 주"가 조용히 갈라진다.
Files:
- Create:
apps/admin/src/attendance/date-preset.ts+date-preset.test.ts - Create:
apps/team-manager/src/attendance/date-preset.ts(admin과 동일 내용) - Create:
apps/team-manager/src/attendance/date-preset-sync.test.ts - Modify:
apps/admin/src/app.ts:7006-7045,apps/team-manager/src/app.ts:1455-1494(위임)
Interfaces:
Produces (양 앱 동일):
resolveDatePresetRange(preset: string, today?: Date): { from: string; to: string }—today주입 가능(기본new Date()), 테스트가 fake timer 없이 날짜를 고정할 수 있다.[ ] Step 1: 모듈 작성 (추출 + today 인자화)
// apps/admin/src/attendance/date-preset.ts (tm에 동일 사본 — date-preset-sync.test.ts가 강제)
/**
* 자주 쓰는 기간 프리셋 → { from, to } (YYYY-MM-DD).
* 로컬(브라우저=조직 tz) 기준 — toISOString()은 UTC라 KST에서 하루 밀리므로 금지.
* admin·team-manager 동일 사본 (kr-holidays와 같은 정책).
*/
export function resolveDatePresetRange(
preset: string,
today: Date = new Date(),
): { from: string; to: string } {
const pad = (n: number) => String(n).padStart(2, "0");
const toISO = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
if (preset === "thisWeek") {
const dow = today.getDay();
const monday = new Date(today);
monday.setDate(today.getDate() - (dow === 0 ? 6 : dow - 1));
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
return { from: toISO(monday), to: toISO(sunday) };
}
if (preset === "lastWeek") {
const lastWeekEnd = new Date(today);
lastWeekEnd.setDate(today.getDate() - 7);
const dow = lastWeekEnd.getDay();
const monday = new Date(lastWeekEnd);
monday.setDate(lastWeekEnd.getDate() - (dow === 0 ? 6 : dow - 1));
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
return { from: toISO(monday), to: toISO(sunday) };
}
if (preset === "thisMonth") {
const first = new Date(today.getFullYear(), today.getMonth(), 1);
const last = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return { from: toISO(first), to: toISO(last) };
}
if (preset === "lastMonth") {
const first = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const last = new Date(today.getFullYear(), today.getMonth(), 0);
return { from: toISO(first), to: toISO(last) };
}
if (preset === "last3Months") {
const first = new Date(today.getFullYear(), today.getMonth() - 3, 1);
return { from: toISO(first), to: toISO(today) };
}
return { from: "", to: "" };
}양 app.ts 위임 (메서드 이름·시그니처 유지 — HTML/내부 호출부 6곳 무수정):
resolveDatePresetRange(preset: string): { from: string; to: string } {
return resolveDatePresetRange(preset);
},import (양 앱): import { resolveDatePresetRange } from "./attendance/date-preset"; — 메서드명과 함수명이 같지만 메서드 본문에서 모듈 함수를 참조하는 것은 유효하다(shadowing 없음 — 메서드는 객체 리터럴 프로퍼티).
- [ ] Step 2: 직접 테스트 (admin 쪽)
// apps/admin/src/attendance/date-preset.test.ts
import { describe, expect, it } from "vitest";
import { resolveDatePresetRange } from "./date-preset";
// 정오로 고정해 DST/자정 경계 영향 배제. kr-holidays 지원 범위(2024-2027) 내 날짜만 사용.
const at = (iso: string) => new Date(`${iso}T12:00:00`);
describe("resolveDatePresetRange", () => {
it.each([
// 일요일 특례: dow=0 → 그 주의 월요일은 6일 전
["thisWeek", "2026-07-05", { from: "2026-06-29", to: "2026-07-05" }],
["thisWeek", "2026-07-06", { from: "2026-07-06", to: "2026-07-12" }],
["lastWeek", "2026-07-05", { from: "2026-06-22", to: "2026-06-28" }],
["thisMonth", "2024-02-10", { from: "2024-02-01", to: "2024-02-29" }], // 윤년
["lastMonth", "2026-01-15", { from: "2025-12-01", to: "2025-12-31" }], // 연말 롤오버
["last3Months", "2026-01-15", { from: "2025-10-01", to: "2026-01-15" }],
["unknown", "2026-07-06", { from: "", to: "" }],
])("should resolve %s on %s", (preset, today, expected) => {
expect(resolveDatePresetRange(preset, at(today))).toEqual(expected);
});
});- [ ] Step 3: 동기화 가드 (tm 쪽, kr-holidays-sync 선례)
// apps/team-manager/src/attendance/date-preset-sync.test.ts
import { describe, expect, it } from "vitest";
import { resolveDatePresetRange as adminPreset } from "../../../admin/src/attendance/date-preset";
import { resolveDatePresetRange as tmPreset } from "./date-preset";
// 두 앱에 동일 사본이 의도적으로 중복돼 있다 (kr-holidays와 같은 정책).
// 한쪽만 고치면 이 테스트가 즉시 깨져 "이번 주"가 화면마다 달라지는 사고를 막는다.
const DATES = ["2026-07-05", "2026-07-06", "2024-02-10", "2026-01-01", "2026-12-31"];
const PRESETS = ["thisWeek", "lastWeek", "thisMonth", "lastMonth", "last3Months", "unknown"];
describe.each(DATES)("date-preset copy sync on %s", (date) => {
it.each(PRESETS)("should return identical ranges for %s", (preset) => {
const today = new Date(`${date}T12:00:00`);
expect(tmPreset(preset, today)).toEqual(adminPreset(preset, today));
});
});- [ ] Step 4: tm 미래 클램프 characterization (인스턴스, fake timer)
tm app-attendance-labels.test.ts가 아직 없으므로 이 케이스는 sync 테스트 파일이 아닌 새 인스턴스 케이스로 apps/team-manager/src/attendance/date-preset-clamp.test.ts에 둔다:
// apps/team-manager/src/attendance/date-preset-clamp.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTmApp } from "../test-support/harness";
describe("setDashAttDatePreset (미래 클램프)", () => {
// characterization: locks current behavior, not proven-correct output
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should clamp thisWeek's future end to today (문자열 비교)", () => {
vi.setSystemTime(new Date("2026-07-08T12:00:00")); // 수요일
const app = createTmApp();
app.dashReload = () => {}; // 네트워크 로더 차단
app.setDashAttDatePreset("thisWeek");
expect(app.dashAtt.from).toBe("2026-07-06");
expect(app.dashAtt.to).toBe("2026-07-08"); // 일요일(7/12) 아님 — 오늘로 클램프
});
it("should leave a fully past range unclamped", () => {
vi.setSystemTime(new Date("2026-07-08T12:00:00"));
const app = createTmApp();
app.dashReload = () => {};
app.setDashAttDatePreset("lastWeek");
expect(app.dashAtt.to).toBe("2026-07-05");
});
});주의: app.dashReload = () => {} 할당이 타입 에러가 나면 원본 시그니처(dashReload(kind: string))에 맞춰 app.dashReload = (_kind: string) => {};로 조정한다.
- [ ] Step 5: 실행 + 빌드 + Commit
pnpm --filter @hereby/admin exec vitest run src/attendance/date-preset.test.ts \
&& pnpm --filter @hereby/team-manager exec vitest run src/attendance \
&& pnpm --filter @hereby/admin build && pnpm --filter @hereby/team-manager build
git add apps/admin/src/attendance/date-preset.* apps/team-manager/src/attendance apps/admin/src/app.ts apps/team-manager/src/app.ts
git commit -m "test(attendance): extract mirrored date presets with cross-app sync guard
admin·tm에 복붙돼 있던 resolveDatePresetRange를 각자 attendance/date-preset.ts로
추출(today 인자화)하고, kr-holidays-sync 선례대로 양앱 동일성 가드 테스트 추가.
일요일 특례·윤년·연말 롤오버·미래 클램프 케이스 고정.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 5: admin enum→라벨 전수 계약 테스트 (추출 없음)
대상: leaveStatusLabel(:7112), leaveApprovalTypeLabel(:7077), offsiteStatusLabel/Badge(:7488/:7501), offsiteAuditActionLabel(:7646), overtimeStatusLabel(:2889), overtimeCategoryLabel(:2879). 라벨 드리프트 실버그 2회 전력 유형. 여기서 KI-6가 드러난다 — OvertimeCategory.HOLIDAY_NIGHT("holiday_night")의 라벨이 admin 맵에 없어 raw enum이 노출된다. 고치지 않고 characterization으로 고정한다.
Files:
Create:
apps/admin/src/app-attendance-labels.test.ts[ ] Step 1: 테스트 작성
// apps/admin/src/app-attendance-labels.test.ts
import {
LeaveStatus,
LeaveType,
OffsiteWorkStatus,
OvertimeCategory,
OvertimeStatus,
} from "@hereby/core";
import { beforeEach, describe, expect, it } from "vitest";
import { createAdminApp } from "./test-support/harness";
type AdminApp = ReturnType<typeof createAdminApp>;
let app: AdminApp;
beforeEach(() => {
app = createAdminApp();
});
describe("leaveStatusLabel", () => {
it.each([
[LeaveStatus.PENDING, "대기"],
[LeaveStatus.APPROVED, "승인"],
[LeaveStatus.REJECTED, "반려"],
[LeaveStatus.CANCELLED, "취소"],
])('should label %s as "%s"', (status, expected) => {
expect(app.leaveStatusLabel(status)).toBe(expected);
});
// 전수 가드 — enum에 값이 추가되면 라벨 누락(raw 노출)을 즉시 잡는다
it.each(Object.values(LeaveStatus))("should not leak the raw enum for %s", (status) => {
expect(app.leaveStatusLabel(status)).not.toBe(String(status));
});
});
describe("leaveApprovalTypeLabel", () => {
it.each([
[LeaveType.ANNUAL, "연차"],
[LeaveType.SICK, "병가"],
[LeaveType.PERSONAL, "개인 사유"],
[LeaveType.MATERNITY, "출산 휴가"],
[LeaveType.PATERNITY, "배우자 출산"],
[LeaveType.BEREAVEMENT, "경조 휴가"],
[LeaveType.MILITARY, "예비군/민방위"],
[LeaveType.OTHER, "기타"],
])('should map legacy type %s to "%s"', (type, expected) => {
expect(app.leaveApprovalTypeLabel(type)).toBe(expected);
});
it("should prefer the org custom reason name and mark deleted customs", () => {
app.leaveReasons = [{ id: "lr_1", name: "여름 휴가" }];
expect(app.leaveApprovalTypeLabel("lr_1")).toBe("여름 휴가");
expect(app.leaveApprovalTypeLabel("lr_gone")).toBe("삭제");
expect(app.leaveApprovalTypeLabel("")).toBe("-");
});
});
describe("offsiteStatusLabel / offsiteStatusBadge", () => {
it.each([
[OffsiteWorkStatus.PENDING, "대기", "ds-label--yellow"],
[OffsiteWorkStatus.APPROVED, "승인", "ds-label--green"],
[OffsiteWorkStatus.REJECTED, "반려", "ds-label--red"],
[OffsiteWorkStatus.CANCELLED, "취소", "ds-label--gray"],
[OffsiteWorkStatus.REPORT_PENDING, "보고서 대기", "ds-label--yellow"],
[OffsiteWorkStatus.REPORT_APPROVED, "보고서 승인", "ds-label--green"],
[OffsiteWorkStatus.REPORT_REJECTED, "보고서 반려", "ds-label--red"],
])('should label %s as "%s" with badge %s', (status, label, badge) => {
expect(app.offsiteStatusLabel(status)).toBe(label);
expect(app.offsiteStatusBadge(status)).toBe(badge);
});
it.each(Object.values(OffsiteWorkStatus))("should not leak the raw enum for %s", (status) => {
expect(app.offsiteStatusLabel(status)).not.toBe(String(status));
});
});
describe("offsiteAuditActionLabel", () => {
it.each([
["request", "신청"],
["approve", "승인"],
["reject", "반려"],
["cancel_request", "신청 취소"],
["cancel_approved", "취소"],
["submit_report", "보고서 제출"],
["approve_report", "보고서 승인"],
["reject_report", "보고서 반려"],
])('should label audit action %s as "%s"', (action, expected) => {
expect(app.offsiteAuditActionLabel(action)).toBe(expected);
});
});
describe("overtimeStatusLabel (admin)", () => {
// admin은 "보고 대기", tm은 "보고서 대기" — 앱별 표기가 다른 것이 현행 계약이다.
it.each([
[OvertimeStatus.PENDING, "대기"],
[OvertimeStatus.APPROVED, "승인"],
[OvertimeStatus.REJECTED, "반려"],
[OvertimeStatus.CANCELLED, "취소"],
[OvertimeStatus.REPORT_PENDING, "보고 대기"],
[OvertimeStatus.REPORT_APPROVED, "보고 승인"],
[OvertimeStatus.REPORT_REJECTED, "보고 반려"],
])('should label %s as "%s"', (status, expected) => {
expect(app.overtimeStatusLabel(status)).toBe(expected);
});
});
describe("overtimeCategoryLabel", () => {
it.each([
[OvertimeCategory.WEEKDAY, "평일 연장"],
[OvertimeCategory.SATURDAY, "주말"],
[OvertimeCategory.HOLIDAY, "휴일"],
[OvertimeCategory.NIGHT, "야간"],
])('should label %s as "%s"', (category, expected) => {
expect(app.overtimeCategoryLabel(category)).toBe(expected);
});
it("should leak the raw enum for HOLIDAY_NIGHT (KI-6)", () => {
// characterization: locks current behavior, not proven-correct output
// KI-6: OvertimeCategory.HOLIDAY_NIGHT 라벨이 admin 맵에 없어 raw 문자열이
// 화면에 노출된다 (스펙 §8, hr-system 433fe910과 동일 패턴). 수정은 별도 판단.
expect(app.overtimeCategoryLabel(OvertimeCategory.HOLIDAY_NIGHT)).toBe("holiday_night");
});
});- [ ] Step 2: 실행
pnpm --filter @hereby/admin exec vitest run src/app-attendance-labels.test.tsExpected: PASS. KI-6 케이스가 FAIL하면(= 누군가 이미 라벨을 추가함) 그 케이스만 실제 라벨로 교체하고 KI-6 주석 제거.
- [ ] Step 3: 빌드 + Commit
pnpm --filter @hereby/admin build
git add apps/admin/src/app-attendance-labels.test.ts
git commit -m "test(admin): pin attendance enum-to-label contracts incl. KI-6 gap
연차/외근/연장 enum→라벨 맵을 it.each 전수 계약으로 고정. OvertimeCategory
HOLIDAY_NIGHT 라벨 누락(KI-6)은 raw 노출 현행을 characterization으로 고정.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 6: team-manager enum→라벨 전수 계약 테스트 (추출 없음)
대상: overtimeStatusLabel(:1781), overtimeStatusBadgeClass(:1794), overtimeTypeLabel 폴백 체인(:1526-1534), categoryLabel(:2791). 비마운트 인스턴스에서 _t(ctx,key)는 $store 부재로 ?? key 폴백 → i18n 키 자체가 기대값이다.
Files:
Create:
apps/team-manager/src/app-attendance-labels.test.ts[ ] Step 1: 테스트 작성
// apps/team-manager/src/app-attendance-labels.test.ts
import { OvertimeCategory, OvertimeStatus } from "@hereby/core";
import { beforeEach, describe, expect, it } from "vitest";
import { createTmApp } from "./test-support/harness";
type TmApp = ReturnType<typeof createTmApp>;
let app: TmApp;
beforeEach(() => {
app = createTmApp();
});
describe("overtimeStatusLabel / overtimeStatusBadgeClass", () => {
it.each([
[OvertimeStatus.PENDING, "대기", "ds-label--yellow"],
[OvertimeStatus.APPROVED, "승인", "ds-label--green"],
[OvertimeStatus.REJECTED, "반려", "ds-label--red"],
[OvertimeStatus.CANCELLED, "취소", "ds-label--gray"],
[OvertimeStatus.REPORT_PENDING, "보고서 대기", "ds-label--yellow"],
[OvertimeStatus.REPORT_APPROVED, "보고서 승인", "ds-label--green"],
[OvertimeStatus.REPORT_REJECTED, "보고서 반려", "ds-label--red"],
])('should label %s as "%s" with badge %s', (status, label, badge) => {
expect(app.overtimeStatusLabel(status)).toBe(label);
expect(app.overtimeStatusBadgeClass(status)).toBe(badge);
});
it("should fall back to yellow badge for unknown status", () => {
// characterization: locks current behavior, not proven-correct output
expect(app.overtimeStatusBadgeClass("mystery")).toBe("ds-label--yellow");
});
});
describe("categoryLabel (i18n 키 계약 — 비마운트 인스턴스는 _t가 키를 반환)", () => {
it.each([
[OvertimeCategory.WEEKDAY, "team_mgr.category_weekday"],
[OvertimeCategory.SATURDAY, "team_mgr.category_saturday"],
[OvertimeCategory.HOLIDAY, "team_mgr.category_holiday"],
[OvertimeCategory.NIGHT, "team_mgr.category_night"],
])("should map %s to i18n key %s", (category, key) => {
expect(app.categoryLabel(category)).toBe(key);
});
it("should leak the raw enum for HOLIDAY_NIGHT (KI-6, tm측)", () => {
// characterization: locks current behavior, not proven-correct output
// KI-6: CATEGORY_KEYS에 holiday_night 키 부재 — raw 문자열 노출 (스펙 §8)
expect(app.categoryLabel(OvertimeCategory.HOLIDAY_NIGHT)).toBe("holiday_night");
});
});
describe("overtimeTypeLabel (폴백 체인: typeName → shiftName → category)", () => {
it.each([
[{ typeName: "관리형 유형", shiftName: "D", category: "weekday" }, "관리형 유형"],
[{ shiftName: "D", category: "weekday" }, "D"],
[{ category: "weekday" }, "team_mgr.category_weekday"],
])("should resolve %o to %s", (item, expected) => {
expect(app.overtimeTypeLabel(item)).toBe(expected);
});
it("should return undefined for an empty item", () => {
// characterization: locks current behavior, not proven-correct output
// category undefined → CATEGORY_KEYS[undefined] 미스 → category(undefined) 반환
expect(app.overtimeTypeLabel({})).toBeUndefined();
});
});- [ ] Step 2: 실행 + 빌드 + Commit
pnpm --filter @hereby/team-manager exec vitest run src/app-attendance-labels.test.ts \
&& pnpm --filter @hereby/team-manager build
git add apps/team-manager/src/app-attendance-labels.test.ts
git commit -m "test(team-manager): pin overtime label contracts incl. KI-6 tm-side gap
연장 상태 라벨·배지·유형 폴백 체인을 전수 계약으로 고정. 비마운트 인스턴스의
_t 폴백(키 반환)을 이용해 i18n 키 자체를 계약으로 삼음. holiday_night 누락은
characterization (KI-6).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 7: admin 외근 일괄결재 step 규칙 추출
대상: offsiteRecSelectedStep(:7669), offsiteRecRowSelectable(:7680), toggleAllOffsiteRec(:7690). 깨지면 잘못된 API(신청 승인 vs 보고서 승인)로 일괄 처리 — 무증상.
Files:
- Create:
apps/admin/src/attendance/offsite.ts - Create:
apps/admin/src/attendance/offsite.test.ts - Modify:
apps/admin/src/app.ts:7669-7706(위임)
Interfaces:
Produces:
offsiteSelectedStep(records: Record<string, unknown>[], selectedIds: string[]): string— admin 의미론: 목록 순서상 첫 선택 레코드의 status (tm과 다름 — tm은 selectedIds[0] 기준)offsiteRowSelectable(record: Record<string, unknown>, records: Record<string, unknown>[], selectedIds: string[]): booleanoffsiteSelectAllIds(records: Record<string, unknown>[], currentStep: string): string[]- (Task 11이 같은 파일에
offsiteConfirmedByDate추가)
[ ] Step 1: characterization 테스트 (인스턴스)
// apps/admin/src/attendance/offsite.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { createAdminApp } from "../test-support/harness";
type AdminApp = ReturnType<typeof createAdminApp>;
let app: AdminApp;
const ROWS = [
{ id: "a", status: "report_pending" },
{ id: "b", status: "pending" },
{ id: "c", status: "pending" },
{ id: "d", status: "approved" },
];
beforeEach(() => {
app = createAdminApp();
app.offsiteRecords = ROWS.map((r) => ({ ...r }));
});
describe("offsiteRecSelectedStep / offsiteRecRowSelectable", () => {
// characterization: locks current behavior, not proven-correct output
it("should report the step of the first record in list order among selected", () => {
// 선택 순서가 아니라 목록 순서 기준 — b를 먼저 골라도 a(report_pending)가 이긴다
app.selectedOffsiteRecIds = ["b", "a"];
expect(app.offsiteRecSelectedStep).toBe("report_pending");
});
it("should make only same-step rows selectable once a step is chosen", () => {
app.selectedOffsiteRecIds = ["b"];
expect(app.offsiteRecRowSelectable({ id: "c", status: "pending" })).toBe(true);
expect(app.offsiteRecRowSelectable({ id: "a", status: "report_pending" })).toBe(false);
});
it("should never allow non-approvable steps", () => {
app.selectedOffsiteRecIds = [];
expect(app.offsiteRecRowSelectable({ id: "d", status: "approved" })).toBe(false);
});
});
describe("toggleAllOffsiteRec", () => {
// characterization: locks current behavior, not proven-correct output
const check = (checked: boolean) => ({ target: { checked } }) as unknown as Event;
it("should select every row of the top step when nothing was selected", () => {
app.selectedOffsiteRecIds = [];
app.toggleAllOffsiteRec(check(true));
// 혼재 시 상단(먼저 나온) 단계 기준 — report_pending(a)
expect(app.selectedOffsiteRecIds).toEqual(["a"]);
});
it("should keep the already-chosen step when selecting all", () => {
app.selectedOffsiteRecIds = ["b"];
app.toggleAllOffsiteRec(check(true));
expect(app.selectedOffsiteRecIds).toEqual(["b", "c"]);
});
it("should clear the selection on uncheck", () => {
app.selectedOffsiteRecIds = ["b"];
app.toggleAllOffsiteRec(check(false));
expect(app.selectedOffsiteRecIds).toEqual([]);
});
});- [ ] Step 2: 실행 — PASS 확인
pnpm --filter @hereby/admin exec vitest run src/attendance/offsite.test.tsExpected: PASS.
- [ ] Step 3: 추출
// apps/admin/src/attendance/offsite.ts
/**
* 외근 일괄 결재 선택 규칙 — 대기/보고서 대기 "단일 단계"만 동시 선택 (스펙 §4).
* 주의: admin은 "목록 순서상 첫 선택 레코드"가 단계를 정한다.
* team-manager(approval-steps.ts)는 selectedIds[0] 기준 — 의도된 앱별 차이(characterized).
*/
/** 현재 선택된 건들의 단계(pending | report_pending). 빈 선택이면 "". */
export function offsiteSelectedStep(
records: Record<string, unknown>[],
selectedIds: string[],
): string {
if (selectedIds.length === 0) {
return "";
}
const first = records.find((r) => selectedIds.includes(r.id as string));
return first ? (first.status as string) : "";
}
/** 행 선택 가능 — 대기/보고서 대기만, 이미 선택된 단계와 동일해야. */
export function offsiteRowSelectable(
r: Record<string, unknown>,
records: Record<string, unknown>[],
selectedIds: string[],
): boolean {
const st = r.status as string;
if (st !== "pending" && st !== "report_pending") {
return false;
}
const step = offsiteSelectedStep(records, selectedIds);
return step === "" || step === st;
}
/** 헤더 체크박스 전체 선택 — 혼재 시 먼저 나온(상단) 단계 기준. */
export function offsiteSelectAllIds(
records: Record<string, unknown>[],
currentStep: string,
): string[] {
const rows = records.filter((r) => r.status === "pending" || r.status === "report_pending");
if (rows.length === 0) {
return [];
}
const step = currentStep || (rows[0].status as string);
return rows.filter((r) => r.status === step).map((r) => r.id as string);
}app.ts 위임:
get offsiteRecSelectedStep(): string {
return offsiteSelectedStep(this.offsiteRecords, this.selectedOffsiteRecIds);
},
offsiteRecRowSelectable(r: Record<string, unknown>): boolean {
return offsiteRowSelectable(r, this.offsiteRecords, this.selectedOffsiteRecIds);
},
toggleAllOffsiteRec(ev: Event) {
const checked = (ev.target as HTMLInputElement).checked;
this.selectedOffsiteRecIds = checked
? offsiteSelectAllIds(this.offsiteRecords, this.offsiteRecSelectedStep)
: [];
},import: import { offsiteRowSelectable, offsiteSelectAllIds, offsiteSelectedStep } from "./attendance/offsite";
- [ ] Step 4: 검증 + Commit
pnpm --filter @hereby/admin exec vitest run src/attendance/offsite.test.ts \
&& pnpm --filter @hereby/admin build
git add apps/admin/src/attendance/offsite.ts apps/admin/src/attendance/offsite.test.ts apps/admin/src/app.ts
git commit -m "test(admin): extract offsite bulk-approval step rules with characterization
단일 단계 일괄결재 규칙(선택 단계 판정·행 선택 가능·전체 선택)을
attendance/offsite.ts로 추출. 목록 순서 기준 단계 판정(tm과 다른 의미론)을
characterization으로 명시.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 8: team-manager 일괄결재 step 규칙 쌍 추출 (연장·외근 파라미터라이즈)
대상: overtimeSelectedStep/RowSelectable/toggleAllOvertime(:2507-2540)과 offsiteSelectedStep/RowSelectable/toggleAllOffsite(:2547-2580) — 복붙 이중화. 하나의 헬퍼로 접고 양쪽 위임.
Files:
- Create:
apps/team-manager/src/attendance/approval-steps.ts - Create:
apps/team-manager/src/attendance/approval-steps.test.ts - Modify:
apps/team-manager/src/app.ts:2507-2580(양쪽 위임)
Interfaces:
Produces:
type ApprovalStep = "pending" | "report_pending"selectedStep(records: Record<string, unknown>[], selectedIds: string[]): ApprovalStep | null— tm 의미론: selectedIds[0]의 레코드 기준rowSelectable(item: Record<string, unknown>, step: ApprovalStep | null): booleanselectAllIds(records: Record<string, unknown>[], currentStep: ApprovalStep | null): string[]— 대기 우선
[ ] Step 1: characterization 테스트 (인스턴스 — describe.each로 연장·외근 동시)
// apps/team-manager/src/attendance/approval-steps.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { createTmApp } from "../test-support/harness";
type TmApp = ReturnType<typeof createTmApp>;
const ROWS = [
{ id: "a", status: "report_pending" },
{ id: "b", status: "pending" },
{ id: "c", status: "pending" },
{ id: "d", status: "approved" },
];
// 연장·외근이 같은 규칙의 복붙 쌍 — 타입 보존 접근자 람다로 파라미터라이즈 (as any 금지).
const KINDS = [
{
kind: "overtime",
setRecords: (app: TmApp, rows: Record<string, unknown>[]) => {
app.overtimeRecords = rows;
},
setSelected: (app: TmApp, ids: string[]) => {
app.selectedOvertimeIds = ids;
},
getSelected: (app: TmApp) => app.selectedOvertimeIds,
getStep: (app: TmApp) => app.overtimeSelectedStep,
selectable: (app: TmApp, item: Record<string, unknown>) => app.overtimeRowSelectable(item),
toggleAll: (app: TmApp, ev: Event) => app.toggleAllOvertime(ev),
},
{
kind: "offsite",
setRecords: (app: TmApp, rows: Record<string, unknown>[]) => {
app.offsiteRecords = rows;
},
setSelected: (app: TmApp, ids: string[]) => {
app.selectedOffsiteIds = ids;
},
getSelected: (app: TmApp) => app.selectedOffsiteIds,
getStep: (app: TmApp) => app.offsiteSelectedStep,
selectable: (app: TmApp, item: Record<string, unknown>) => app.offsiteRowSelectable(item),
toggleAll: (app: TmApp, ev: Event) => app.toggleAllOffsite(ev),
},
];
describe.each(KINDS)("$kind bulk-approval steps", (k) => {
// characterization: locks current behavior, not proven-correct output
let app: TmApp;
const check = (checked: boolean) => ({ target: { checked } }) as unknown as Event;
beforeEach(() => {
app = createTmApp();
k.setRecords(app, ROWS.map((r) => ({ ...r })));
});
it("should derive the step from the first *selected* id (admin과 다른 의미론)", () => {
k.setSelected(app, ["b", "a"]);
expect(k.getStep(app)).toBe("pending");
});
it("should block rows of a different step", () => {
k.setSelected(app, ["b"]);
expect(k.selectable(app, { id: "a", status: "report_pending" })).toBe(false);
expect(k.selectable(app, { id: "c", status: "pending" })).toBe(true);
});
it("should select-all with pending-first priority when nothing chosen", () => {
k.setSelected(app, []);
k.toggleAll(app, check(true));
// admin(상단 단계 기준)과 달리 tm은 pending 우선 — b,c가 뽑힌다
expect(k.getSelected(app)).toEqual(["b", "c"]);
});
it("should clear on uncheck", () => {
k.setSelected(app, ["b"]);
k.toggleAll(app, check(false));
expect(k.getSelected(app)).toEqual([]);
});
});(접근자 람다가 타입을 보존하므로 as any·biome-ignore가 필요 없다. KINDS에 as const를 붙이면 describe.each 타입 추론이 유니언으로 좁혀지며 람다 시그니처가 어긋날 수 있으니 붙이지 않는다.)
- [ ] Step 2: 실행 — PASS 확인 후 추출
// apps/team-manager/src/attendance/approval-steps.ts
/**
* 일괄 결재 대상 단계 규칙 (스펙 §4) — 연장·외근 복붙 쌍을 단일 헬퍼로 통합.
* 첫 "선택된" id의 레코드가 단계를 정한다 (admin attendance/offsite.ts는 목록
* 순서 기준 — 의도된 앱별 차이, 양쪽 모두 characterization으로 고정됨).
*/
export type ApprovalStep = "pending" | "report_pending";
export function selectedStep(
records: Record<string, unknown>[],
selectedIds: string[],
): ApprovalStep | null {
if (selectedIds.length === 0) {
return null;
}
const first = records.find((r) => r.id === selectedIds[0]);
const s = first?.status as string | undefined;
return s === "pending" || s === "report_pending" ? s : null;
}
export function rowSelectable(item: Record<string, unknown>, step: ApprovalStep | null): boolean {
const s = item.status as string;
if (s !== "pending" && s !== "report_pending") {
return false;
}
return step === null || step === s;
}
/** 전체 선택 — 이미 선택된 단계 우선, 없으면 대기 → 보고서 대기 순. */
export function selectAllIds(
records: Record<string, unknown>[],
currentStep: ApprovalStep | null,
): string[] {
const hasPending = records.some((r) => r.status === "pending");
const step: ApprovalStep = currentStep ?? (hasPending ? "pending" : "report_pending");
return records.filter((r) => r.status === step).map((r) => r.id as string);
}app.ts 위임 (연장 3개 + 외근 3개, 총 6개 메서드 본문 교체 — 중복 주석 블록은 하나로):
get overtimeSelectedStep(): "pending" | "report_pending" | null {
return selectedStep(this.overtimeRecords, this.selectedOvertimeIds);
},
overtimeRowSelectable(item: Record<string, unknown>): boolean {
return rowSelectable(item, this.overtimeSelectedStep);
},
toggleAllOvertime(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.selectedOvertimeIds = checked
? selectAllIds(this.overtimeRecords, this.overtimeSelectedStep)
: [];
},외근 3개 (:2547-2580 교체):
get offsiteSelectedStep(): "pending" | "report_pending" | null {
return selectedStep(this.offsiteRecords, this.selectedOffsiteIds);
},
offsiteRowSelectable(item: Record<string, unknown>): boolean {
return rowSelectable(item, this.offsiteSelectedStep);
},
toggleAllOffsite(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.selectedOffsiteIds = checked
? selectAllIds(this.offsiteRecords, this.offsiteSelectedStep)
: [];
},import: import { rowSelectable, selectAllIds, selectedStep } from "./attendance/approval-steps";
- [ ] Step 3: 직접 테스트 추가 + 검증 + Commit
import { rowSelectable, selectAllIds, selectedStep } from "./approval-steps";
describe("approval-steps (extracted)", () => {
it("should fall back to report_pending when no pending rows exist", () => {
const records = [
{ id: "a", status: "report_pending" },
{ id: "d", status: "approved" },
];
expect(selectAllIds(records, null)).toEqual(["a"]);
});
it("should return null step for a selected non-approvable record", () => {
expect(selectedStep([{ id: "d", status: "approved" }], ["d"])).toBeNull();
});
it("should treat null step as fully selectable for approvable rows", () => {
expect(rowSelectable({ status: "pending" }, null)).toBe(true);
});
});pnpm --filter @hereby/team-manager exec vitest run src/attendance/approval-steps.test.ts \
&& pnpm --filter @hereby/team-manager build
git add apps/team-manager/src/attendance/approval-steps.* apps/team-manager/src/app.ts
git commit -m "test(team-manager): unify overtime/offsite bulk-step rules into one helper
복붙 이중화된 연장·외근 일괄결재 단계 규칙을 attendance/approval-steps.ts
단일 헬퍼로 접고 양쪽을 위임. describe.each 파라미터라이즈로 두 표면을
같은 케이스로 고정 — 한쪽만 고치는 drift를 차단.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 9: admin 월 이동 추출 + pageWindow·연차 통계 인스턴스 테스트
대상: setAttendanceMonth/shiftAttendanceMonth(:2450-2467, 추출), pageWindow(:4459, 인스턴스), leaveDeductionDays(:7100, 인스턴스), leaveStats(:8648, 인스턴스).
Files:
- Create:
apps/admin/src/attendance/month-nav.ts+month-nav.test.ts - Create:
apps/admin/src/app-attendance-misc.test.ts - Modify:
apps/admin/src/app.ts:2450-2467(위임)
Interfaces:
Produces:
monthRange(yearMonth: string): { from: string; to: string } | null(형식 불일치 → null)shiftMonth(yearMonth: string, delta: number): string
[ ] Step 1: month-nav characterization + 추출
// apps/admin/src/attendance/month-nav.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { createAdminApp } from "../test-support/harness";
import { monthRange, shiftMonth } from "./month-nav";
type AdminApp = ReturnType<typeof createAdminApp>;
describe("setAttendanceMonth / shiftAttendanceMonth (instance)", () => {
// characterization: locks current behavior, not proven-correct output
let app: AdminApp;
beforeEach(() => {
app = createAdminApp();
app.loadEmployeeAttendance = async () => {}; // 네트워크 차단
});
it("should derive the 1st..last-day range for a leap February", async () => {
await app.setAttendanceMonth("2024-02");
expect(app.attendanceFilter.dateFrom).toBe("2024-02-01");
expect(app.attendanceFilter.dateTo).toBe("2024-02-29");
});
it("should ignore malformed input", async () => {
app.attendanceMonth = "2026-06";
await app.setAttendanceMonth("2026-6");
expect(app.attendanceMonth).toBe("2026-06");
});
it("should roll over year boundaries in both directions", () => {
app.attendanceMonth = "2026-12";
app.shiftAttendanceMonth(1);
expect(app.attendanceMonth).toBe("2027-01");
app.attendanceMonth = "2026-01";
app.shiftAttendanceMonth(-1);
expect(app.attendanceMonth).toBe("2025-12");
});
});
describe("monthRange / shiftMonth (extracted)", () => {
it.each([
["2024-02", "2024-02-01", "2024-02-29"],
["2026-02", "2026-02-01", "2026-02-28"],
["2026-12", "2026-12-01", "2026-12-31"],
])("should range %s as %s..%s", (ym, from, to) => {
expect(monthRange(ym)).toEqual({ from, to });
});
it("should return null for malformed input", () => {
expect(monthRange("2026-6")).toBeNull();
});
it.each([
["2026-12", 1, "2027-01"],
["2026-01", -1, "2025-12"],
["2026-06", 7, "2027-01"],
])("should shift %s by %s to %s", (ym, delta, expected) => {
expect(shiftMonth(ym, delta)).toBe(expected);
});
});// apps/admin/src/attendance/month-nav.ts
/** 월 단위 조회 범위·이동 — app.ts에서 추출. */
/** "YYYY-MM" → 1일~말일. 형식이 다르면 null. */
export function monthRange(yearMonth: string): { from: string; to: string } | null {
if (!/^\d{4}-\d{2}$/.test(yearMonth)) {
return null;
}
const [year, month] = yearMonth.split("-").map(Number);
const lastDay = new Date(year, month, 0).getDate();
return { from: `${yearMonth}-01`, to: `${yearMonth}-${String(lastDay).padStart(2, "0")}` };
}
/** "YYYY-MM"에 delta개월 가감 (연 롤오버 포함). */
export function shiftMonth(yearMonth: string, delta: number): string {
const [year, month] = yearMonth.split("-").map(Number);
const d = new Date(year, month - 1 + delta, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}app.ts 위임:
async setAttendanceMonth(yearMonth: string) {
const range = monthRange(yearMonth);
if (!range) {
return;
}
this.attendanceMonth = yearMonth;
this.attendanceFilter.dateFrom = range.from;
this.attendanceFilter.dateTo = range.to;
await this.loadEmployeeAttendance();
},
shiftAttendanceMonth(delta: number) {
this.setAttendanceMonth(shiftMonth(this.attendanceMonth, delta));
},import: import { monthRange, shiftMonth } from "./attendance/month-nav";
- [ ] Step 2: misc 인스턴스 테스트
// apps/admin/src/app-attendance-misc.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { createAdminApp } from "./test-support/harness";
type AdminApp = ReturnType<typeof createAdminApp>;
let app: AdminApp;
beforeEach(() => {
app = createAdminApp();
});
describe("pageWindow", () => {
it.each([
[1, 1, [1]],
[1, 15, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]],
[8, 15, [4, 5, 6, 7, 8, 9, 10, 11, 12, 13]],
[15, 15, [6, 7, 8, 9, 10, 11, 12, 13, 14, 15]],
])("should window page %s of %s", (current, total, expected) => {
expect(app.pageWindow(current, total)).toEqual(expected);
});
it("should return an empty window for zero pages", () => {
// characterization: locks current behavior, not proven-correct output
expect(app.pageWindow(1, 0)).toEqual([]);
});
});
describe("leaveDeductionDays", () => {
it("should count inclusive days and honor half-day", () => {
expect(app.leaveDeductionDays({ startDate: "2026-07-01", endDate: "2026-07-03" })).toBe(3);
expect(app.leaveDeductionDays({ isHalfDay: true, startDate: "x", endDate: "y" })).toBe(0.5);
});
it("should count across month boundaries", () => {
expect(app.leaveDeductionDays({ startDate: "2026-06-30", endDate: "2026-07-01" })).toBe(2);
});
it("should return 0 for unparsable dates", () => {
expect(app.leaveDeductionDays({ startDate: "n/a", endDate: "2026-07-01" })).toBe(0);
});
});
describe("leaveStats", () => {
it("should return zeros for an empty list", () => {
app.leaveBalances = [];
expect(app.leaveStats()).toEqual({ totalEmployees: 0, avgUsed: 0, avgRemaining: 0 });
});
it("should average used/remaining to 0.1 precision", () => {
app.leaveBalances = [
{ used: 5, remaining: 10 },
{ used: 6, remaining: 11 },
];
expect(app.leaveStats()).toEqual({ totalEmployees: 2, avgUsed: 5.5, avgRemaining: 10.5 });
});
it("should coerce missing fields to 0", () => {
app.leaveBalances = [{ used: 3 }, {}];
expect(app.leaveStats()).toEqual({ totalEmployees: 2, avgUsed: 1.5, avgRemaining: 0 });
});
});- [ ] Step 3: 검증 + Commit
pnpm --filter @hereby/admin exec vitest run src/attendance/month-nav.test.ts src/app-attendance-misc.test.ts \
&& pnpm --filter @hereby/admin build
git add apps/admin/src/attendance/month-nav.* apps/admin/src/app-attendance-misc.test.ts apps/admin/src/app.ts
git commit -m "test(admin): extract month navigation, pin pageWindow and leave stats
월 범위/이동을 attendance/month-nav.ts로 추출(윤년·연 롤오버 고정),
pageWindow off-by-one·0페이지, 연차 차감일수·평균 통계를 인스턴스로 고정.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 10: admin 일별 표 표시 헬퍼 추출
대상: attnDateLabel(:2484), attendanceRowRed(:2507), checkOutDayOffset(:2516), attnEditChanges(:2847). (+N) 미표시·요일 밀림·감사 diff 소실 — 전부 무증상.
Files:
- Create:
apps/admin/src/attendance/display.ts+display.test.ts - Modify:
apps/admin/src/app.ts해당 4개 메서드 (위임)
Interfaces:
Produces:
attnDateLabel(date: unknown): stringattendanceRowRed(r: Record<string, unknown>, holidayName: (date: unknown) => string): booleancheckOutDayOffset(r: Record<string, unknown>, formatDateISO: (v: string) => string): numberattnEditChanges(h: Record<string, unknown>, formatTime: (v: unknown) => string): { label: string; before: string; after: string }[]
[ ] Step 1: characterization 테스트 (인스턴스)
// apps/admin/src/attendance/display.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { createAdminApp } from "../test-support/harness";
type AdminApp = ReturnType<typeof createAdminApp>;
let app: AdminApp;
beforeEach(() => {
app = createAdminApp();
});
describe("attnDateLabel", () => {
it.each([
["2026-07-06", "2026.07.06.(월)"],
["2026-07-05", "2026.07.05.(일)"],
["2026-07-06T00:00:00.000Z", "2026.07.06.(월)"], // slice(0,10) — 시각부 무시
])("should format %s as %s", (input, expected) => {
expect(app.attnDateLabel(input)).toBe(expected);
});
it.each([["", "-"], [null, "-"], ["07/06/2026", "-"]])(
"should fall back to '-' for %s",
(input, expected) => {
expect(app.attnDateLabel(input)).toBe(expected);
},
);
});
describe("attendanceRowRed", () => {
it.each([
[{ isWeekend: true, date: "2026-07-06" }, true],
[{ date: "2026-01-01" }, true], // KR_HOLIDAYS 신정
[{ date: "2026-07-06", shiftName: "OFF" }, true],
[{ date: "2026-07-06", shiftName: "주간 휴무" }, true],
[{ date: "2026-07-06", shiftName: "D" }, false],
])("should mark %o as red=%s", (row, expected) => {
expect(app.attendanceRowRed(row)).toBe(expected);
});
});
describe("checkOutDayOffset", () => {
it("should return +1 for an after-midnight checkout (Seoul)", () => {
// 6/24 15:02Z = 6/25 00:02 KST
const row = { date: "2026-06-24", actualCheckOut: "2026-06-24T15:02:00.000Z" };
expect(app.checkOutDayOffset(row)).toBe(1);
});
it("should return 0 for a same-day checkout and for missing values", () => {
expect(
app.checkOutDayOffset({ date: "2026-06-24", actualCheckOut: "2026-06-24T08:00:00.000Z" }),
).toBe(0);
expect(app.checkOutDayOffset({ date: "2026-06-24" })).toBe(0);
});
});
describe("attnEditChanges", () => {
it("should diff changed time fields via the time formatter", () => {
const entry = {
beforeCheckIn: "2026-06-24T00:00:00.000Z", // 09:00 KST
afterCheckIn: "2026-06-24T00:30:00.000Z", // 09:30 KST
beforeCheckOut: "2026-06-24T09:00:00.000Z",
afterCheckOut: "2026-06-24T09:00:00.000Z",
};
const out = app.attnEditChanges(entry);
expect(out).toEqual([{ label: "출근", before: "09:00", after: "09:30" }]);
});
it('should print "-" (not "미입력") for a missing side of a time change', () => {
// characterization: locks current behavior, not proven-correct output
// formatTimeOnly는 falsy 입력에 "-"(truthy)를 반환하므로 `|| "미입력"` 분기는 도달 불가.
const out = app.attnEditChanges({
beforeCheckOut: null,
afterCheckOut: "2026-06-24T09:00:00.000Z",
});
expect(out).toEqual([{ label: "퇴근", before: "-", after: "18:00" }]);
});
it("should diff numeric fields above the 0.001 threshold only", () => {
const out = app.attnEditChanges({ beforeRegular: 8, afterRegular: 7.5, beforeNight: 1, afterNight: 1 });
expect(out).toEqual([{ label: "일반", before: "8h", after: "7.5h" }]);
});
it("should return an empty diff for identical entries", () => {
expect(app.attnEditChanges({})).toEqual([]);
});
});주의: attendanceRowRed의 공휴일 케이스는 실행 전 rg '"2026-01-01"' apps/admin/src/data/kr-holidays.ts로 신정 등록을 확인한다 — 없으면 등록된 다른 2026 공휴일로 교체.
- [ ] Step 2: 실행 — PASS 확인 후 추출
// apps/admin/src/attendance/display.ts
/** 일별 근태 표 표시 헬퍼 — app.ts에서 추출. 포맷터·공휴일 판정은 주입. */
/** 날짜 라벨 — YYYY.MM.DD.(요일). 근태 표는 예외적으로 24시간/한국식 표기. */
export function attnDateLabel(date: unknown): string {
const s = String(date || "").slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
return "-";
}
const [y, m, d] = s.split("-").map(Number);
const dow = ["일", "월", "화", "수", "목", "금", "토"][
new Date(Date.UTC(y, m - 1, d)).getUTCDay()
];
return `${y}.${String(m).padStart(2, "0")}.${String(d).padStart(2, "0")}.(${dow})`;
}
/** 날짜 빨간 톤 — 주말·공휴일·OFF(휴무 근무). */
export function attendanceRowRed(
r: Record<string, unknown>,
holidayName: (date: unknown) => string,
): boolean {
if (r.isWeekend === true || holidayName(r.date) !== "") {
return true;
}
const shiftName = String(r.shiftName || "").toLowerCase();
return shiftName.includes("off") || shiftName.includes("휴무");
}
/** 야간 퇴근 표시 — 퇴근이 다음날이면 +N. */
export function checkOutDayOffset(
r: Record<string, unknown>,
formatDateISO: (v: string) => string,
): number {
const out = (r.actualCheckOut as string) || (r.checkOut as string) || null;
if (!out) {
return 0;
}
const outIso = formatDateISO(out) || String(out).slice(0, 10);
const dateIso = String(r.date || "").slice(0, 10);
if (!(outIso && dateIso)) {
return 0;
}
const diff = Math.round((new Date(outIso).getTime() - new Date(dateIso).getTime()) / 86400000);
return diff > 0 ? diff : 0;
}
/** 변경 내용 diff — 출퇴근 + 8개 구분 + 휴게 중 변경 항목만. */
export function attnEditChanges(
h: Record<string, unknown>,
formatTime: (v: unknown) => string,
): { label: string; before: string; after: string }[] {
const out: { label: string; before: string; after: string }[] = [];
const t = (v: unknown) => formatTime(v) || "미입력";
if (h.beforeCheckIn !== h.afterCheckIn) {
out.push({ label: "출근", before: t(h.beforeCheckIn), after: t(h.afterCheckIn) });
}
if (h.beforeCheckOut !== h.afterCheckOut) {
out.push({ label: "퇴근", before: t(h.beforeCheckOut), after: t(h.afterCheckOut) });
}
const numFields: { key: string; label: string }[] = [
{ key: "Regular", label: "일반" },
{ key: "Overtime", label: "연장" },
{ key: "Night", label: "야간" },
{ key: "NightOvertime", label: "야간연장" },
{ key: "HolidayRegular", label: "휴일일반" },
{ key: "HolidayOvertime", label: "휴일연장" },
{ key: "HolidayNight", label: "휴일야간" },
{ key: "HolidayNightOvertime", label: "휴일야간연장" },
{ key: "Break", label: "휴게" },
];
for (const f of numFields) {
const before = Number(h[`before${f.key}`] ?? 0);
const after = Number(h[`after${f.key}`] ?? 0);
if (Math.abs(before - after) > 0.001) {
out.push({ label: f.label, before: `${before}h`, after: `${after}h` });
}
}
return out;
}참고: 원본 attnEditChanges의 t()는 formatTimeOnly(...) || "미입력"이지만 formatTimeOnly가 실패/부재 시 "-"(truthy)를 반환하므로 "미입력" 분기는 도달 불가 — Step 1의 해당 케이스가 이미 "-"를 기대값으로 고정하고 있다. 추출 시 이 죽은 분기를 "정리"하지 말 것 (characterization 유지).
app.ts 위임:
attnDateLabel(date: unknown): string {
return attnDateLabel(date);
},
attendanceRowRed(r: Record<string, unknown>): boolean {
return attendanceRowRed(r, (d) => this.krHolidayName(d));
},
checkOutDayOffset(r: Record<string, unknown>): number {
const store = Alpine.store("hereby");
return checkOutDayOffset(r, (v) => store.formatDateISO(v));
},
attnEditChanges(h: Record<string, unknown>): { label: string; before: string; after: string }[] {
return attnEditChanges(h, (v) => this.formatTimeOnly(v as string));
},import: import { attendanceRowRed, attnDateLabel, attnEditChanges, checkOutDayOffset } from "./attendance/display";
- [ ] Step 3: 검증 + Commit
pnpm --filter @hereby/admin exec vitest run src/attendance/display.test.ts \
&& pnpm --filter @hereby/admin build
git add apps/admin/src/attendance/display.* apps/admin/src/app.ts
git commit -m "test(admin): extract attendance table display helpers
날짜 라벨·red 판정·익일 (+N)·수정 diff를 attendance/display.ts로 추출
(포맷터·공휴일 판정 주입). 자정 넘김·경계 0.001h·미입력 표기를 고정.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 11: admin 외근 byDate 매핑 + embedUrlFor 추출
대상: loadEmployeeAttendance 내 byDate 매핑(:3028-3039), embedUrlFor(:6659-6696). 상태 Set 누락 → 확정 외근 표에서 소실 / prefix·구분자 오류 → 빈 iframe.
Files:
- Modify:
apps/admin/src/attendance/offsite.ts(+offsiteConfirmedByDate),apps/admin/src/attendance/offsite.test.ts(케이스 추가) - Create:
apps/admin/src/attendance/embed-url.ts+embed-url.test.ts - Modify:
apps/admin/src/app.ts:3028-3039,:6659-6696(위임)
Interfaces:
Produces:
offsiteConfirmedByDate(items: Record<string, unknown>[]): Record<string, Record<string, unknown>>interface EmbedConfig { devPort: number; path: string; prodPrefix: string; title: string; icon: string }interface EmbedContext { isDev: boolean; protocol: string; hostname: string; origin: string; selectedTeamId: string; reloadKey: number }embedUrl(cfg: EmbedConfig | undefined, ctx: EmbedContext): string
[ ] Step 1: byDate — offsite.ts에 추가 + 테스트
// attendance/offsite.ts에 추가
/** 승인 이후 상태의 외근만 workDate("YYYY-MM-DD") 키로 매핑 — 대기/반려/취소는 근태에 미노출. */
export function offsiteConfirmedByDate(
items: Record<string, unknown>[],
): Record<string, Record<string, unknown>> {
const confirmedStatuses = new Set(["approved", "report_pending", "report_approved"]);
const byDate: Record<string, Record<string, unknown>> = {};
for (const ow of items) {
if (!confirmedStatuses.has(String(ow.status))) {
continue;
}
const key = String(ow.workDate || "").slice(0, 10);
if (key) {
byDate[key] = ow;
}
}
return byDate;
}// offsite.test.ts에 추가
import { offsiteConfirmedByDate } from "./offsite";
describe("offsiteConfirmedByDate", () => {
it("should keep only approved/report_pending/report_approved records", () => {
const byDate = offsiteConfirmedByDate([
{ workDate: "2026-07-01", status: "approved" },
{ workDate: "2026-07-02", status: "pending" },
{ workDate: "2026-07-03", status: "report_approved" },
{ workDate: "2026-07-04", status: "rejected" },
]);
expect(Object.keys(byDate)).toEqual(["2026-07-01", "2026-07-03"]);
});
it("should let a later record overwrite the same date and drop dateless rows", () => {
// characterization: locks current behavior, not proven-correct output
const byDate = offsiteConfirmedByDate([
{ workDate: "2026-07-01T00:00:00Z", status: "approved", id: 1 },
{ workDate: "2026-07-01", status: "report_pending", id: 2 },
{ status: "approved", id: 3 },
]);
expect(byDate["2026-07-01"].id).toBe(2);
expect(Object.keys(byDate)).toEqual(["2026-07-01"]);
});
});app.ts :3028-3039 교체:
this.attendanceOffsiteByDate = offsiteConfirmedByDate(
(offsite?.items || []) as Record<string, unknown>[],
);- [ ] Step 2: embedUrl 추출 + 테스트
// apps/admin/src/attendance/embed-url.ts
/** 임베드 iframe URL 생성 — app.ts embedUrlFor에서 추출. window/import.meta 의존은 ctx 주입. */
export interface EmbedConfig {
devPort: number;
path: string;
prodPrefix: string;
title: string;
icon: string;
}
export interface EmbedContext {
isDev: boolean;
protocol: string;
hostname: string;
origin: string;
selectedTeamId: string;
reloadKey: number;
}
export function embedUrl(cfg: EmbedConfig | undefined, ctx: EmbedContext): string {
if (!cfg) {
return "";
}
const base = ctx.isDev
? `${ctx.protocol}//${ctx.hostname}:${cfg.devPort}${cfg.path}`
: `${ctx.origin}${cfg.prodPrefix}${cfg.path}`;
const isTeamManager = cfg.prodPrefix === "/team-manager";
const isChromeless =
isTeamManager ||
cfg.prodPrefix === "/general-affairs" ||
cfg.prodPrefix === "/management-dashboard" ||
cfg.prodPrefix === "/hr-system";
const params: string[] = [];
if (isChromeless) {
params.push("embedded=1");
}
if (isTeamManager && ctx.selectedTeamId) {
params.push(`teamId=${encodeURIComponent(ctx.selectedTeamId)}`);
}
if (ctx.reloadKey > 0) {
params.push(`_=${ctx.reloadKey}`);
}
if (params.length === 0) {
return base;
}
const sep = base.includes("?") ? "&" : "?";
return `${base}${sep}${params.join("&")}`;
}// apps/admin/src/attendance/embed-url.test.ts
import { describe, expect, it } from "vitest";
import { embedUrl } from "./embed-url";
const CFG = {
work: { devPort: 4004, path: "/?att=1", prodPrefix: "/team-manager", title: "", icon: "" },
overtime: {
devPort: 4004,
path: "/work?noOffsite=1",
prodPrefix: "/team-manager",
title: "",
icon: "",
},
mgmt: { devPort: 4006, path: "/", prodPrefix: "/management-dashboard", title: "", icon: "" },
};
const ctx = (over: Partial<Parameters<typeof embedUrl>[1]> = {}) => ({
isDev: true,
protocol: "http:",
hostname: "localhost",
origin: "http://localhost:4001",
selectedTeamId: "",
reloadKey: 0,
...over,
});
describe("embedUrl", () => {
it("should append with & when the path already has a query", () => {
expect(embedUrl(CFG.work, ctx())).toBe("http://localhost:4004/?att=1&embedded=1");
expect(embedUrl(CFG.overtime, ctx())).toBe("http://localhost:4004/work?noOffsite=1&embedded=1");
});
it("should append with ? for a bare path", () => {
expect(embedUrl(CFG.mgmt, ctx())).toBe("http://localhost:4006/?embedded=1");
});
it("should pass teamId only to team-manager, URL-encoded", () => {
expect(embedUrl(CFG.work, ctx({ selectedTeamId: "t 1" }))).toBe(
"http://localhost:4004/?att=1&embedded=1&teamId=t%201",
);
expect(embedUrl(CFG.mgmt, ctx({ selectedTeamId: "t 1" }))).toBe(
"http://localhost:4006/?embedded=1",
);
});
it("should use origin+prodPrefix in prod mode", () => {
expect(embedUrl(CFG.work, ctx({ isDev: false }))).toBe(
"http://localhost:4001/team-manager/?att=1&embedded=1",
);
});
it("should append the cache-busting reload key and return '' for unknown cfg", () => {
expect(embedUrl(CFG.mgmt, ctx({ reloadKey: 2 }))).toBe("http://localhost:4006/?embedded=1&_=2");
expect(embedUrl(undefined, ctx())).toBe("");
});
});app.ts 위임:
embedUrlFor(key: string): string {
const { protocol, hostname, origin } = window.location;
return embedUrl(this.embedRegistry[key], {
isDev: import.meta.env.DEV,
protocol,
hostname,
origin,
selectedTeamId: this.selectedTeamId,
reloadKey: this.embedReloadKey,
});
},import: import { embedUrl } from "./attendance/embed-url"; / import { offsiteConfirmedByDate, ... } from "./attendance/offsite";
- [ ] Step 3: 검증 + Commit
pnpm --filter @hereby/admin exec vitest run src/attendance/offsite.test.ts src/attendance/embed-url.test.ts \
&& pnpm --filter @hereby/admin build
git add apps/admin/src/attendance apps/admin/src/app.ts
git commit -m "test(admin): extract offsite byDate mapping and embed URL builder
확정 외근 byDate 매핑(상태 Set·덮어쓰기 규칙)과 iframe URL 생성(?/& 분기·
teamId 인코딩·dev/prod prefix)을 순수 함수로 추출해 고정.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 12: admin shift 폼 어댑터 characterization (KI-1 — 수정 금지)
대상: openEditType(:5865-5893)의 Number(shift.workHours) || 8 폴백(KI-1: 0값을 8/1로 조용히 덮어씀), isActive 표시 변환(:5881), 저장 시 select 문자열 강제 변환(:5769·:5904 — coerceSelectBoolean으로 추출).
Files:
- Create:
apps/admin/src/attendance/forms.ts - Create:
apps/admin/src/app-shift-form.test.ts - Modify:
apps/admin/src/app.ts:5769,:5904(위임)
Interfaces:
Produces:
coerceSelectBoolean(v: unknown): boolean—String(v) !== "false"[ ] Step 1: characterization 테스트
// apps/admin/src/app-shift-form.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { coerceSelectBoolean } from "./attendance/forms";
import { createAdminApp } from "./test-support/harness";
type AdminApp = ReturnType<typeof createAdminApp>;
let app: AdminApp;
const GROUP = { id: "g1", name: "병동" };
beforeEach(() => {
app = createAdminApp();
});
describe("openEditType (KI-1)", () => {
it("should silently replace workHours=0 with 8 and breakHours=0 with 1", () => {
// characterization: locks current behavior, not proven-correct output
// KI-1: Number(x) || fallback 이 0을 삼킨다 — breakHours=0은 유효값인데 1로 덮임 (스펙 §8).
// 767b4f36 회귀와 동일 계열. 이 테스트가 깨지면 = 누군가 폴백을 수정한 것 → 의도 확인.
app.openEditType(GROUP, { id: "s1", workHours: 0, breakHours: 0 });
expect(app.shiftTypeForm.workHours).toBe(8);
expect(app.shiftTypeForm.breakHours).toBe(1);
});
it("should keep overtimeBreakHours=0 as 0 (|| 0 폴백은 동일값)", () => {
app.openEditType(GROUP, { id: "s1", overtimeBreakHours: 0 });
expect(app.shiftTypeForm.overtimeBreakHours).toBe(0);
});
it("should trim seconds from stored times and default missing times", () => {
app.openEditType(GROUP, { id: "s1", startTime: "22:30:00", endTime: "07:15:00" });
expect(app.shiftTypeForm.startTime).toBe("22:30");
expect(app.shiftTypeForm.endTime).toBe("07:15");
app.openEditType(GROUP, { id: "s2" });
expect(app.shiftTypeForm.startTime).toBe("09:00");
expect(app.shiftTypeForm.endTime).toBe("18:00");
});
it.each([
[false, false],
[undefined, true],
[true, true],
])("should map isActive %s to %s (!== false 규칙)", (raw, expected) => {
app.openEditType(GROUP, { id: "s1", isActive: raw });
expect(app.shiftTypeForm.isActive).toBe(expected);
});
it("should default applicableDays to weekdays-only when unset", () => {
app.openEditType(GROUP, { id: "s1" });
expect(app.shiftTypeForm.applicableDays).toEqual({
monday: true,
tuesday: true,
wednesday: true,
thursday: true,
friday: true,
saturday: false,
sunday: false,
});
});
});
describe("coerceSelectBoolean (저장 페이로드의 select 문자열 변환)", () => {
it.each([
["false", false],
[false, false],
["true", true],
[true, true],
[undefined, true], // characterization: locks current behavior, not proven-correct output
])("should coerce %s to %s", (raw, expected) => {
expect(coerceSelectBoolean(raw)).toBe(expected);
});
});- [ ] Step 2: forms.ts 작성 + 저장부 위임
// apps/admin/src/attendance/forms.ts
/** <select>가 문자열 "false"를 줄 수 있어 boolean으로 강제 변환. "false"만 false. */
export function coerceSelectBoolean(v: unknown): boolean {
return String(v) !== "false";
}app.ts saveGroup(:5769)과 saveType(:5904)의 인라인 식을 교체:
isActive: coerceSelectBoolean(this.shiftGroupForm.isActive), isActive: coerceSelectBoolean(this.shiftTypeForm.isActive),import: import { coerceSelectBoolean } from "./attendance/forms";. openEditType 본문은 수정하지 않는다 (KI-1 현행 유지 — characterization만).
- [ ] Step 3: 검증 + Commit
pnpm --filter @hereby/admin exec vitest run src/app-shift-form.test.ts \
&& pnpm --filter @hereby/admin build
git add apps/admin/src/attendance/forms.ts apps/admin/src/app-shift-form.test.ts apps/admin/src/app.ts
git commit -m "test(admin): characterize shift form adapter incl. KI-1 zero-swallow
openEditType의 0값→8/1 폴백(KI-1)·isActive 변환·시각 초 절삭·요일 기본값을
characterization으로 고정(수정 금지 결정). select 문자열 boolean 변환은
coerceSelectBoolean으로 추출해 저장 2곳 위임.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 13: team-manager 페이지네이션 수식 추출 + URL/모드 파싱 보강 + dashRange/formatKRW
대상: HTML 인라인 "N-M건" 수식(overview.html:96, overtime.html:156 — 현재 테스트 불가), totalPages 산수(:1185), initialWorkKindFromUrl(:210, 비export), embedded/teamId/mode 파싱(:795-812), attOnly/noOffsite/onlyOffsite getter(:1120-1130), _dashRange(:1153), formatKRW(:2808).
Files:
- Create:
apps/team-manager/src/attendance/pagination.ts+pagination.test.ts - Modify:
apps/team-manager/src/app.ts(모듈 수준 export 추가 + init 위임 + 메서드 위임) - Modify:
apps/team-manager/src/views/tabs/overview.html:96,apps/team-manager/src/views/tabs/overtime.html:156 - Modify:
apps/team-manager/src/app-helpers.test.ts(보강)
Interfaces:
Produces:
pageItemRangeLabel(page: number, pageSize: number, totalCount: number): string—"1-30"형식totalPagesFor(totalCount: number, pageSize: number): number—Math.max(1, Math.ceil(...))- app.ts 모듈 수준:
export function initialWorkKindFromUrl()(export 추가만),export function embedParamsFromSearch(search: string): { embedded: boolean; teamId: string; mode: "status" | "approval" | null },export function dashRange(from: string, to: string): { from?: Date; to?: Date } | undefined,export function formatKRW(value: number | string): string - 컴포넌트 메서드
pageItemRange(p: Pagination): string(뷰 2곳이 호출)
[ ] Step 1: pagination 모듈 (신규 코드 — 진짜 red→green)
먼저 테스트:
// apps/team-manager/src/attendance/pagination.test.ts
import { describe, expect, it } from "vitest";
import { pageItemRangeLabel, totalPagesFor } from "./pagination";
describe("pageItemRangeLabel", () => {
it.each([
[1, 30, 100, "1-30"],
[4, 30, 100, "91-100"], // 마지막 페이지 — totalCount로 클램프
[2, 30, 31, "31-31"],
])("should label page %s (size %s, total %s) as %s", (page, size, total, expected) => {
expect(pageItemRangeLabel(page, size, total)).toBe(expected);
});
it("should degenerate to 1-0 for an empty list (뷰에서 x-show로 숨김)", () => {
// characterization: locks current behavior, not proven-correct output — HTML 수식 그대로
expect(pageItemRangeLabel(1, 30, 0)).toBe("1-0");
});
});
describe("totalPagesFor", () => {
it.each([
[0, 30, 1],
[30, 30, 1],
[31, 30, 2],
])("should compute %s items / size %s as %s pages", (total, size, expected) => {
expect(totalPagesFor(total, size)).toBe(expected);
});
});실행 → FAIL (모듈 없음) 확인 후 구현:
// apps/team-manager/src/attendance/pagination.ts
/** 번호형 페이지네이션의 "N-M건" 라벨 — 뷰 인라인 수식을 추출 (overview/overtime). */
export function pageItemRangeLabel(page: number, pageSize: number, totalCount: number): string {
const start = (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, totalCount);
return `${start}-${end}`;
}
/** 목록 총 페이지 수 — 최소 1. */
export function totalPagesFor(totalCount: number, pageSize: number): number {
return Math.max(1, Math.ceil(totalCount / pageSize));
}컴포넌트 메서드 추가 (app.ts, pageRange 옆):
/** "N-M건" 표기 — 뷰 인라인 수식 대체. */
pageItemRange(p: Pagination): string {
return pageItemRangeLabel(p.page, this.pageSize, p.totalCount);
},loadDashAttendance(:1185)의 Math.max(1, Math.ceil(total / this.pageSize))를 totalPagesFor(total, this.pageSize)로 교체. 뷰 2곳의 두 span을 하나로 교체:
<!-- overview.html:96 (before) -->
<span x-text="((dashAttPaging.page - 1) * pageSize) + 1"></span>-<span x-text="Math.min(dashAttPaging.page * pageSize, dashAttPaging.totalCount)"></span>건
<!-- overview.html (after) -->
<span x-text="pageItemRange(dashAttPaging)"></span>건<!-- overtime.html:156 (before) -->
<span x-text="((overtimePagination.page - 1) * pageSize) + 1"></span>-<span x-text="Math.min(overtimePagination.page * pageSize, overtimePagination.totalCount)"></span>건
<!-- overtime.html (after) -->
<span x-text="pageItemRange(overtimePagination)"></span>건(leave/documents/certificates/incentives의 동일 수식 4곳은 근태 도메인 밖 — 후속 정리 후보로 남긴다.)
- [ ] Step 2: app.ts 모듈 수준 함수 추가/추출
initialWorkKindFromUrl(:210)에 export 키워드 추가. init()의 :795-812를 다음 모듈 함수로 추출하고 위임:
/** admin iframe 래퍼가 붙이는 embed 신호 — URL 검색 문자열에서 파싱. */
export function embedParamsFromSearch(search: string): {
embedded: boolean;
teamId: string;
mode: "status" | "approval" | null;
} {
const params = new URLSearchParams(search);
const rawMode = params.get("mode");
return {
embedded: params.get("embedded") === "1",
teamId: params.get("teamId") ?? "",
mode: rawMode === "status" || rawMode === "approval" ? rawMode : null,
};
}init() 해당 구간 교체:
const embed = embedParamsFromSearch(window.location.search);
if (embed.embedded) {
this.embeddedMode = true;
}
if (embed.teamId) {
this.selectedTeamId = embed.teamId;
}
// `mode=status|approval` — admin LNB가 연장 근무 현황/결재로 같은 화면을 나눠 embed.
if (embed.mode === "status") {
this.overtimeMode = "status";
this.offsiteMode = "status";
} else if (embed.mode === "approval") {
this.overtimeMode = "approval";
this.offsiteMode = "approval";
}_dashRange(:1153)와 formatKRW(:2808)를 모듈 수준으로 이동(본문 그대로, 이름 dashRange/formatKRW), 메서드는 위임:
_dashRange(from: string, to: string) {
return dashRange(from, to);
}, formatKRW(value: number | string): string {
return formatKRW(value);
},- [ ] Step 3: app-helpers.test.ts 보강
기존 파일에 describe 추가:
import {
dashRange,
embedParamsFromSearch,
formatKRW,
initialWorkKindFromUrl,
} from "./app";
describe("initialTabFromUrl — /overtime·/offsite → work 별칭 (스펙 §4.2 #5 보강)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it.each([
["/overtime", "work"],
["/offsite", "work"],
])("should alias %s to the unified %s tab (admin embed 하위 호환)", (pathname, expected) => {
vi.stubGlobal("window", { location: { pathname } });
expect(initialTabFromUrl()).toBe(expected);
});
});
describe("initialWorkKindFromUrl (연장/외근 별칭 진입)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it.each([
["/offsite", "offsite"],
["/overtime", "overtime"],
["/work", "overtime"],
["/", "overtime"],
])("should resolve %s to %s", (pathname, expected) => {
vi.stubGlobal("window", { location: { pathname } });
expect(initialWorkKindFromUrl()).toBe(expected);
});
});
describe("embedParamsFromSearch (admin iframe URL 계약)", () => {
it("should keep mode null when absent — 연장 탭이 결재 모드로 뜨는 근거 (스펙 §3.1)", () => {
expect(embedParamsFromSearch("?att=1")).toEqual({ embedded: false, teamId: "", mode: null });
});
it.each([
["?mode=status", "status"],
["?mode=approval", "approval"],
["?mode=garbage", null],
])("should parse %s as mode %s", (search, mode) => {
expect(embedParamsFromSearch(search).mode).toBe(mode);
});
it("should parse embedded flag and decoded teamId", () => {
expect(embedParamsFromSearch("?embedded=1&teamId=t%201")).toEqual({
embedded: true,
teamId: "t 1",
mode: null,
});
});
});
describe("dashRange", () => {
it("should return undefined when both bounds are empty", () => {
expect(dashRange("", "")).toBeUndefined();
});
it("should build partial ranges with Date objects (UTC 자정 파싱)", () => {
// characterization: locks current behavior, not proven-correct output
// new Date("YYYY-MM-DD")는 UTC 자정 — org tz 규칙과 어긋나는 현행(후속 판단).
expect(dashRange("2026-07-01", "")).toEqual({
from: new Date("2026-07-01"),
to: undefined,
});
});
});
describe("formatKRW", () => {
it.each([
[1234567, "1,234,567"],
[0, "0"],
[-1000, "-1,000"],
["1234.5", "1,234.5"],
])("should format %s as %s", (input, expected) => {
expect(formatKRW(input)).toBe(expected);
});
it('should disguise NaN as "0"', () => {
// characterization: locks current behavior, not proven-correct output
expect(formatKRW("abc")).toBe("0");
});
});attOnly/noOffsite getter (인스턴스 — window stub 필요):
import { createTmApp } from "./test-support/harness";
describe("attOnly / noOffsite getters (iframe 모드 URL 계약)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("should read att=1 and noOffsite=1 from the current URL", () => {
vi.stubGlobal("window", { location: { pathname: "/", search: "?att=1&noOffsite=1" } });
const app = createTmApp();
expect(app.attOnly).toBe(true);
expect(app.noOffsite).toBe(true);
expect(app.onlyOffsite).toBe(false);
});
});- [ ] Step 4: 검증 + Commit
pnpm --filter @hereby/team-manager exec vitest run src/attendance/pagination.test.ts src/app-helpers.test.ts \
&& pnpm --filter @hereby/team-manager build
git add apps/team-manager/src
git commit -m "test(team-manager): extract pagination label, embed URL parsing, KRW format
뷰 인라인 'N-M건' 수식을 pagination.ts로 추출(뷰 2곳 교체), embed URL
파라미터 파싱을 embedParamsFromSearch로 추출해 mode 부재→approval 기본
계약(§3.1)을 고정. dashRange UTC 파싱·formatKRW NaN 위장은 characterization.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 14: 앱 경계 계약 테스트 (postMessage 리터럴 + iframe URL 파라미터)
스펙 §4.3. postMessage 5종 리터럴은 admin 파일에서 전담(수신 4종 + 송신 back — File Structure의 편차 1 참고), tm 파일은 URL 파라미터 계약 전담 (Task 13에서 파싱 계약을 이미 고정 — 여기서는 admin측 생성부와 맞물리는 registry를 고정).
Files:
Create:
apps/admin/src/app-embed-contract.test.tsCreate:
apps/team-manager/src/app-embed-contract.test.ts[ ] Step 1: admin 계약 테스트
// apps/admin/src/app-embed-contract.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { createAdminApp } from "./test-support/harness";
type AdminApp = ReturnType<typeof createAdminApp>;
let app: AdminApp;
beforeEach(() => {
app = createAdminApp();
});
describe("embedRegistry (tm iframe URL 계약 — 파라미터 리터럴)", () => {
it("should embed 근무 현황 as attOnly", () => {
expect(app.embedRegistry["attendance-work"].path).toBe("/?att=1");
expect(app.embedRegistry["attendance-work"].devPort).toBe(4004);
});
it("should embed 연장근무 현황 without a mode param (결재 모드 계약, 스펙 §3.1)", () => {
// mode 파라미터가 없으므로 tm 기본값 approval — "현황" 탭이지만 결재 UI가 뜬다.
// 이 경로에 mode=status를 "고치듯" 추가하면 이 테스트가 깨져 의도 확인을 강제한다.
expect(app.embedRegistry["attendance-overtime"].path).toBe("/work?noOffsite=1");
});
});
describe("postMessage 수신 계약 (hereby:tm:*)", () => {
const send = (data: Record<string, unknown>) =>
window.dispatchEvent(new MessageEvent("message", { data }));
beforeEach(() => {
app.bindEmbedMessageListener();
});
it("should update back-button state on hereby:tm:nav", () => {
send({ type: "hereby:tm:nav", canGoBack: true, activeTabLabel: "근무 관리" });
expect(app.embedCanGoBack).toBe(true);
expect(app.embedNavTitle).toBe("근무 관리");
});
it("should toggle the full-page dim on hereby:tm:modal", () => {
send({ type: "hereby:tm:modal", open: true });
expect(app.tmModalOpen).toBe(true);
send({ type: "hereby:tm:modal", open: false });
expect(app.tmModalOpen).toBe(false);
});
it("should refresh the LNB badge on hereby:tm:doc-approval-changed", () => {
let refreshed = false;
app.loadDocApprovalPending = async () => {
refreshed = true;
};
send({ type: "hereby:tm:doc-approval-changed" });
expect(refreshed).toBe(true);
});
it("should navigate the parent on hereby:tm:navigate", () => {
const calls: string[] = [];
app.switchTab = (tab: string) => calls.push(`tab:${tab}`);
app.switchAttendanceTab = (tab: string) => calls.push(`att:${tab}`);
send({ type: "hereby:tm:navigate", route: "offsite" });
expect(calls).toEqual(["tab:attendance", "att:offsite"]);
});
it("should ignore non-hereby messages", () => {
send({ type: "other:thing", canGoBack: true });
expect(app.embedCanGoBack).toBe(false);
});
});
describe("postMessage 송신 계약 (admin→tm back)", () => {
it("should post hereby:tm:back into the embed iframe", () => {
const iframe = document.createElement("iframe");
iframe.setAttribute("data-embed-iframe", "");
document.body.appendChild(iframe);
const posted: unknown[] = [];
// happy-dom의 contentWindow에 postMessage 스파이를 심는다
// biome-ignore lint/suspicious/noExplicitAny: test spy on iframe window
(iframe.contentWindow as any).postMessage = (msg: unknown) => posted.push(msg);
app.goBackInEmbed();
expect(posted).toEqual([{ type: "hereby:tm:back" }]);
iframe.remove();
});
});주의: happy-dom에서 iframe.contentWindow가 null이면(버전에 따라 다름) 이 케이스는 다음 fallback으로 교체: iframe 대신 document.querySelector를 그대로 두고 goBackInEmbed가 iframe 부재 시 조용히 no-op하는 현행을 어서션(expect(() => app.goBackInEmbed()).not.toThrow())하고, back 리터럴 자체는 admin 수신 계약과 E2E(E2-3 인접 여정)로 커버됨을 주석으로 남긴다.
- [ ] Step 2: tm 계약 테스트
// apps/team-manager/src/app-embed-contract.test.ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { embedParamsFromSearch } from "./app";
import { createTmApp } from "./test-support/harness";
/**
* tm측 앱 경계 계약 — admin embedRegistry가 생성하는 URL을 tm 파서가 읽는다.
* (postMessage 송신부는 전부 `window.parent !== window` 가드라 happy-dom에서
* 도달 불가 — 리터럴 5종은 admin app-embed-contract.test.ts가 전담하고,
* 실제 cross-frame 배선은 E2E PR-E2가 커버한다.)
*/
describe("admin iframe URL 계약 (tm 파서측)", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("should read the exact params admin's attendance-work embed sends", () => {
// admin embedRegistry["attendance-work"].path = "/?att=1" (+embedded=1&teamId=)
vi.stubGlobal("window", {
location: { pathname: "/", search: "?att=1&embedded=1&teamId=team-9" },
});
const app = createTmApp();
expect(app.attOnly).toBe(true);
expect(embedParamsFromSearch(window.location.search)).toEqual({
embedded: true,
teamId: "team-9",
mode: null, // mode 부재 → overtimeMode 기본 approval 유지 (스펙 §3.1)
});
});
it("should read the attendance-overtime embed params on the /work alias", () => {
// admin embedRegistry["attendance-overtime"].path = "/work?noOffsite=1"
vi.stubGlobal("window", {
location: { pathname: "/work", search: "?noOffsite=1&embedded=1" },
});
const app = createTmApp();
expect(app.noOffsite).toBe(true);
expect(app.activeTab).toBe("work");
});
});- [ ] Step 3: 검증 + Commit
pnpm --filter @hereby/admin exec vitest run src/app-embed-contract.test.ts \
&& pnpm --filter @hereby/team-manager exec vitest run src/app-embed-contract.test.ts \
&& pnpm --filter @hereby/admin build && pnpm --filter @hereby/team-manager build
git add apps/admin/src/app-embed-contract.test.ts apps/team-manager/src/app-embed-contract.test.ts
git commit -m "test(attendance): pin admin-tm embed contracts (postMessage + URL params)
hereby:tm:* 5종 리터럴을 admin 수신 4종 dispatch + back 송신으로 고정,
iframe URL 파라미터(att/noOffsite/embedded/teamId/mode 부재→approval)를
양앱에서 교차 고정. tm 송신부는 parent 가드로 unit 도달 불가 — E2E가 커버.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"Task 15: 최종 게이트 + KI-6 문서화 + PR
Files:
Modify:
docs/superpowers/specs/2026-07-03-attendance-frontend-tests-design.md§8 (KI-6 행 추가)[ ] Step 1: 스펙 §8 Known Issues에 KI-6 추가
§8 표 끝에 행 추가:
| KI-6 | `OvertimeCategory.HOLIDAY_NIGHT("holiday_night")` 라벨 누락 — admin `overtimeCategoryLabel` 맵과 tm `CATEGORY_KEYS` 모두 4종만 매핑, raw enum이 화면 노출. hr-system 433fe910 실버그와 동일 패턴 (PR-U 라벨 전수 테스트로 발견, characterization 고정) | admin `app.ts` overtimeCategoryLabel / tm `app.ts` CATEGORY_KEYS |- [ ] Step 2: 전체 게이트
pnpm --filter @hereby/admin test && pnpm --filter @hereby/team-manager test
pnpm lint:check && pnpm buildExpected: 전부 green. lint 실패 시 pnpm lint:fix 후 재확인. 루트 build까지 green이어야 완료 (레포 규칙).
- [ ] Step 3: Commit + PR
git add docs/superpowers/specs/2026-07-03-attendance-frontend-tests-design.md
git commit -m "docs: record KI-6 holiday_night label gap found by label sweep
라벨 전수 계약 테스트가 발견한 OvertimeCategory HOLIDAY_NIGHT 라벨 누락을
스펙 §8 Known Issues에 KI-6으로 기록 (양앱 공통, characterization 고정됨).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
git push -u origin test/attendance-unit
gh pr create --base develop --title "test: attendance-domain unit tests with progressive extraction (PR-U)" --body "$(cat <<'EOF'
## Summary
- Add instance-test harness (Alpine store stub) enabling component-object testing for both giant app.ts files
- Pin current behavior with characterization tests, then progressively extract high-risk attendance logic into `attendance/` helper modules (admin 8 + tm 4 extractions; HTML binding surface unchanged)
- Add exhaustive enum→label contract tests (found KI-6: `holiday_night` label missing in both apps — pinned, not fixed) and admin↔tm embed contracts (postMessage literals, iframe URL params, date-preset copy sync)
## 요약
- 근태 현황 도메인(근태 수정 재계산, 지문 대조, 날짜 프리셋, 일괄결재 규칙, 라벨 맵, embed 계약)에 단위 테스트 도입 — 스펙 `docs/superpowers/specs/2026-07-03-attendance-frontend-tests-design.md` Track U
- characterization으로 현행(버그 포함: KI-1 0값 삼킴, KI-6 라벨 누락)을 고정하고, helpers 모듈 추출 후 동일 테스트 green으로 동작 보존을 증명
- HTML이 부르는 메서드 이름은 전부 유지 (app.ts는 위임만), 뷰 수정은 tm 페이지네이션 수식 2곳뿐
## Test plan
- [ ] `pnpm --filter @hereby/admin test` green
- [ ] `pnpm --filter @hereby/team-manager test` green
- [ ] `pnpm lint:check && pnpm build` green
- [ ] admin 근태 현황 4탭 수동 스모크 (근무/연차/연장/외근 로드, 근태 수정 모달 재계산, 외근 일괄 선택)
- [ ] tm 대시보드 출퇴근 카드 페이지네이션 "N-M건" 표기 확인 (overview/overtime)
EOF
)"Self-Review 체크 결과 (계획 작성 시점)
- 스펙 커버리지: §4 절차(스텁 선행→characterization→추출) = Task 1 + 각 태스크 Step 구조. §4.1 #1→T2, #2→T3, #3→T4, #4→T5, #5→T7, #6→T9, #7→T9, #8→T9, #9→T10, #10→T11, #11→T11, #12→T12. §4.2 #1→T4, #2→T13, #3→T8, #4→T6, #5→T13, #6→T13. §4.3→T14(+T13 파싱). §8 KI-1→T12, KI-6(신규)→T5/T6/T15.
- 명시된 편차 2건: tm postMessage 송신부 unit 도달 불가(→admin 전담+E2E), isActive 저장 변환의 함수 추출. File Structure 절에 기록.
- 타입/이름 일관성:
createAdminApp/createTmApp(T1)을 모든 인스턴스 태스크가 동일 이름으로 소비.resolveDatePresetRange(preset, today?)시그니처 T4에서 정의, sync 테스트가 동일 시그니처 사용. - characterization 기대값 주의: 이 계획의 기대값은 소스 코드 정독으로 도출했다. Step "실행 — PASS 확인"에서 불일치가 나오면 관찰값이 정답 — 기대값을 교정하고 주석을 남긴다 (코드 수정 금지).