Newer
Older
client_dwatch / lib / db / __tests__ / fixture-reports.test.ts
import { describe, expect, it } from "vitest";

import {
  filterFixtureFacilityPerformance,
  filterFixtureReportKpis,
  filterFixtureReportRows,
} from "@/lib/db/fixture-reports";
import { facilityPerformance, reportKpis } from "@/lib/fixtures";
import { REPORT_KPI_CUSTOMER_ID } from "@/lib/fixtures/reports";
import { todayJst } from "@/lib/format/date";
import { shiftPeriod } from "@/lib/format/period";
import {
  selectFacilityPerformanceRows,
  selectKpiRow,
  type ReportQuery,
} from "@/lib/reports/query";
import type {
  FacilityPerformanceRow,
  ReportDbQuery,
  ReportKpiDbQuery,
  ReportKpiRow,
} from "@/lib/types";

/**
 * fixtures 版レポート4メソッドの絞り込み(指示書AC ★段階5★)。
 *
 * ★★何を守るためのテストか★★
 *   1. ★`USE_FIXTURES` の true/false で件数が変わらないこと★(禁止事項6)—
 *      Prisma 版が SQL に降ろした条件と ★同じ意味★ をここで再現している
 *   2. ★★`facility_ids` の `null` と `[]` が別物であること★★(★罠1 / 罠9★)—
 *      ★`[]` を「条件なし」と読んで全件返す実装になっていないか★
 *   3. ★絞り込み規則の「正」(`selectXxxRows()` / `selectKpiRow()`)と一致すること★(C-5)
 */

function dbQuery(partial: Partial<ReportDbQuery> = {}): ReportDbQuery {
  // ★`facility_ids: null` = 施設で絞らない★(指示書AK 段階4b で `ReportDbQuery` に足った。
  //   ★`[]`(0件ヒット)と混同しないこと★ — 既定を `[]` にすると全テストが0件になる)
  return {
    period: "weekly",
    year: 2026,
    month: 9,
    week: 2,
    facility_ids: null,
    ...partial,
  };
}

/**
 * `reportKpis()` 用(指示書AK ★段階6★)。★`customer_id` は必須★ —
 * ★「顧客が決まらない」状態でデータ層を呼ばない契約★(`ReportKpiDbQuery`)。
 * ★既定は fixtures のKPI行が属する屋号1★。
 */
function kpiDbQuery(partial: Partial<ReportKpiDbQuery> = {}): ReportKpiDbQuery {
  return { ...dbQuery(), customer_id: REPORT_KPI_CUSTOMER_ID, ...partial };
}

/** `selectXxxRows()`(絞り込み規則の「正」)に渡す形。期間以外は素通しの値にする */
function memoryQuery(db: ReportDbQuery): ReportQuery {
  return {
    ...db,
    facilityName: "",
    serial: "",
    status: "all",
    // ★絞り込まない(指示書AK 段階2 で足った2つ。`null` = 条件なし)★
    groupId: null,
    customerId: null,
    limit: 100,
  };
}

const KPI_ROWS: ReportKpiRow[] = [
  { period: "weekly", year: 2026, month: 9, week: 2, kpi: reportKpis[0].kpi },
  { period: "weekly", year: 2026, month: 9, week: 1, kpi: reportKpis[0].kpi },
  // ★月次は week=0(D-072)。★週次の第0週ではない★ので weekly とは別行になる★
  { period: "monthly", year: 2026, month: 9, week: 0, kpi: reportKpis[0].kpi },
  { period: "monthly", year: 2026, month: 8, week: 0, kpi: reportKpis[0].kpi },
];

describe("filterFixtureReportRows(期間キーで絞る)", () => {
  it("要求された期間の行だけを返す", () => {
    expect(filterFixtureReportRows(KPI_ROWS, dbQuery())).toEqual([KPI_ROWS[0]]);
  });

  it("★前期(1つ前の期間)の行は返さない★(Prisma 版は前期比のために読むが、返しはしない)", () => {
    const rows = filterFixtureReportRows(KPI_ROWS, dbQuery());
    expect(rows.some((row) => row.week === 1)).toBe(false);
  });

  it("★monthly と weekly は混ざらない★(monthly の week=0 は予約値。D-072)", () => {
    expect(
      filterFixtureReportRows(KPI_ROWS, dbQuery({ period: "monthly", week: 0 }))
    ).toEqual([KPI_ROWS[2]]);
  });

  it("該当が無ければ空配列(404 にしない。指示書AC 段階5 期待値表 #5)", () => {
    expect(filterFixtureReportRows(KPI_ROWS, dbQuery({ year: 2020 }))).toEqual([]);
  });
});

describe("filterFixtureFacilityPerformance(期間キー + 施設ID)", () => {
  const ROWS: FacilityPerformanceRow[] = [
    { period: "weekly", year: 2026, month: 9, week: 2, item: { ...facilityPerformance[0].item, facility_id: 1 } },
    { period: "weekly", year: 2026, month: 9, week: 2, item: { ...facilityPerformance[0].item, facility_id: 2 } },
    { period: "weekly", year: 2026, month: 9, week: 1, item: { ...facilityPerformance[0].item, facility_id: 1 } },
  ];

  it("★`facility_ids: null` → 施設で絞らない★", () => {
    expect(
      filterFixtureFacilityPerformance(ROWS, { ...dbQuery(), facility_ids: null })
    ).toHaveLength(2);
  });

  it("★★`facility_ids: []` → 0件★★(★罠9。「条件なし」と読んで全件返していないか★)", () => {
    expect(
      filterFixtureFacilityPerformance(ROWS, { ...dbQuery(), facility_ids: [] })
    ).toEqual([]);
  });

  it("指定した施設だけを返す", () => {
    const rows = filterFixtureFacilityPerformance(ROWS, {
      ...dbQuery(),
      facility_ids: [2],
    });
    expect(rows.map((row) => row.item.facility_id)).toEqual([2]);
  });

  it("★期間の絞り込みと AND になる★(同じ施設でも期間が違えば落ちる)", () => {
    const rows = filterFixtureFacilityPerformance(ROWS, {
      ...dbQuery({ week: 1 }),
      facility_ids: [1],
    });
    expect(rows).toHaveLength(1);
    expect(rows[0].week).toBe(1);
  });
});

describe("★絞り込み規則の「正」と一致する★(指示書AC C-5。fixtures の実データで突き合わせる)", () => {
  it("facilityPerformance: fixtures 版の絞り込み = `selectFacilityPerformanceRows()`", () => {
    for (const row of facilityPerformance) {
      const q = dbQuery({
        period: row.period,
        year: row.year,
        month: row.month,
        week: row.week,
      });
      expect(
        filterFixtureFacilityPerformance(facilityPerformance, {
          ...q,
          facility_ids: null,
        }).map((r) => r.item)
      ).toEqual(selectFacilityPerformanceRows(facilityPerformance, memoryQuery(q)));
    }
  });

  it("reportKpis: fixtures 版の絞り込み = `selectKpiRow()`", () => {
    for (const row of reportKpis) {
      const q = kpiDbQuery({
        period: row.period,
        year: row.year,
        month: row.month,
        week: row.week,
      });
      const filtered = filterFixtureReportKpis(reportKpis, q);
      expect(selectKpiRow(filtered, memoryQuery(q))).toEqual(
        selectKpiRow(reportKpis, memoryQuery(q))
      );
    }
  });

  /**
   * ★年次(`month=0, week=0`)は月次(`week=0`)と別キー★(D-151。CLAUDE.md §4.7)
   *
   * ★何を守るためのテストか★: fixtures に今年・前年の行が無く、`USE_FIXTURES=true` でだけ
   *   年次のKPIが4つとも null になっていた(監督の実測 2026-10-01。禁止事項6)。
   *   月次の行も `week=0` を持つので、★年次の要求に月次の行が当たらない/その逆も無い★ことを
   *   fixtures の実データで確かめる。
   */
  it("★年次の要求には年次の行だけが当たり、月次の week=0 行と取り違えない(今年・前年とも KPI が出る)★", () => {
    const thisYear = Number(todayJst().slice(0, 4));
    const lastYear = shiftPeriod("yearly", thisYear, 0, 0, -1);
    const monthlyRows = reportKpis.filter((row) => row.period === "monthly");
    // 前提: 取り違えの相手になる月次(week=0)の行が fixtures に実在する
    expect(monthlyRows.length).toBeGreaterThan(0);
    monthlyRows.forEach((row) => expect(row.week).toBe(0));

    for (const year of [thisYear, lastYear.year]) {
      const q = kpiDbQuery({ period: "yearly", year, month: 0, week: 0 });
      const filtered = filterFixtureReportKpis(reportKpis, q);
      expect(filtered, `${year}年`).toHaveLength(1);
      expect(filtered[0]).toMatchObject({ period: "yearly", year, month: 0, week: 0 });
      // ★監督の実測で null だった completion_rate が数値で出る★
      const kpi = selectKpiRow(filtered, memoryQuery(q));
      expect(kpi, `${year}年`).not.toBeNull();
      expect(typeof kpi?.completion_rate.value, `${year}年`).toBe("number");
    }

    // 逆向き: 月次の要求(week=0)に年次の行は混ざらない
    for (const row of monthlyRows) {
      const q = kpiDbQuery({ period: "monthly", year: row.year, month: row.month, week: 0 });
      expect(filterFixtureReportKpis(reportKpis, q).map((r) => r.period)).toEqual(["monthly"]);
    }
  });
});

/**
 * `filterFixtureReportKpis`(期間キー + ★対象顧客★。指示書AK ★段階6★)。
 *
 * ★★何を守るためのテストか★★
 *  - ★Prisma 版が `customer_summaries` の `where` に降ろした `customer_id` と
 *    ★★同じ意味★★ を fixtures 側でも再現していること★(禁止事項6。
 *    ★`USE_FIXTURES` の切替で数字が変わってはいけない★)
 *  - ★★別顧客の行を拾わないこと★★(★拾うと「別会社の数字で判断する」ことになる★。D-5)
 */
describe("filterFixtureReportKpis(期間キー + 対象顧客)", () => {
  /** ★2社ぶんの行★(fixtures 本体は1社ぶんしか持たないのでここで組み立てる) */
  const ROWS = [
    { period: "weekly", year: 2026, month: 9, week: 2, customer_id: 1, kpi: reportKpis[0].kpi },
    { period: "weekly", year: 2026, month: 9, week: 2, customer_id: 2, kpi: reportKpis[1].kpi },
    { period: "weekly", year: 2026, month: 9, week: 1, customer_id: 2, kpi: reportKpis[1].kpi },
  ] as const;

  it("★対象顧客の行だけを返す★(★期待値表 6-2 の fixtures 版★)", () => {
    expect(filterFixtureReportKpis(ROWS, kpiDbQuery({ customer_id: 1 }))).toEqual([ROWS[0]]);
    expect(filterFixtureReportKpis(ROWS, kpiDbQuery({ customer_id: 2 }))).toEqual([ROWS[1]]);
  });

  it("★存在しない顧客 → 空配列★(404 にしない。期待値表 6-9)", () => {
    expect(filterFixtureReportKpis(ROWS, kpiDbQuery({ customer_id: 999 }))).toEqual([]);
  });

  it("★期間が違えば同じ顧客でも返さない★(期間キーの絞り込みを壊していない)", () => {
    expect(filterFixtureReportKpis(ROWS, kpiDbQuery({ customer_id: 1, week: 1 }))).toEqual([]);
  });

  it("★fixtures 本体のKPI行は屋号1 のもの★(`REPORT_KPI_CUSTOMER_ID`)", () => {
    for (const row of reportKpis) {
      expect(row.customer_id).toBe(REPORT_KPI_CUSTOMER_ID);
    }
  });
});