/**
* 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([]);
});
});