import { describe, expect, it } from "vitest";
import {
completionRateFromReport,
computeConsecutiveProblemDays,
foldProblemDaysByDate,
formatModelName,
hasCleanResult,
isFutureSchedule,
isProblemCompletion,
latestReportByTimetableId,
notExecutedFromReport,
robotStatusFromErrors,
type ConsecutiveInput,
} from "@/lib/db/clean-log-mapping";
import { fromDbJst } from "@/lib/format/date";
import { ROBOT_STATUS_STANDBY } from "@/lib/config";
// ---------------------------------------------------------------------------
// 期待値表(指示書R 2-5。CLAUDE.md §9.4「テストを先に書く / 境界値を必ず含める」)
// ---------------------------------------------------------------------------
describe("formatModelName", () => {
// | maker_id | model_name | 期待値 | 備考 |
// |----------|-----------|---------------|-----------------------------|
// | 1 | CC1 | PUDU CC1 | P1で唯一の実機(D-087) |
// | 2 | C30 | Keenon C30 | 将来候補 |
// | 99 | X1 | 99 X1 | ★未知のmaker_idは潰さない★ |
it.each([
[1, "CC1", "PUDU CC1"],
[2, "C30", "Keenon C30"],
[99, "X1", "99 X1"],
] as const)("maker_id=%i model=%s → %s", (makerId, model, expected) => {
expect(formatModelName(makerId, model)).toBe(expected);
});
it("半角スペース1つで連結する(facilities() の MAKER_NAMES 前例に合わせる。D-090 項4)", () => {
expect(formatModelName(1, "CC1")).toBe("PUDU CC1");
expect(formatModelName(1, "CC1").split(" ")).toHaveLength(2);
});
});
describe("robotStatusFromErrors", () => {
const at = (iso: string) => new Date(iso);
it("エラーが1件も無ければ Standby.(D-065 Q2 / D-098)", () => {
expect(robotStatusFromErrors([])).toBe(ROBOT_STATUS_STANDBY);
expect(robotStatusFromErrors([])).toBe("Standby.");
});
it("★英語識別子を日本語に変換しない(D-077 / D-088)★", () => {
expect(
robotStatusFromErrors([
{ error_type: "EmergencyStop", errored_at: at("2026-09-10T01:00:00+09:00") },
])
).toBe("EmergencyStop");
expect(
robotStatusFromErrors([
{ error_type: "Handrail Lifted", errored_at: at("2026-09-10T01:00:00+09:00") },
])
).toBe("Handrail Lifted");
});
it("複数ある場合は errored_at が最新の1件(指示書R 2-3)", () => {
const errors = [
{ error_type: "Old", errored_at: at("2026-09-10T01:00:00+09:00") },
{ error_type: "New", errored_at: at("2026-09-10T01:30:00+09:00") },
];
expect(robotStatusFromErrors(errors)).toBe("New");
expect(robotStatusFromErrors([...errors].reverse())).toBe("New");
});
it("errored_at が null の行は最も古く扱う", () => {
expect(
robotStatusFromErrors([
{ error_type: "NoTime", errored_at: null },
{ error_type: "Timed", errored_at: at("2026-09-10T01:00:00+09:00") },
])
).toBe("Timed");
});
it("すべて errored_at が null なら入力順の先頭(安定ソート)", () => {
expect(
robotStatusFromErrors([
{ error_type: "First", errored_at: null },
{ error_type: "Second", errored_at: null },
])
).toBe("First");
});
});
describe("hasCleanResult", () => {
// ★取得元が `clean_status_histories` → `clean_task_reports` に変わった(指示書AQ 決定2・決定3)★。
// ★期待値は変わっていない★ — この関数は最初から「対応する結果行があるか」だけを見ている。
// ★「出発したか」の判定は `notExecutedFromReport()` が正★。
//
// | clean_task_reports の行 | 戻り | 備考 |
// |-------------------------|-------|----------------------------------------|
// | 有り | true | 結果が記録されている |
// | 無し(undefined) | false | 結果が記録されていない |
//
// ★「行が無い=出発失敗」という**結果**も変わっていない★ —
// ただしその判定を担うのは `notExecutedFromReport()`(undefined → true)に移った
it("レポートの行があれば結果が記録されている", () => {
expect(hasCleanResult({ clean_timetable_id: BigInt(1) })).toBe(true);
});
it("レポートの行が無ければ結果が記録されていない", () => {
expect(hasCleanResult(undefined)).toBe(false);
});
it("Map.get() の戻り値をそのまま渡せる(呼び出し側の形)", () => {
const map = new Map<string, { id: number }>([["1", { id: 1 }]]);
expect(hasCleanResult(map.get("1"))).toBe(true);
expect(hasCleanResult(map.get("2"))).toBe(false);
});
});
describe("isFutureSchedule", () => {
// ★`clean_timetables` には1週間分の予定がまとめて登録される(ユーザー回答 2026-09-10)★
// → `notExecutedFromReport()` は「レポートの行が無い」を出発失敗と判定する(D-107 設計3-C)ので、
// ★本関数を外すと未来の予定が全部「出発失敗」になる(=C案の下では必須)★。
//
// | start_at | now | 未来か | 備考 |
// |-----------------|-----------------|----------|--------------------------------------------|
// | now より前 | 基準時刻 | false | 予定時刻は過ぎている → 出発失敗の判定対象 |
// | ★now と同時刻★ | 基準時刻 | ★false★ | ★予定時刻には達している(`>` であって `>=` ではない)★ |
// | now より後 | 基準時刻 | true | まだ来ていない → 判定対象から外す |
const now = new Date("2026-09-10T12:00:00+09:00");
it.each([
["1日前", "2026-09-09T12:00:00+09:00", false],
["1分前", "2026-09-10T11:59:00+09:00", false],
["1ミリ秒前", "2026-09-10T11:59:59.999+09:00", false],
["★同時刻★", "2026-09-10T12:00:00+09:00", false],
["1ミリ秒後", "2026-09-10T12:00:00.001+09:00", true],
["1分後", "2026-09-10T12:01:00+09:00", true],
["3日後", "2026-09-13T02:00:00+09:00", true],
] as const)("start_at が %s → 未来か=%s", (_label, startAt, expected) => {
expect(isFutureSchedule(new Date(startAt), now)).toBe(expected);
});
it("now を省略すると現在時刻で判定する(既定値)", () => {
expect(isFutureSchedule(new Date("2000-01-01T00:00:00+09:00"))).toBe(false);
expect(isFutureSchedule(new Date("2999-01-01T00:00:00+09:00"))).toBe(true);
});
it("同じ now を渡せば結果が実行時刻で揺れない(テストの前提)", () => {
const startAt = new Date("2026-09-10T12:00:00.001+09:00");
expect(isFutureSchedule(startAt, now)).toBe(isFutureSchedule(startAt, new Date(now)));
});
// ★呼び出し側(`cleanLogs()` / `prechecks()`)が行う絞り込みの形をそのまま固定する★
// DBには触らない(既存テストと同じく純粋関数のみ)。
describe("呼び出し側の絞り込み(cleanLogs() / prechecks() が共有する形)", () => {
// `clean_timetables` の本清掃行に見立てた最小の形。レポートの有無は departed の判定材料
const rows = [
{ id: 1, start_at: new Date("2026-09-08T02:00:00+09:00"), hasReport: true },
{ id: 2, start_at: new Date("2026-09-09T02:00:00+09:00"), hasReport: false },
{ id: 3, start_at: new Date("2026-09-10T12:00:00+09:00"), hasReport: false },
{ id: 4, start_at: new Date("2026-09-13T02:00:00+09:00"), hasReport: false },
];
it("★cleanLogs(): 未来の本清掃行は行ごと結果に含めない★", () => {
const kept = rows.filter((row) => !isFutureSchedule(row.start_at, now));
expect(kept.map((row) => row.id)).toEqual([1, 2, 3]);
});
it("★prechecks(): 未来の本清掃行から launch_failed を作らない★", () => {
// ★実装と同じ2段(①未来ならスキップ → ②`notExecutedFromReport()` が真なら出す)★
const launchFailed = rows.filter(
(row) =>
!isFutureSchedule(row.start_at, now) &&
notExecutedFromReport(row.hasReport ? report(95) : undefined)
);
// id=2(過去・レポートなし)と id=3(同時刻・レポートなし)は出発失敗のまま。
// id=4(3日後)だけが消える = 過去の真の出発失敗は1件も減らない
expect(launchFailed.map((row) => row.id)).toEqual([2, 3]);
});
it("★未来行を残すと連続日数が水増しされる(行ごと除外する理由)★", () => {
// 過去2日は問題なし(完了率95)、3日後の未来予定はレポートが無いので completion_rate=null(=問題あり)
// ★面積(`clean_area` / `task_area`)は連続日数の判定に使わないので 0★(指示書AI 段階2)
const logs: ConsecutiveInput[] = [
{ id: 1, facility_id: 1, scheduled_at: "2026-09-08T02:00:00+09:00", completion_rate: 95, departed: true, clean_area: 0, task_area: 0 },
{ id: 2, facility_id: 1, scheduled_at: "2026-09-09T02:00:00+09:00", completion_rate: 95, departed: true, clean_area: 0, task_area: 0 },
{ id: 4, facility_id: 1, scheduled_at: "2026-09-13T02:00:00+09:00", completion_rate: null, departed: false, clean_area: 0, task_area: 0 },
];
// ★未来行を残すと「1日連続で問題あり」という実在しない事実が生まれる★
expect(computeConsecutiveProblemDays(logs).get(4)).toBe(1);
// ★除外すればそもそも行が無い★
const filtered = logs.filter((log) => !isFutureSchedule(new Date(log.scheduled_at), now));
expect(computeConsecutiveProblemDays(filtered).has(4)).toBe(false);
});
});
// ★指示書Y — DBのDATETIMEは日本時間の壁時計値(TeamWorks第8回A2)。Prismaのアダプタは
// それをUTCとみなしてDateにするため、lib/db/index.ts・report-mapping.tsは
// `isFutureSchedule(fromDbJst(row.start_at), now)` という形で呼ぶ。
// ここではその合成をDBの生値(`new Date("<値>Z")`という、アダプタの実際の挙動と
// 同じ形の入力)に対してテストする。isFutureSchedule() 自体は変更していない★
describe("fromDbJst() と組み合わせた判定(DBの生値を渡したときに正しく判定できること)", () => {
// ★翌朝の確認時刻。CLAUDE.md §1「夜間に自動実行された清掃の結果を、翌朝DFA運用担当が確認する」★
const nextMorning = new Date("2026-09-11T08:00:00+09:00");
it("深夜2時の清掃が翌朝8時の確認でfalseになる(画面から消えないこと。これが本書の存在理由)", () => {
const dbRawValue = new Date("2026-09-11T02:00:00Z"); // DB生値 2026-09-11 02:00:00
expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(false);
// ★補正前(fromDbJstを通さない)だと壊れていたことの記録 — trueになり画面から消えていた★
expect(isFutureSchedule(dbRawValue, nextMorning)).toBe(true);
});
it("深夜1時30分の清掃も翌朝8時の確認でfalseになる", () => {
const dbRawValue = new Date("2026-09-11T01:30:00Z");
expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(false);
});
it("当日夜23時の予定は翌朝8時の確認でtrueのまま(正しく未来)", () => {
const dbRawValue = new Date("2026-09-11T23:00:00Z");
expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(true);
});
it("DB生値がnowとちょうど同時刻ならfalse(> であって >= ではない)", () => {
const dbRawValue = new Date("2026-09-11T08:00:00Z");
expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(false);
});
it("DB生値がnowの1秒後ならtrue", () => {
const dbRawValue = new Date("2026-09-11T08:00:01Z");
expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(true);
});
it("前夜23時59分59秒の予定は翌朝8時の確認でfalse", () => {
const dbRawValue = new Date("2026-09-10T23:59:59Z");
expect(isFutureSchedule(fromDbJst(dbRawValue), nextMorning)).toBe(false);
});
});
});
// ---------------------------------------------------------------------------
// ★取得元の切替(指示書AQ 決定2・決定3。2026-09-30 ユーザー確定)の期待値表★
//
// ★清掃実績の参照元が `clean_status_histories` → `clean_task_reports` に変わった★。
// ★★新しいテーブルに `clean_error` 列が無い★★(`prisma/schema.prisma:517-558`)ので、
// 旧実装の「`clean_error === 1` なら未実施」という第2分岐は★条件として成立しない★。
//
// | 旧(clean_status_histories) | 新(clean_task_reports) |
// |----------------------------------------------------|-----------------------------------|
// | 行が無い **または** `clean_error === 1` → 未実施 | ★行が無い → 未実施★(第1分岐のみ)|
// | 順序キー = `ended_at`(NOT NULL) | ★`ended_at ?? started_at`★ |
// | `clean_error === 1` のとき完了率 null | ★行が無いときだけ完了率 null★ |
//
// ★これは新しい推測ではない★ — 残した第1分岐は旧実装の第1分岐と同一の規則(指示書AQ §1-1)。
// ---------------------------------------------------------------------------
/**
* `clean_task_reports` の行に見立てた最小の形。
* ★完了率は面積から `runCompletionRate()` で決まる★(D-155)ので、`task_area = 100` に対して
* `clean_area = rate` とし、`clean_ratio` 列にも同じ値を入れる(★列は完了率に使っていない。D6★)。
*/
const report = (rate: number) => ({ clean_area: rate, task_area: 100, clean_ratio: rate });
describe("notExecutedFromReport", () => {
// | 入力 | 期待 | 意味 |
// |----------------------------|----------|--------------------------------------------------|
// | ★undefined(行なし)★ | ★true★ | ★未実施=出発失敗。データ収集処理の障害も含む★ |
// | 行あり(clean_ratio 95) | false | 実施された |
// | ★行あり(clean_ratio 0)★ | ★false★ | ★「0%で実施」は未実施ではない★ |
// | 行あり(status 3/4/5/6) | false | ★`status` を判定材料にしない(D-077)★ |
it("★レポートの行が無ければ未実施(出発失敗)★(指示書AQ §1-1 / D-107 設計3-C。fail-loud)", () => {
expect(notExecutedFromReport(undefined)).toBe(true);
});
it("レポートの行があれば実施された扱い", () => {
expect(notExecutedFromReport(report(95))).toBe(false);
});
it("★完了率 0(clean_area = 0)でも行があれば未実施ではない★(「0%で実施」と「未実施」は別物)", () => {
expect(notExecutedFromReport(report(0))).toBe(false);
});
// ★★`clean_task_reports.status` は判定に使わない(D-155 項2。値の意味は第10回 N-2 で受領済み。`prisma/schema.prisma:529`)★★ —
// 未実施は「行が無いこと」だけで決める。★判定材料にしていないことを固定する★
it.each([3, 4, 5, 6])(
"★status=%i を未実施の判定材料にしない(D-077。CSV実測の4値すべて false)★",
(status) => {
expect(notExecutedFromReport({ clean_ratio: 0, status })).toBe(false);
}
);
it("★判定材料は「行の有無」だけ★(他の列を足していないことの証明)", () => {
// どんな中身でも、行がある限り false でなければならない
const anyRows = [
{ clean_ratio: 0 },
{ clean_ratio: 100 },
{ clean_ratio: 0, status: 4, ended_at: null },
{},
];
expect(anyRows.map((row) => notExecutedFromReport(row))).toEqual([
false,
false,
false,
false,
]);
});
});
describe("completionRateFromReport", () => {
// ★D-155(指示書AS 段階3-1): 完了率は1回ごとの面積比 `runCompletionRate()`(第11回で確定。D-158)★
// ★旧: D-151 では `clean_ratio` 列の生値だった(面積から再計算しない)★
//
// | 入力(clean_area / task_area=100) | 期待 | 意味 |
// |------------------------------------|----------|-----------------------------------------------|
// | 92.4 | 92.4 | 小数第1位まで残る |
// | ★0★ | ★0★ | ★「0%で実施」と「未実施」は別物★ |
// | 89.5 | 89.5 | ★表示90%だが判定は○(CLAUDE.md §9.4 の境界)★|
// | 90.4 | 90.4 | ★表示90%だが判定は◎(同上)★ |
// | 100 | 100 | 上限 |
// | ★undefined(行なし)★ | ★null★ | ★未実施★ |
// | ★task_area = 0★ | ★0★ | ★0% として数える(P-5=B。指示書AU 表3)★ |
it.each([
[92.4, 92.4],
[0, 0],
[9.9, 9.9],
[10.0, 10.0],
[70.0, 70.0],
[89.5, 89.5],
[90.0, 90.0],
[90.4, 90.4],
[100, 100],
] as const)("clean_area=%s / task_area=100 → %s", (rate, expected) => {
expect(completionRateFromReport(report(rate))).toBe(expected);
});
it("★レポートの行が無ければ null★(不変条件を行なしの場合にも保つ)", () => {
expect(completionRateFromReport(undefined)).toBeNull();
});
it("★丸めない★(小数第1位がそのまま残る。CLAUDE.md §4.3)", () => {
expect(completionRateFromReport(report(89.5))).toBe(89.5);
expect(completionRateFromReport(report(90.4))).toBe(90.4);
});
// ★★境界: 完了率 null(未実施)と 0(0%で実施)は別物★★(指示書U 落とし穴1。★区別を維持★)
it("★null(未実施)と 0(0%で実施)を取り違えない★", () => {
expect(completionRateFromReport(undefined)).toBeNull();
expect(completionRateFromReport(undefined)).not.toBe(0);
expect(completionRateFromReport(report(0))).toBe(0);
expect(completionRateFromReport(report(0))).not.toBeNull();
});
// ★★`clean_ratio` 列を読んでいないことの証明(D-155 / 指示書AS D6)★★ —
// CSV実測の乖離例「面積比 13.8% ↔ `clean_ratio` 100.0%」「面積比 162.8% ↔ `clean_ratio` 61.0%」を
// そのまま入れて、★面積の側(上限100・小数第1位切り捨て)★が返ることを固定する
it.each([
// [clean_area, task_area, clean_ratio(読まない), 期待(= runCompletionRate)]
[13.8, 100, 100.0, 13.8],
[162.8, 100, 61.0, 100.0],
// ★`clean_area > task_area` の行はCSV実測189行中129行(68.3%)★ — 上限100で切る
[150, 100, 42.5, 100.0],
[50, 100, 12.3, 50.0],
])(
"★clean_area=%s / task_area=%s → clean_ratio 列(%s)ではなく面積から %s★",
(cleanArea, taskArea, cleanRatio, expected) => {
// ★DB から来る行は面積も `clean_ratio` も持っている★(行をそのまま渡す作法。D6)
const dbRow = {
clean_ratio: cleanRatio,
clean_area: cleanArea,
task_area: taskArea,
};
expect(completionRateFromReport(dbRow)).toBe(expected);
}
);
it("★task_area = 0 は 0★(P-5=B。指示書AU 表3「詳細ログの1行」。行はある=出発している)", () => {
const dbRow = { clean_ratio: 80, clean_area: 10, task_area: 0 };
expect(completionRateFromReport(dbRow)).toBe(0);
expect(notExecutedFromReport(dbRow)).toBe(false);
});
});
describe("★不変条件: departed === false ⟺ completion_rate === null(P-5=B で両向きに戻った)★", () => {
// ★旧: D-155 で `task_area = 0` の清掃が「出発したが完了率 null」になり、逆は成り立たなかった★(指示書AS 段階3-1)。
// ★P-5=B(第11回。D-158)で `task_area = 0` は 0% になり、両向きに成り立つ★。
// ★それでも未実施は `departed` で数える(【未決1】案A。D-156 / 指示書AU D3)★
//
// | 入力 | departed | completion_rate |
// |--------------------------|----------|-----------------|
// | 92.4 / 100 | true | 92.4 |
// | ★0 / 100★ | ★true★ | ★0★ |
// | ★10 / 0(task_area=0)★ | ★true★ | ★0★ |
// | ★レポート行なし★ | ★false★ | ★null★ |
const cases: ReadonlyArray<
readonly [
string,
{ clean_area: number; task_area: number; clean_ratio: number } | undefined,
boolean,
number | null,
]
> = [
["92.4 / 100", report(92.4), true, 92.4],
["0 / 100(0%で実施)", report(0), true, 0],
["100 / 100", report(100), true, 100],
["★10 / 0(task_area = 0)★", { clean_area: 10, task_area: 0, clean_ratio: 0 }, true, 0],
["★レポート行なし★", undefined, false, null],
];
it.each(cases)("%s → departed=%s / completion_rate=%s", (_label, row, departed, rate) => {
// ★呼び出し側(`cleanLogs()`)が作る形をそのまま固定する★
expect(!notExecutedFromReport(row)).toBe(departed);
expect(completionRateFromReport(row)).toBe(rate);
});
it("全ケースで departed === false ⟹ completion_rate === null", () => {
for (const [label, row] of cases) {
const departed = !notExecutedFromReport(row);
const rate = completionRateFromReport(row);
if (!departed) expect(rate, label).toBeNull();
}
});
it("★両向きに成り立つ★: 全ケースで completion_rate === null ⟹ departed === false(task_area = 0 も 0。P-5=B)", () => {
for (const [label, row] of cases) {
const departed = !notExecutedFromReport(row);
const rate = completionRateFromReport(row);
if (rate === null) expect(departed, label).toBe(false);
}
const row = { clean_area: 10, task_area: 0, clean_ratio: 0 };
expect(!notExecutedFromReport(row)).toBe(true);
expect(completionRateFromReport(row)).toBe(0);
});
});
describe("latestReportByTimetableId", () => {
/**
* `clean_task_reports` の行に見立てた最小の形。
* ★`ended_at` は nullable★(`prisma/schema.prisma:541`)、`started_at` は NOT NULL(`:540`)。
*/
const row = (
timetableId: number | null,
startedAt: string,
endedAt: string | null,
cleanRatio = 0
) => ({
clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
started_at: new Date(startedAt),
ended_at: endedAt === null ? null : new Date(endedAt),
// ★完了率は面積から決まる(D-155)★ — `task_area = 100` に対して `clean_area = cleanRatio`。
// `clean_ratio` 列にも同じ値を入れる(列は完了率に使っていない。D6)
clean_area: cleanRatio,
task_area: 100,
clean_ratio: cleanRatio,
});
it("キーは clean_timetable_id.toString()(BigInt は === で引けない)", () => {
const map = latestReportByTimetableId([
row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
]);
expect(map.get("1001")).toBeDefined();
expect(map.size).toBe(1);
});
it("★同一 timetable が複数あれば ended_at が新しい方★", () => {
const older = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
const newer = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T04:30:00+09:00");
expect(latestReportByTimetableId([older, newer]).get("1001")).toBe(newer);
// 入力順が逆でも同じ答え(DBの返却順に依存しない)
expect(latestReportByTimetableId([newer, older]).get("1001")).toBe(newer);
});
it("★ended_at 同着なら入力順の先頭★(現行の `<` 比較の挙動を維持する)", () => {
const first = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
const second = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
expect(latestReportByTimetableId([first, second]).get("1001")).toBe(first);
});
// -------------------------------------------------------------------------
// ★★順序キー: `ended_at` があればそれ、無ければ `started_at`★★(指示書AQ §1-4 / 落とし穴3)
// -------------------------------------------------------------------------
it("★ended_at が null の行は started_at で比較する★(3-1)", () => {
// ended_at なし・started_at が新しい方が勝つ
const older = row(1001, "2026-09-10T02:00:00+09:00", null);
const newer = row(1001, "2026-09-10T05:00:00+09:00", null);
expect(latestReportByTimetableId([older, newer]).get("1001")).toBe(newer);
expect(latestReportByTimetableId([newer, older]).get("1001")).toBe(newer);
});
it("★ended_at 有りと無しが混在しても落ちない★(3-2。null を getTime() しない)", () => {
// ended_at=null 側の started_at(05:00) が、ended_at 有り側の ended_at(03:30) より新しい
const ended = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
const running = row(1001, "2026-09-10T05:00:00+09:00", null);
expect(latestReportByTimetableId([ended, running]).get("1001")).toBe(running);
expect(latestReportByTimetableId([running, ended]).get("1001")).toBe(running);
});
it("★ended_at 有りの方が新しければそちらが勝つ★(3-3。混在の逆向き)", () => {
const running = row(1001, "2026-09-10T02:00:00+09:00", null);
const ended = row(1001, "2026-09-10T02:30:00+09:00", "2026-09-10T06:00:00+09:00");
expect(latestReportByTimetableId([running, ended]).get("1001")).toBe(ended);
expect(latestReportByTimetableId([ended, running]).get("1001")).toBe(ended);
});
it("★started_at ではなく ended_at を優先する★(3-4。started_at だけで比較していない証明)", () => {
// started_at は A の方が新しいが、ended_at は B の方が新しい → B が勝つ
const a = row(1001, "2026-09-10T04:00:00+09:00", "2026-09-10T04:30:00+09:00");
const b = row(1001, "2026-09-10T01:00:00+09:00", "2026-09-10T07:00:00+09:00");
expect(latestReportByTimetableId([a, b]).get("1001")).toBe(b);
expect(latestReportByTimetableId([b, a]).get("1001")).toBe(b);
});
it("★ended_at が両方 null で started_at も同着なら入力順の先頭★(3-5)", () => {
const first = row(1001, "2026-09-10T02:00:00+09:00", null);
const second = row(1001, "2026-09-10T02:00:00+09:00", null);
expect(latestReportByTimetableId([first, second]).get("1001")).toBe(first);
});
it("★ended_at が全部 null でも Map に入る(行ごと消えない)★(3-6)", () => {
const map = latestReportByTimetableId([row(1001, "2026-09-10T02:00:00+09:00", null)]);
expect(map.size).toBe(1);
expect(map.get("1001")?.ended_at).toBeNull();
});
// -------------------------------------------------------------------------
// ★★`clean_timetable_id` が NULL の行★★(指示書AQ 落とし穴1)
// -------------------------------------------------------------------------
it("★clean_timetable_id が null の行は Map に入れない★(\"null\" という偽のキーを作らない)", () => {
const map = latestReportByTimetableId([
row(null, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
]);
expect(map.size).toBe(0);
expect(map.has("null")).toBe(false);
});
it("★null の行が混ざっても、他の行の対応は壊れない★", () => {
const valid = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00");
const orphanA = row(null, "2026-09-10T02:00:00+09:00", "2026-09-10T09:00:00+09:00");
const orphanB = row(null, "2026-09-10T02:00:00+09:00", "2026-09-10T10:00:00+09:00");
const map = latestReportByTimetableId([orphanA, valid, orphanB]);
expect(map.size).toBe(1);
expect(map.get("1001")).toBe(valid);
});
it("★null の行に対応する本清掃は「レポート行なし」=出発失敗として出る★(fail-loud)", () => {
const map = latestReportByTimetableId([
row(null, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
]);
expect(notExecutedFromReport(map.get("1001"))).toBe(true);
expect(completionRateFromReport(map.get("1001"))).toBeNull();
});
it("別 timetable は別エントリ", () => {
const map = latestReportByTimetableId([
row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
row(1002, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00"),
]);
expect(map.size).toBe(2);
expect([...map.keys()].sort()).toEqual(["1001", "1002"]);
});
it("空配列なら空の Map", () => {
expect(latestReportByTimetableId([]).size).toBe(0);
});
it("★どの行を選ぶかで completion_rate の答えが変わる★(規則を1箇所にした理由。落とし穴2)", () => {
const old = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00", 12.5);
const latest = row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T04:30:00+09:00", 95.0);
expect(completionRateFromReport(latestReportByTimetableId([old, latest]).get("1001"))).toBe(
95.0
);
// ★DB返却順の後勝ちだと答えが変わっていた★
expect(completionRateFromReport(latestReportByTimetableId([latest, old]).get("1001"))).toBe(
95.0
);
});
it("★戻りの Map.get() をそのまま判定関数に渡せる(呼び出し側の形)★", () => {
const map = latestReportByTimetableId([
row(1001, "2026-09-10T02:00:00+09:00", "2026-09-10T03:30:00+09:00", 80),
]);
expect(notExecutedFromReport(map.get("1001"))).toBe(false);
// ★存在しないキー → undefined → 出発失敗(設計3-C / 指示書AQ §1-1)★
expect(notExecutedFromReport(map.get("9999"))).toBe(true);
expect(completionRateFromReport(map.get("9999"))).toBeNull();
});
});
// ★★表5(指示書AS §4。【未決1】案A。D-156)— 連続日数の問題日は `completion_rate` で見る(変えない)★★
// U2(行がある・`task_area = 0` → completion_rate 0.0。P-5=B)は ★問題日のまま★(0.0 は ×)
describe("isProblemCompletion — 表5(指示書AS)", () => {
it.each<[string, { clean_area: number; task_area: number; clean_ratio: number } | undefined, boolean]>([
["U1 レポートの行が無い → 問題", undefined, true],
["★U2 行がある・task_area = 0 → 0.0 で問題(×。P-5=B)★", { clean_area: 10, task_area: 0, clean_ratio: 0 }, true],
["U3 行がある・50/100 → 問題(△)", { clean_area: 50, task_area: 100, clean_ratio: 50 }, true],
["U4 行がある・100/100 → 問題なし", { clean_area: 100, task_area: 100, clean_ratio: 100 }, false],
])("%s", (_name, reportRow, expected) => {
expect(isProblemCompletion(completionRateFromReport(reportRow))).toBe(expected);
});
it("U5 同じ施設・同じ日に U1 と U4 → その日は問題(OR)", () => {
const base = { facility_id: 1, scheduled_at: "2026-09-10T02:00:00+09:00", clean_area: 0, task_area: 0 };
expect(
foldProblemDaysByDate([
{ ...base, id: 1, completion_rate: null, departed: false },
{ ...base, id: 2, completion_rate: 100, departed: true },
])
).toEqual([{ date: "2026-09-10", isProblem: true }]);
});
});
describe("isProblemCompletion", () => {
// | 完了率 | 問題あり | 備考 |
// |--------|----------|------------------------------------------|
// | null | true | 未実施 |
// | 0 | true | × |
// | 9.9 | true | × |
// | 10.0 | true | △ |
// | 69.9 | true | △ |
// | 70.0 | ★false★ | ★○なので問題なし(D-139。旧 D-020b では △ で問題ありだった)★|
// | 70.1 | false | ○ |
// | 90.0 | false | ◎(D-139) |
// | 90.1 | false | ◎ |
it.each([
[null, true],
[0, true],
[9.9, true],
[10.0, true],
[69.9, true],
[70.0, false],
[70.1, false],
[90.0, false],
[90.1, false],
] as const)("completion_rate=%s → 問題あり=%s", (rate, expected) => {
expect(isProblemCompletion(rate)).toBe(expected);
});
it("★完了率ちょうど70.0は○なので「問題なし」(D-139。2026-09-16ユーザー指示)★", () => {
// ユーザー原文: 「◯、×は90%以上のように境はふくめてください」
// CLAUDE.md §4.1 の△が「10%以上70%未満」になったので 70.0 は ○ = 問題なし。
// ★judgeGrade() と同じ規則★(入力が完了率か grade かの違いだけ)。
expect(isProblemCompletion(70.0)).toBe(false);
// ★直前の 69.9 は △ のまま問題あり★(境界の片側だけが動いたことの確認)
expect(isProblemCompletion(69.9)).toBe(true);
});
});
describe("computeConsecutiveProblemDays", () => {
// ★指示書V 段階3(不具合B)で分割キーを serial_number → facility_id に変更した★
// (CLAUDE.md §4.5 の R3/R4「同一施設で」に合わせるため。1施設に複数の清掃エリアが
// 実在する= serial_number では代用にならない)。第2引数は施設id(数値)
const row = (
id: number,
facilityId: number,
date: string,
rate: number | null
): ConsecutiveInput => ({
id,
facility_id: facilityId,
scheduled_at: `${date}T02:00:00+09:00`,
completion_rate: rate,
// ★連続日数は `departed` を見ない★(見るのは `completion_rate` だけ)。ここでは
// null = レポート行なしとして詰める(`task_area = 0` のケースは表5 U2 で別に確かめる)
departed: rate !== null,
// ★連続日数は面積を見ない★(見るのは `completion_rate` だけ)。指示書AI 段階2
clean_area: 0,
task_area: 0,
});
it("問題なしの行は 0", () => {
const map = computeConsecutiveProblemDays([row(1, 1, "2026-09-01", 95)]);
expect(map.get(1)).toBe(0);
});
it("1日 / 2日 / 3日と積み上がる", () => {
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-01", 95),
row(2, 1, "2026-09-02", 40),
row(3, 1, "2026-09-03", null),
row(4, 1, "2026-09-04", 10),
]);
expect(map.get(1)).toBe(0);
expect(map.get(2)).toBe(1);
expect(map.get(3)).toBe(2);
expect(map.get(4)).toBe(3);
});
it("問題なしの日が入ると 0 にリセットされる", () => {
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-01", 40),
row(2, 1, "2026-09-02", 95),
row(3, 1, "2026-09-03", 40),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(0);
expect(map.get(3)).toBe(1);
});
it("★スケジュール非存在日を挟んでも連続が途切れない(D-026)★(b4)", () => {
// 月水金スケジュールの施設。カレンダー上は5日間だが行があるのは3日だけ → L3相当の3
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-07", 41),
row(2, 1, "2026-09-09", 33),
row(3, 1, "2026-09-11", null),
]);
expect(map.get(3)).toBe(3);
});
it("入力が日付降順でも正しく数える(並べ替えは関数の責任)", () => {
const map = computeConsecutiveProblemDays([
row(3, 1, "2026-09-03", 20),
row(1, 1, "2026-09-01", 20),
row(2, 1, "2026-09-02", 20),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(2);
expect(map.get(3)).toBe(3);
});
it("施設(facility_id)ごとに独立して数える(b5。1台のみの施設。b3相当)", () => {
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-01", 20),
row(2, 2, "2026-09-01", 95),
row(3, 1, "2026-09-02", 20),
row(4, 2, "2026-09-02", 20),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(0);
expect(map.get(3)).toBe(2);
expect(map.get(4)).toBe(1);
});
// ★決定2(2026-09-11 ユーザー確定): 同一ロボットが同じ日に2回清掃した場合も「1日」に畳む★
// (D-026「連続はスケジュール存在日の連続」=日単位)。
// ★★意図した仕様変更: 期待値を 2 → 1 に直した(b8)★★ — 旧実装は畳み込みが無く、
// 同じ日の2行を2日と数えていた
it("同一ロボットが同じ日に2回清掃しても1日として数える(決定2。日単位。b8)", () => {
const map = computeConsecutiveProblemDays([
{ ...row(1, 1, "2026-09-01", 20), scheduled_at: "2026-09-01T02:00:00+09:00" },
{ ...row(2, 1, "2026-09-01", 20), scheduled_at: "2026-09-01T22:00:00+09:00" },
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(1);
});
it("空配列でも落ちない(b10)", () => {
expect(computeConsecutiveProblemDays([]).size).toBe(0);
});
// -------------------------------------------------------------------------
// ★指示書V 段階3 期待値表(b) — 複数ロボット・施設単位の畳み込み(すべて必須。ユーザー指定)★
// -------------------------------------------------------------------------
it("b1: 2台・3日・毎日どちらか1台が問題 → 3日連続(同じ日の全行が同じ値)", () => {
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-01", 50), // 問題
row(2, 1, "2026-09-01", 95),
row(3, 1, "2026-09-02", 95),
row(4, 1, "2026-09-02", 40), // 問題
row(5, 1, "2026-09-03", 30), // 問題
row(6, 1, "2026-09-03", 99),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(1);
expect(map.get(3)).toBe(2);
expect(map.get(4)).toBe(2);
expect(map.get(5)).toBe(3);
expect(map.get(6)).toBe(3);
});
it("b2: 2台・同じ日に両方問題でも2にならない(落とし穴1)", () => {
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-01", 50),
row(2, 1, "2026-09-01", 40),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(1);
});
it("b6: 同じ日に複数台・最新日が問題なし", () => {
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-01", 50),
row(2, 1, "2026-09-02", 95),
row(3, 1, "2026-09-02", 99),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(0);
expect(map.get(3)).toBe(0);
});
it("b7: ロボットが違っても施設が同じなら連続する(仕様の本体。旧実装では両方1だった)", () => {
// id1はロボットA相当・id2はロボットB相当。旧実装(serial_number分割)では別集計になり
// どちらも1のままだったが、施設単位に揃えると2に連続する
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-01", 50),
row(2, 1, "2026-09-02", 50),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(2);
});
it("b9: 未実施(completion_rate === null)は問題ありとして数える", () => {
const map = computeConsecutiveProblemDays([row(1, 1, "2026-09-01", null)]);
expect(map.get(1)).toBe(1);
});
it("b11: 同じ日の行の並び順を変えても結果が同じ(b1と同じ入力を任意の順で渡す)", () => {
const map = computeConsecutiveProblemDays([
row(4, 1, "2026-09-02", 40),
row(6, 1, "2026-09-03", 99),
row(1, 1, "2026-09-01", 50),
row(3, 1, "2026-09-02", 95),
row(5, 1, "2026-09-03", 30),
row(2, 1, "2026-09-01", 95),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(1);
expect(map.get(3)).toBe(2);
expect(map.get(4)).toBe(2);
expect(map.get(5)).toBe(3);
expect(map.get(6)).toBe(3);
});
// ★★「正常終了した行があっても、同じ日に履歴が無い行があればその日は問題日」★★
// `isProblemCompletion(null) === true` なので OR で畳むと true になる(D-109 決定)。
// ★★気づいた人が「正常終了しているのだから false では」と直さないように固定する★★
it("b12: 同一日に履歴が無い行(null)と正常終了の行(95)が混在 → 両方とも連続1", () => {
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-01", null),
row(2, 1, "2026-09-01", 95),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(1);
});
// ★★D-139(2026-09-16): 完了率ちょうど70.0は ○ になった★★
// (旧 D-020b 下では △=問題ありで連続1だった。指示書V 決定3)。
// ★ここを直し忘れると、連続日数が静かに1日多く数えられ、
// L1〜L3 → R3/R4 → 要レスキュー店舗まで波及する★
it("d1: 完了率ちょうど70.0の行は○なので連続0(D-139。旧規則では1だった)", () => {
const map = computeConsecutiveProblemDays([row(1, 1, "2026-09-01", 70.0)]);
expect(map.get(1)).toBe(0);
});
it("d2: 69.9(△)は問題あり、翌日70.0(○)で連続はリセットされる", () => {
const map = computeConsecutiveProblemDays([
row(1, 1, "2026-09-01", 69.9),
row(2, 1, "2026-09-02", 70.0),
]);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(0);
});
});
describe("foldProblemDaysByDate", () => {
const row = (
id: number,
facilityId: number,
date: string,
rate: number | null,
time = "02:00:00"
): ConsecutiveInput => ({
id,
facility_id: facilityId,
scheduled_at: `${date}T${time}+09:00`,
completion_rate: rate,
departed: rate !== null,
// ★畳み込みは面積を見ない★(見るのは `completion_rate` だけ)。指示書AI 段階2
clean_area: 0,
task_area: 0,
});
it("a1: 1日1行", () => {
expect(foldProblemDaysByDate([row(1, 1, "2026-09-01", 50)])).toEqual([
{ date: "2026-09-01", isProblem: true },
]);
});
it("a2: 同一日に問題行と正常行があれば1要素(OR)", () => {
expect(
foldProblemDaysByDate([
row(1, 1, "2026-09-01", 50),
row(2, 1, "2026-09-01", 95),
])
).toEqual([{ date: "2026-09-01", isProblem: true }]);
});
it("a3: 同一日に正常行だけなら問題なし", () => {
expect(
foldProblemDaysByDate([
row(1, 1, "2026-09-01", 95),
row(2, 1, "2026-09-01", 99),
])
).toEqual([{ date: "2026-09-01", isProblem: false }]);
});
it("a4: 入力順が逆でもa2と同じ結果(順序非依存)", () => {
expect(
foldProblemDaysByDate([
row(2, 1, "2026-09-01", 95),
row(1, 1, "2026-09-01", 50),
])
).toEqual([{ date: "2026-09-01", isProblem: true }]);
});
it("a5: 複数日は日付昇順で返す", () => {
expect(
foldProblemDaysByDate([
row(1, 1, "2026-09-01", 50),
row(2, 1, "2026-09-03", 95),
])
).toEqual([
{ date: "2026-09-01", isProblem: true },
{ date: "2026-09-03", isProblem: false },
]);
});
it("a6: 日付が降順で来ても昇順に並ぶ", () => {
expect(
foldProblemDaysByDate([
row(2, 1, "2026-09-03", 95),
row(1, 1, "2026-09-01", 50),
])
).toEqual([
{ date: "2026-09-01", isProblem: true },
{ date: "2026-09-03", isProblem: false },
]);
});
it("a7: 未実施(completion_rate === null)は問題あり", () => {
expect(foldProblemDaysByDate([row(1, 1, "2026-09-01", null)])).toEqual([
{ date: "2026-09-01", isProblem: true },
]);
});
it("a8: 同一日・同一ロボットが2回(決定2)でも1要素", () => {
expect(
foldProblemDaysByDate([
row(1, 1, "2026-09-01", 20, "02:00:00"),
row(2, 1, "2026-09-01", 20, "22:00:00"),
])
).toEqual([{ date: "2026-09-01", isProblem: true }]);
});
// ★★「正常終了した行があっても、同じ日に履歴が無い行があればその日は問題日」★★
// `isProblemCompletion(null) === true` なので OR で畳むと true になる(D-109 決定)。
// ★★気づいた人が「正常終了しているのだから false では」と直さないように固定する★★
it("a10: 同一日に履歴が無い行(null)と正常終了の行(95)が混在しても問題日(OR)", () => {
expect(
foldProblemDaysByDate([
row(1, 1, "2026-09-01", null),
row(2, 1, "2026-09-01", 95),
])
).toEqual([{ date: "2026-09-01", isProblem: true }]);
});
it("a9: 空配列は空配列", () => {
expect(foldProblemDaysByDate([])).toEqual([]);
});
});