Skip to content

근태 현황 단위 테스트 (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개가 전부다.

스펙 대비 이행 편차 (승인 필요 없음, 기록용):

  1. §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)가 커버.
  2. §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의 무관한 커밋 유입 방지).

bash
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: 베이스라인 확인
bash
pnpm --filter @hereby/admin test && pnpm --filter @hereby/team-manager test

Expected: 기존 테스트 전부 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-5 formatTimeOnly 하드코딩과 일관), t(key)=key(i18n 키 자체가 기대값이 된다), sdk={}.

  • 이후 모든 인스턴스 태스크가 이 두 함수를 consume한다.

  • [ ] Step 1: admin 하네스 작성

ts
// 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은 동일 구조로 팩토리만 다르다:

ts
// 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: 스모크 테스트 작성
ts
// 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");
  });
});
ts
// 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 크래시 여부가 관건
bash
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.ts

Expected: 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 허용 대상).

ts
// 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: () => {},
}));
ts
// apps/admin/vitest.config.ts 의 test 블록에 추가 (fallback 시에만)
    setupFiles: ["./src/test-support/setup.ts"],
  • [ ] Step 4: 빌드 검증
bash
pnpm --filter @hereby/admin build && pnpm --filter @hereby/team-manager build

Expected: 둘 다 성공.

  • [ ] Step 5: Commit
bash
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>): number
    • expectedHoursFromTimes(checkIn: string, checkOut: string, breakHours: number): number | null
    • scheduledSplitFromTimes(checkIn: string, checkOut: string, breakHours: number, isHoliday: boolean): { regular: number; night: number; holidayRegular: number; holidayNight: number } | null
  • [ ] Step 1: characterization 테스트 작성 (인스턴스 대상)

ts
// 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: 실행 — 현행 고정 확인
bash
pnpm --filter @hereby/admin exec vitest run src/attendance/edit-hours.test.ts

Expected: PASS (characterization은 현행이 스펙). 실패하는 케이스가 있으면 기대값을 관찰된 실제 출력으로 수정하고 그 케이스에 발견 사실을 주석으로 남긴다 — 코드를 고치지 않는다.

  • [ ] Step 3: 순수 함수 추출
ts
// 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 주석은 유지):

ts
    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는 그대로 둔다.)

ts
    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 추가:

ts
import {
  editHoursSum,
  expectedHoursFromTimes,
  scheduledSplitFromTimes,
} from "./attendance/edit-hours";
  • [ ] Step 4: 추출 함수 직접 엣지 테스트 추가 (같은 테스트 파일 하단)
ts
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: 검증
bash
pnpm --filter @hereby/admin exec vitest run src/attendance/edit-hours.test.ts \
  && pnpm --filter @hereby/admin build

Expected: 전체 PASS + 빌드 성공. characterization 블록이 추출 후에도 green = 위임이 동작 보존을 증명.

  • [ ] Step 6: Commit
bash
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 = 5
    • interface SecomDeps { formatDateISO(value: string): string; formatTimeOnly(value: string): string }
    • localMinutes(target: unknown, deps: SecomDeps): number | null
    • secomMinutes(dt: string): number | null
    • matchSecomEvent(record: Record<string, unknown>, which: "in" | "out", events: Record<string, unknown>[], deps: SecomDeps): Record<string, unknown> | null
    • checkoutGuess(record: Record<string, unknown>, events: Record<string, unknown>[], deps: SecomDeps): Record<string, unknown> | null (attnMethod 게이트는 컴포넌트에 잔류)
  • [ ] Step 1: characterization 테스트 (인스턴스)

ts
// 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)
bash
pnpm --filter @hereby/admin exec vitest run src/attendance/secom-match.test.ts

Expected: PASS. (불일치 시 기대값을 관찰값으로 교정 — 코드 수정 금지.)

  • [ ] Step 3: 추출

apps/admin/src/attendance/secom-match.ts — 원본 로직을 그대로 옮기되 Alpine.store("hereby").formatDateISO / this.formatTimeOnly / this.secomEvents / this.SECOM_MATCH_MINUTES를 인자·상수로 치환:

ts
// 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 위임:

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)
ts
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
bash
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 인자화)

ts
// 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곳 무수정):

ts
    resolveDatePresetRange(preset: string): { from: string; to: string } {
      return resolveDatePresetRange(preset);
    },

import (양 앱): import { resolveDatePresetRange } from "./attendance/date-preset"; — 메서드명과 함수명이 같지만 메서드 본문에서 모듈 함수를 참조하는 것은 유효하다(shadowing 없음 — 메서드는 객체 리터럴 프로퍼티).

  • [ ] Step 2: 직접 테스트 (admin 쪽)
ts
// 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 선례)
ts
// 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에 둔다:

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
bash
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: 테스트 작성

ts
// 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: 실행
bash
pnpm --filter @hereby/admin exec vitest run src/app-attendance-labels.test.ts

Expected: PASS. KI-6 케이스가 FAIL하면(= 누군가 이미 라벨을 추가함) 그 케이스만 실제 라벨로 교체하고 KI-6 주석 제거.

  • [ ] Step 3: 빌드 + Commit
bash
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: 테스트 작성

ts
// 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
bash
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[]): boolean
    • offsiteSelectAllIds(records: Record<string, unknown>[], currentStep: string): string[]
    • (Task 11이 같은 파일에 offsiteConfirmedByDate 추가)
  • [ ] Step 1: characterization 테스트 (인스턴스)

ts
// 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 확인
bash
pnpm --filter @hereby/admin exec vitest run src/attendance/offsite.test.ts

Expected: PASS.

  • [ ] Step 3: 추출
ts
// 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 위임:

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
bash
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): boolean
    • selectAllIds(records: Record<string, unknown>[], currentStep: ApprovalStep | null): string[] — 대기 우선
  • [ ] Step 1: characterization 테스트 (인스턴스 — describe.each로 연장·외근 동시)

ts
// 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 확인 후 추출
ts
// 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개 메서드 본문 교체 — 중복 주석 블록은 하나로):

ts
    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 교체):

ts
    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
ts
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);
  });
});
bash
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 + 추출

ts
// 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);
  });
});
ts
// 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 위임:

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 인스턴스 테스트
ts
// 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
bash
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): string
    • attendanceRowRed(r: Record<string, unknown>, holidayName: (date: unknown) => string): boolean
    • checkOutDayOffset(r: Record<string, unknown>, formatDateISO: (v: string) => string): number
    • attnEditChanges(h: Record<string, unknown>, formatTime: (v: unknown) => string): { label: string; before: string; after: string }[]
  • [ ] Step 1: characterization 테스트 (인스턴스)

ts
// 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 확인 후 추출
ts
// 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 위임:

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
bash
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에 추가 + 테스트

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;
}
ts
// 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 교체:

ts
        this.attendanceOffsiteByDate = offsiteConfirmedByDate(
          (offsite?.items || []) as Record<string, unknown>[],
        );
  • [ ] Step 2: embedUrl 추출 + 테스트
ts
// 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("&")}`;
}
ts
// 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 위임:

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
bash
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 테스트

ts
// 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 작성 + 저장부 위임
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)의 인라인 식을 교체:

ts
          isActive: coerceSelectBoolean(this.shiftGroupForm.isActive),
ts
          isActive: coerceSelectBoolean(this.shiftTypeForm.isActive),

import: import { coerceSelectBoolean } from "./attendance/forms";. openEditType 본문은 수정하지 않는다 (KI-1 현행 유지 — characterization만).

  • [ ] Step 3: 검증 + Commit
bash
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)

먼저 테스트:

ts
// 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 (모듈 없음) 확인 후 구현:

ts
// 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 옆):

ts
    /** "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을 하나로 교체:

html
<!-- 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>건
html
<!-- 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를 다음 모듈 함수로 추출하고 위임:

ts
/** 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() 해당 구간 교체:

ts
      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), 메서드는 위임:

ts
    _dashRange(from: string, to: string) {
      return dashRange(from, to);
    },
ts
    formatKRW(value: number | string): string {
      return formatKRW(value);
    },
  • [ ] Step 3: app-helpers.test.ts 보강

기존 파일에 describe 추가:

ts
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 필요):

ts
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
bash
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.ts

  • Create: apps/team-manager/src/app-embed-contract.test.ts

  • [ ] Step 1: admin 계약 테스트

ts
// 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 계약 테스트
ts
// 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
bash
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 표 끝에 행 추가:

markdown
| 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: 전체 게이트
bash
pnpm --filter @hereby/admin test && pnpm --filter @hereby/team-manager test
pnpm lint:check && pnpm build

Expected: 전부 green. lint 실패 시 pnpm lint:fix 후 재확인. 루트 build까지 green이어야 완료 (레포 규칙).

  • [ ] Step 3: Commit + PR
bash
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 확인"에서 불일치가 나오면 관찰값이 정답 — 기대값을 교정하고 주석을 남긴다 (코드 수정 금지).