Newer
Older
client_dwatch / lib / fixtures / __tests__ / reports.test.ts
/**
 * fixtures の不変条件テスト(`lib/fixtures/reports.ts` を守る側)
 *
 * `lib/fixtures/clean-logs.ts` は `judgeGrade` / `judgeEscalationLevel` を実際に通して
 * 期待値を作っていますが、`reports.ts` は判定結果(`status` / `escalation_level`)を
 * 直書きしています。直書きのままだと、fixture の数値を1つ直したときに
 * 判定結果だけが置き去りになり、**画面に出ている判定と `lib/judge` の判定が
 * 静かに食い違う**状態が生まれます(D-044 が避けたい状態そのもの)。
 *
 * そこでこのテストは、期待値をハードコードせず **`lib/judge` / `lib/format/period` の
 * 本番関数を実際に通して** fixture と突き合わせます。
 * 「fixture 側だけを直した」ときに落ちるのが目的で、二重管理を作らないためです。
 */
import { describe, expect, test } from "vitest";
import { isCurrentPeriod } from "@/lib/format/period";
import { judgeEscalationLevel, judgeFacilityStatus } from "@/lib/judge";
import {
  continuousProblems,
  continuousProblemsEmpty,
  errorBreakdown,
  errorBreakdownEmpty,
  facilityPerformance,
  facilityPerformanceEmpty,
  kpiCurrentPeriod,
  kpiMonthly,
  kpiWeekly,
  kpiYearly,
  reportKpis,
} from "@/lib/fixtures/reports";
import { ESCALATION_THRESHOLDS } from "@/lib/config";
import type { KpiValue, ReportPeriodKey } from "@/lib/types";

/**
 * 期間キーを1つの文字列にする(グルーピング用)。
 *
 * ★`week` は `number` で、月次は 0(D-072)★ 旧実装は `number | null` だったため
 * `?? "-"` で null を潰していたが、いまは 0 がそのまま「月次」を表すキーになる。
 */
function periodKey(row: ReportPeriodKey): string {
  return `${row.period}:${row.year}-${row.month}-${row.week}`;
}

/** 期間キーごとに行をまとめる。fixture は同じ item 配列を6期間に複製している */
function groupByPeriod<T extends ReportPeriodKey>(
  rows: readonly T[]
): Map<string, T[]> {
  const groups = new Map<string, T[]>();
  for (const row of rows) {
    const key = periodKey(row);
    const list = groups.get(key);
    if (list) {
      list.push(row);
    } else {
      groups.set(key, [row]);
    }
  }
  return groups;
}

/** 稼働率の昇順(ワースト順)比較。null は末尾に置く */
function operationRateOrder(value: number | null): number {
  return value === null ? Number.POSITIVE_INFINITY : value;
}

describe("fixtures/reports: 期間キー", () => {
  test("KPI は今週 / 先週 / 今月 / 先月 / 今年 / 前年 の6行", () => {
    // 年次(今年・前年)を D-151 で足したので 4 → 6(期間キーが2つ増えた)
    expect(reportKpis).toHaveLength(6);
    expect(new Set(reportKpis.map(periodKey)).size).toBe(6);
  });

  test("KPI は週次・月次・年次それぞれで当期の行がちょうど1本ある(当期/確定期の出し分け確認用)", () => {
    (["weekly", "monthly", "yearly"] as const).forEach((period) => {
      const rows = reportKpis.filter((row) => row.period === period);
      const current = rows.filter((row) =>
        isCurrentPeriod(row.period, row.year, row.month, row.week)
      );
      expect(rows.length).toBe(2);
      expect(current.length).toBe(1);
      // 当期の行には kpiCurrentPeriod が入っている(確定期のサマリ値ではない)
      expect(current[0].kpi).toBe(kpiCurrentPeriod);
    });
  });

  test("週次の確定行は kpiWeekly、月次の確定行は kpiMonthly、年次の確定行は kpiYearly", () => {
    const settled = reportKpis.filter(
      (row) => !isCurrentPeriod(row.period, row.year, row.month, row.week)
    );
    expect(
      settled.find((row) => row.period === "weekly")?.kpi
    ).toBe(kpiWeekly);
    expect(
      settled.find((row) => row.period === "monthly")?.kpi
    ).toBe(kpiMonthly);
    expect(
      settled.find((row) => row.period === "yearly")?.kpi
    ).toBe(kpiYearly);
  });

  /**
   * ★月次の行は `week = 0`、週次の行は 1〜6(D-072。段階5の型変更)★
   *
   * サマリ4テーブルの `week` は NOT NULL で「月次は 0」なので、fixtures も同じ表し方にする
   * (`prismaSource` を書くときに null ↔ 0 の変換を挟まないため)。
   * `lib/reports/query.ts` の `matchesPeriod` は period で場合分けせず
   * `row.week === query.week` の単純比較なので、月次行が 0 でないと当たらなくなる。
   *
   * ★年次の行は `month = 0` かつ `week = 0`(CLAUDE.md §4.7。D-151)★
   * 同じく単純比較なので、年次行の `month` が 0 でないと年次の要求に当たらない。
   */
  test("年次の行は month=0・week=0、月次の行は week=0、週次の行は 1〜6(★null を使わない★)", () => {
    const allRows: readonly ReportPeriodKey[] = [
      ...reportKpis,
      ...facilityPerformance,
      ...continuousProblems,
      ...errorBreakdown,
    ];
    expect(allRows.length).toBeGreaterThan(0);
    // 3粒度すべての行がある(どれかが欠けると、その粒度だけ fixtures で空になる)
    expect(new Set(allRows.map((row) => row.period))).toEqual(
      new Set(["weekly", "monthly", "yearly"])
    );
    allRows.forEach((row) => {
      if (row.period === "yearly") {
        expect(row.month, periodKey(row)).toBe(0);
        expect(row.week, periodKey(row)).toBe(0);
      } else if (row.period === "monthly") {
        // ★年次の予約値 month=0 を月次に使っていないこと★
        expect(row.month, periodKey(row)).toBeGreaterThanOrEqual(1);
        expect(row.week, periodKey(row)).toBe(0);
      } else {
        expect(row.week, periodKey(row)).toBeGreaterThanOrEqual(1);
        expect(row.week, periodKey(row)).toBeLessThanOrEqual(6);
      }
    });
  });

  test("施設別 / 連続問題 / エラー内訳も同じ6期間に紐付いている", () => {
    const expected = new Set(reportKpis.map(periodKey));
    [facilityPerformance, continuousProblems, errorBreakdown].forEach((rows) => {
      expect(new Set(rows.map(periodKey))).toEqual(expected);
    });
  });
});

describe("fixtures/reports: KpiValue の diff", () => {
  // 4指標 × (KPI行 + 施設別パフォーマンス) を全部集める
  const values: Array<{ label: string; kpi: KpiValue }> = [];

  ([
    ["kpiWeekly", kpiWeekly],
    ["kpiMonthly", kpiMonthly],
    ["kpiYearly", kpiYearly],
    ["kpiCurrentPeriod", kpiCurrentPeriod],
  ] as const).forEach(([label, kpi]) => {
    values.push({ label: `${label}.operation_rate`, kpi: kpi.operation_rate });
    values.push({ label: `${label}.launch_rate`, kpi: kpi.launch_rate });
    values.push({ label: `${label}.completion_rate`, kpi: kpi.completion_rate });
    values.push({ label: `${label}.error_rate`, kpi: kpi.error_rate });
  });

  reportKpis.forEach((row) => {
    const prefix = `reportKpis[${periodKey(row)}]`;
    values.push({ label: `${prefix}.operation_rate`, kpi: row.kpi.operation_rate });
    values.push({ label: `${prefix}.launch_rate`, kpi: row.kpi.launch_rate });
    values.push({ label: `${prefix}.completion_rate`, kpi: row.kpi.completion_rate });
    values.push({ label: `${prefix}.error_rate`, kpi: row.kpi.error_rate });
  });

  facilityPerformance.forEach((row) => {
    const prefix = `facilityPerformance[${row.item.facility_id}]`;
    values.push({ label: `${prefix}.operation_rate`, kpi: row.item.operation_rate });
    values.push({ label: `${prefix}.launch_rate`, kpi: row.item.launch_rate });
    values.push({ label: `${prefix}.completion_rate`, kpi: row.item.completion_rate });
    values.push({ label: `${prefix}.error_rate`, kpi: row.item.error_rate });
  });

  test("前提: 検証対象の KpiValue が集まっている", () => {
    expect(values.length).toBeGreaterThan(0);
  });

  test("diff は value - prev の生値(どちらかが null なら null。★丸めないこと★)", () => {
    values.forEach(({ label, kpi }) => {
      const expected =
        kpi.value === null || kpi.prev === null ? null : kpi.value - kpi.prev;
      expect(kpi.diff, `${label} の diff が value - prev と一致しません`).toBe(
        expected
      );
    });
  });

  test("前期データなし(prev = null)のケースが含まれている", () => {
    expect(values.some(({ kpi }) => kpi.prev === null)).toBe(true);
  });
});

describe("fixtures/reports: 施設別パフォーマンス", () => {
  test("status が judgeFacilityStatus(operation_rate.value) と一致する", () => {
    facilityPerformance.forEach(({ item }) => {
      expect(
        item.status,
        `${item.facility_name} の status が稼働率 ${item.operation_rate.value} の判定と一致しません`
      ).toBe(judgeFacilityStatus(item.operation_rate.value));
    });
  });

  test("3ステータス(良好 / 注意 / 要改善)が最低1件ずつある(docs/fixtures.md §4)", () => {
    const statuses = new Set(facilityPerformance.map((row) => row.item.status));
    expect(statuses).toEqual(new Set(["good", "warning", "bad"]));
  });

  test("稼働率の昇順(ワースト順)に並んでいる。null は末尾", () => {
    groupByPeriod(facilityPerformance).forEach((rows, key) => {
      for (let i = 1; i < rows.length; i += 1) {
        const prev = operationRateOrder(rows[i - 1].item.operation_rate.value);
        const curr = operationRateOrder(rows[i].item.operation_rate.value);
        expect(
          prev <= curr,
          `${key}: ${rows[i - 1].item.facility_name}(${prev}) の後に ${rows[i].item.facility_name}(${curr}) が来ています`
        ).toBe(true);
      }
    });
  });

  test("11件以上ある(「全N施設を表示」の発火確認。docs/fixtures.md §10)", () => {
    groupByPeriod(facilityPerformance).forEach((rows) => {
      expect(rows.length).toBeGreaterThanOrEqual(11);
    });
  });

  test("serial_number が全件入っている(絞り込みキーが結果にも出る。指示書F 0-6)", () => {
    facilityPerformance.forEach(({ item }) => {
      expect(item.serial_number, `${item.facility_name}`).toMatch(/^\S+$/);
    });
  });

  test("空配列ケースを用意している", () => {
    expect(facilityPerformanceEmpty).toEqual([]);
  });

  // ★第11回 P-6(D-158): 稼働率 = 正常終了 ÷ 予定・出発成功率 = 開始 ÷ 予定。正常終了は開始の部分集合★
  //   なので ★稼働率 ≤ 出発成功率★ が必ず成り立つ。作り値がこの不変条件を破らないことを固定する(指示書AU §11 Q5)
  test("★施設別成績の全行と KPI の全期間で 稼働率 ≤ 出発成功率(value も prev も)★", () => {
    const rows: { label: string; operation: KpiValue; launch: KpiValue }[] = [
      ...facilityPerformance.map(({ item, ...key }) => ({
        label: `${periodKey(key)} ${item.facility_name}`,
        operation: item.operation_rate,
        launch: item.launch_rate,
      })),
      ...reportKpis.map(({ kpi, ...key }) => ({
        label: `${periodKey(key)} KPI`,
        operation: kpi.operation_rate,
        launch: kpi.launch_rate,
      })),
    ];
    for (const { label, operation, launch } of rows) {
      for (const field of ["value", "prev"] as const) {
        const op = operation[field];
        const ln = launch[field];
        if (op === null || ln === null) continue;
        expect(op, `${label} の ${field}: 稼働率 ${op} > 出発成功率 ${ln}`).toBeLessThanOrEqual(ln);
      }
    }
  });
});

describe("fixtures/reports: 連続問題施設", () => {
  const items = continuousProblems
    .filter((row) => periodKey(row) === periodKey(continuousProblems[0]))
    .map((row) => row.item);

  test("escalation_level が judgeEscalationLevel(consecutive_problem_days) と一致する", () => {
    continuousProblems.forEach(({ item }) => {
      expect(
        item.escalation_level,
        `${item.facility_name}(${item.consecutive_problem_days}日)の escalation_level が判定と一致しません`
      ).toBe(judgeEscalationLevel(item.consecutive_problem_days));
    });
  });

  test("L1 は「2日以上で絞る」の確認用にちょうど1件だけ残してある(指示書F 先に決まっていること6)", () => {
    const l1 = items.filter((item) => item.escalation_level === "L1");
    expect(l1).toHaveLength(1);
    expect(l1[0].consecutive_problem_days).toBeLessThan(
      ESCALATION_THRESHOLDS.level2Days
    );
  });

  test("L1 の1件を除く全件が2日以上", () => {
    items
      .filter((item) => item.escalation_level !== "L1")
      .forEach((item) => {
        expect(
          item.consecutive_problem_days,
          `${item.facility_name} が2日未満です`
        ).toBeGreaterThanOrEqual(ESCALATION_THRESHOLDS.level2Days);
      });
  });

  test("2日以上が11件以上ある(絞り込み後に「他N件を表示」が発火する。docs/fixtures.md §10)", () => {
    const filtered = items.filter(
      (item) =>
        item.consecutive_problem_days >= ESCALATION_THRESHOLDS.level2Days
    );
    expect(filtered.length).toBeGreaterThanOrEqual(11);
  });

  test("L1 / L2 / L3 が最低1件ずつある(docs/fixtures.md §4)", () => {
    const levels = new Set(items.map((item) => item.escalation_level));
    expect(levels).toEqual(new Set(["L1", "L2", "L3"]));
  });

  test("空配列ケースを用意している", () => {
    expect(continuousProblemsEmpty).toEqual([]);
  });
});

describe("fixtures/reports: エラー種別内訳", () => {
  test("同一期間内で種別が重複していない", () => {
    groupByPeriod(errorBreakdown).forEach((rows, key) => {
      const types = rows.map((row) => row.item.type);
      expect(new Set(types).size, `${key}: 種別が重複しています`).toBe(
        types.length
      );
    });
  });

  test("件数の降順に並んでいる", () => {
    groupByPeriod(errorBreakdown).forEach((rows, key) => {
      for (let i = 1; i < rows.length; i += 1) {
        expect(
          rows[i - 1].item.count >= rows[i].item.count,
          `${key}: ${rows[i - 1].item.label}(${rows[i - 1].item.count}) の後に ${rows[i].item.label}(${rows[i].item.count}) が来ています`
        ).toBe(true);
      }
    });
  });

  test("件数が同値の組は type(生値)の文字列昇順になっている(段階3のタイブレーク規則)", () => {
    groupByPeriod(errorBreakdown).forEach((rows, key) => {
      for (let i = 1; i < rows.length; i += 1) {
        if (rows[i - 1].item.count !== rows[i].item.count) continue;
        expect(
          rows[i - 1].item.type <= rows[i].item.type,
          `${key}: 同数の組が文字列昇順になっていません("${rows[i - 1].item.type}" の後に "${rows[i].item.type}")`
        ).toBe(true);
      }
    });
  });

  test("rate の合計が 100 になる(浮動小数の誤差は許容)", () => {
    groupByPeriod(errorBreakdown).forEach((rows) => {
      const total = rows.reduce((sum, row) => sum + row.item.rate, 0);
      expect(total).toBeCloseTo(100, 5);
    });
  });

  test("rate は count の比率そのまま(★丸める前の生値★)", () => {
    groupByPeriod(errorBreakdown).forEach((rows) => {
      const totalCount = rows.reduce((sum, row) => sum + row.item.count, 0);
      rows.forEach(({ item }) => {
        expect(item.rate, `${item.label}`).toBeCloseTo(
          (item.count / totalCount) * 100,
          10
        );
      });
    });
  });

  test("該当0件ケースを用意している", () => {
    expect(errorBreakdownEmpty).toEqual([]);
  });
});