import { afterEach, describe, expect, it } from "vitest";
import {
CLEAN_TIMETABLE_PRECHECK,
CONSECUTIVE_DAYS_DISPLAY,
CONSECUTIVE_LOOKBACK,
RESULT_WAIT_MINUTES,
} from "@/lib/config";
import {
completionRateFromReport,
computeConsecutiveProblemDays,
consecutiveProblemDaysByFacilityDate,
facilityDateKey,
foldProblemDaysByDate,
isFutureSchedule,
latestReportByTimetableId,
type ConsecutiveInput,
} from "@/lib/db/clean-log-mapping";
import {
consecutiveInputsFromRows,
consecutiveLookbackCalendarDays,
consecutiveLookbackFrom,
loadConsecutiveInputs,
CONSECUTIVE_RANGE_UNBOUNDED,
type ConsecutiveDbRange,
type ConsecutiveSourcePrisma,
type ConsecutiveTaskReportRow,
type ConsecutiveTimetableRow,
type ConsecutiveUnscheduledReportRow,
type ConsecutiveUnscheduledReportWhere,
} from "@/lib/db/consecutive-source";
import {
consecutiveProblemDaysAtPeriodEnd,
continuousProblemsInPeriod,
} from "@/lib/db/report-mapping";
import { buildRescueRequired } from "@/lib/db/rescue-mapping";
import { judgeEscalationLevel } from "@/lib/judge";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { runCompletionRate } from "@/lib/judge";
import { fromDbJst } from "@/lib/format/date";
import { periodRange } from "@/lib/format/period";
import {
__setDisplayLimitStoreForTesting,
type DisplayLimitOverrides,
} from "@/lib/settings/display-limits";
/**
* 指示書AC 段階1 の期待値表(6行)。
*
* ★★段階1 は「挙動を1ミリも変えないリファクタ」なので、固定するのは
* 「値が**変わらない**こと」です★★。
*
* | # | 入力 | 期待 |
* |---|---|---|
* | 1 | ローカルDB 全件 | `loadConsecutiveInputs()` の件数 = 段階0 の `cleanLogs()` の件数(538) |
* | 2 | ローカルDB 全件 | `computeConsecutiveProblemDays()` の Map が段階0 のスナップショットと完全一致 |
* | 3 | ローカルDB 全件 | `facilityPerformance()` の `consecutive_problem_days` が段階0 と一致 |
* | 4 | ローカルDB 全件 | `continuousProblems()` の全行が段階0 と一致 |
* | 5 | 合成データ | 同一施設・2台・Aの翌日にBが問題 → ★2日連続★(D-109 A案) |
* | 6 | 合成データ | 清掃予定のない日を挟んでも連続が途切れない(D-026) |
*
* ★★#1〜#4 の期待値は、段階0(refactor 前の HEAD)で ★ローカルDB `dwatch_local` から実測した
* スナップショット★ をそのままこのファイルに書き写したものです★★
* (2026-09-13 実測。`clean_timetables` 1,044行 / 本清掃 538行 / 施設12件)。
* ★★テストからDBに接続しないこと★★(`lib/db/` のテストはDB非依存が原則)なので、
* ★施設9(★2台の清掃エリアを持ち、実測の最大連続6日を含む施設★)の実測62行を
* `LOCAL_DB_FACILITY_9` に固定し、その行から作ったスタブを通して同じ値が出ることを確認する★。
* ★全538行ぶんの一致は、同じスクリプトで段階0 と段階1 の全メソッドの戻り値を
* 突き合わせて確認済み(11メソッドすべて完全一致)★。
*/
/**
* ★★指示書AQ(参照元を `clean_task_reports` に差し替える)の期待値表(10行)★★
*
* ★実装の前にこの表を確定させたもの★(`CLAUDE.md` §0-2 / §6-2)。
* ★★`clean_timetable_id` が NULL の行と `ended_at` が NULL の行を必ず含めること★★
* (指示書AQ 罠1 / 罠3。★どちらも例外もテスト失敗も出ずに壊れる形★)。
*
* | # | 入力 | 期待 |
* |----|---|---|
* | Q1 | 本清掃1行 + レポート1行(面積 50/100) | `completion_rate = 50`(★`runCompletionRate()`。D-155★) |
* | Q2 | 本清掃1行 + ★対応レポートなし★ | `completion_rate = null`(★未実施★。§1-1) |
* | Q3 | レポートの `clean_timetable_id` が ★NULL★ | ★`IN` に一致せず返らない → その清掃は「未実施」★(罠1) |
* | Q4 | レポート3行・`ended_at` すべてあり | ★`ended_at` が最新★の1件 |
* | Q5 | レポート2行・片方の `ended_at` が ★NULL★ | ★NULL 側は `started_at` を順序キーにする★(§1-4) |
* | Q6 | レポート2行・★両方 `ended_at` が NULL★ | ★`started_at` が新しいほう★ |
* | Q7 | レポート2行・順序キーが同着 | ★入力順の先頭★(`clean_status_histories` 時代と同じ規則) |
* | Q8 | 面積 0/100 のレポートあり | ★`0`★(★`null` にしない★。「0%で実施」と「未実施」は別物) |
* | Q9 | 面積 900/1000 のレポート / 0/0 のレポート / レポートなし | ★面積は生値 / なしは `0`★。完了率は 90 / ★0(`task_area = 0`。P-5=B。旧: null)★ / null、★`departed` は true / true / false★(D-155 / D-156 / D-158) |
* | Q10| `select` の列 | 6列(`clean_timetable_id` / `clean_ratio` / `started_at` / `ended_at` / `clean_area` / `task_area`)。★`clean_error` は無い★ |
*
* ★★Q1〜Q10 の期待値は実測ではありません★★(`CLAUDE.md` §6-6)— ★仕様(指示書AQ §1-1〜§1-4)
* から起こした合成データ★です。★本番DBの `clean_task_reports` は0行★で、
* ★ユーザー提供CSV 189行が唯一の実データ★(指示書AQ 決定4の根拠表)。
*/
// ---------------------------------------------------------------- ヘルパー
/**
* `clean_timetables.start_at` は ★日本時間の壁時計値★ が入った DATETIME(D-115 / 指示書Y)。
* ISO文字列(+09:00)から、DBに入っているのと同じ「壁時計値としての Date」を作る。
* ★`fromDbJst()` を通すと元の ISO に戻る★のが正しい状態。
*/
function dbWallClock(jstIso: string): Date {
return new Date(jstIso.replace("+09:00", "Z"));
}
/**
* @param endJstIso ★予定の終了★(結果待ちの判定用。D-173)。★省略時は開始の1時間後★
*/
function timetable(
id: number,
facilityId: number,
jstIso: string,
precheck: number = CLEAN_TIMETABLE_PRECHECK.main,
endJstIso?: string
): ConsecutiveTimetableRow {
const startAt = dbWallClock(jstIso);
return {
id: BigInt(id),
facility_id: BigInt(facilityId),
start_at: startAt,
end_at: endJstIso === undefined ? new Date(startAt.getTime() + 3_600_000) : dbWallClock(endJstIso),
precheck,
};
}
/**
* `clean_task_reports` の1行を作る(指示書AQ 決定2)。
*
* ★★「未実施」をこの関数では作れない★★(指示書AQ §1-1)— 未実施は
* ★★レポートの行そのものが無いこと★★で表すので、★行を作らない★のが正しい。
* ★`clean_ratio = 0` を未実施の代わりに使わないこと★(「0%で実施した」と別物。
* 指示書U 落とし穴1)。
*
* ★面積の既定は `cleanRatio / 100`★(D-155)— 完了率は面積から `runCompletionRate()` で決まるので、
* `task_area = 100` に対して `clean_area = cleanRatio` とし、`clean_ratio` 列にも同じ値を入れる
* (★列は完了率に使っていない。指示書AS D6★)。★旧: 面積の既定は 0 だった★(指示書AI 段階2)。
* ★R-3 は面積で重み付けしない★(1回ごとの完了率の単純平均=`meanCleanRatio()`。
* 指示書AQ 決定4 / §5-5)。R-3 の期待値表は `lib/judge/__tests__/rescue.test.ts` が持つ。
*
* @param timetableId ★`null` を渡すと `clean_timetable_id` が NULL の行になる★(罠1 の検証用)
* @param endedAtJstIso ★`null` を渡すと `ended_at` が NULL の行になる★(罠3 / §1-4 の検証用)
*/
function taskReport(
timetableId: number | null,
cleanRatio: number,
endedAtJstIso: string | null = "2026-09-13T05:00:00+09:00",
cleanArea = cleanRatio,
taskArea = 100,
// ★順序キーのフォールバック★(`ended_at` が NULL のとき使われる。指示書AQ §1-4)
startedAtJstIso = "2026-09-13T04:00:00+09:00"
): ConsecutiveTaskReportRow {
return {
clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
clean_ratio: cleanRatio,
started_at: dbWallClock(startedAtJstIso),
ended_at: endedAtJstIso === null ? null : dbWallClock(endedAtJstIso),
clean_area: cleanArea,
task_area: taskArea,
};
}
/**
* `findMany()` に渡された引数を控えるスタブ(★DBに接続しない★)。
*
* ★★`where.start_at` と `where.clean_timetable_id` は**DBと同じように絞る**★★ —
* 段階6(遡り範囲を有界にする)の検証は「★どの行が返らなくなったか★」を見る必要があるため。
*
* ★★ただし `where.precheck` はあえて**無視する**★★ —
* `precheck` の絞り込みは SQL にも降ろしてあるが、★規則の正は
* `consecutiveInputsFromRows()` のメモリ側 filter★(指示書AC C-5)。
* スタブが SQL 側で落としてしまうと、★純粋関数側の filter を消しても
* テストが緑のまま★になる。
*/
function stubPrisma(
timetables: readonly ConsecutiveTimetableRow[],
reports: readonly ConsecutiveTaskReportRow[],
// ★`clean_timetable_histories` の行(指示書AU 段階3b。既定は空)★
historyTimetables: readonly ConsecutiveTimetableRow[] = [],
// ★予定外タスクの行(指示書BG §2-3。3本目のクエリ。既定は空)★
unscheduledReports: readonly ConsecutiveUnscheduledReportRow[] = []
): { prisma: ConsecutiveSourcePrisma; calls: Record<string, unknown[]> } {
const calls: Record<string, unknown[]> = {
cleanTimetable: [],
cleanTimetableHistory: [],
cleanTaskReport: [],
// ★予定外タスクの3本目は別の箱に控える★(2本目の引数を見ている既存のテストを変えないため)
cleanTaskReportUnscheduled: [],
};
/**
* 予定の表の `findMany`(本表・履歴で共通)。★`where.id.in`(影チェック。`lib/db/timetable-source.ts`)だけは
* 再現する★ — 再現しないと、影チェックが本表の全行を返して「本表に同じ id がある」と誤認する。
*/
const timetableFindMany =
(rows: readonly ConsecutiveTimetableRow[], bucket: "cleanTimetable" | "cleanTimetableHistory") =>
async (args: Parameters<ConsecutiveSourcePrisma["cleanTimetable"]["findMany"]>[0]) => {
const idIn = (args.where as { id?: { in: bigint[] } } | undefined)?.id?.in;
// ★影チェックは記録しない★(`calls.cleanTimetable` の引数を見ている既存のテストを変えないため)
if (idIn) return rows.filter((row) => idIn.some((id) => id === row.id));
calls[bucket].push(args);
const range = args.where?.start_at;
return rows.filter((row) => {
if (range?.gte && row.start_at < range.gte) return false;
if (range?.lt && row.start_at >= range.lt) return false;
return true;
});
};
const prisma: ConsecutiveSourcePrisma = {
cleanTimetable: { findMany: timetableFindMany(timetables, "cleanTimetable") },
cleanTimetableHistory: { findMany: timetableFindMany(historyTimetables, "cleanTimetableHistory") },
cleanTaskReport: {
// ★2つの形を `where.clean_timetable_id.in` の有無で見分ける★(予定の id で引く2本目/予定外タスクの3本目)。
// ★交差型の `findMany` は2つの形のどちらにも代入できる必要がある★ので、和の引数で受けて型を合わせる
findMany: (async (
args:
| { where: { clean_timetable_id: { in: bigint[] } } }
| { where: ConsecutiveUnscheduledReportWhere }
) => {
if ("clean_timetable_id" in args.where) {
calls.cleanTaskReport.push(args);
const ids = new Set(args.where.clean_timetable_id.in.map((id) => id.toString()));
return reports.filter((row) => {
// ★★SQL と同じ挙動にする — NULL は `IN` に一致しない★★(指示書AQ 罠1)。
// ★ここを `?? ""` 等で誤魔化すと、罠1(NULL 行が「未実施」に化ける)を
// テストで再現できなくなります★
if (row.clean_timetable_id === null) return false;
return ids.has(row.clean_timetable_id.toString());
});
}
calls.cleanTaskReportUnscheduled.push(args);
const where = args.where;
// ★SQL と同じ意味で当てる★: `OR`(NULL か 0)・`started_at` の範囲・施設の `IN`
return unscheduledReports.filter((row) => {
const isUnscheduled = where.OR.some((cond) =>
cond.clean_timetable_id === null
? row.clean_timetable_id === null
: row.clean_timetable_id !== null && row.clean_timetable_id === cond.clean_timetable_id
);
if (!isUnscheduled) return false;
if (where.started_at?.gte && row.started_at < where.started_at.gte) return false;
if (where.started_at?.lt && row.started_at >= where.started_at.lt) return false;
if (where.facility_id && !where.facility_id.in.some((id) => id === row.facility_id)) return false;
return true;
});
}) as unknown as ConsecutiveSourcePrisma["cleanTaskReport"]["findMany"],
},
};
return { prisma, calls };
}
/**
* ★予定外タスクの `clean_task_reports` の1行★(指示書BG §2-3)。
* @param timetableId ★0 か `null`★(どちらも予定外。表U1 U1-3)
*/
function unscheduledReport(
facilityId: number,
startedAtJstIso: string,
cleanArea: number,
taskArea = 100,
timetableId: 0 | null = null
): ConsecutiveUnscheduledReportRow {
return {
facility_id: BigInt(facilityId),
clean_timetable_id: timetableId === null ? null : BigInt(timetableId),
clean_ratio: 0,
started_at: dbWallClock(startedAtJstIso),
ended_at: dbWallClock(startedAtJstIso),
clean_area: cleanArea,
task_area: taskArea,
};
}
/**
* 「システム管理画面で保存された表示上限」を差し替える(★実ファイルを触らない★)。
*
* ★段階6 の期待値表 #5 / #6(上限を大きく/小さくしたらクエリの遡り範囲も追随する)の
* 検証に使う★。★`undefined` を渡すと「未編集」= `lib/config/` の既定値に倒れる★。
*/
function useSavedConsecutiveDaysMax(value: number | undefined): void {
const overrides: DisplayLimitOverrides =
value === undefined ? {} : { consecutive_days_max: value };
__setDisplayLimitStoreForTesting(
{
async read() {
return { ...overrides };
},
async write() {
/* 段階6 は読むだけ。保存はしない */
},
},
"(test)"
);
}
afterEach(() => {
// ★差し替えを必ず戻す★ — 残すと他のテストが実ファイルではなくこのスタブを読む
__setDisplayLimitStoreForTesting(null);
});
/**
* ★本体(`consecutiveInputsFromRows()`)とは独立に書いた素朴な実装★。
* ★これと本体の結果が完全一致することを合格条件にする★(本体のヘルパーを1つも使わない)。
*
* ★★指示書AQ の切替でこの関数も書き直した★★ — 以前は
* `completionRateFromHistory()` / `latestHistoryByTimetableId()` を呼んでいたが、
* ★`clean_error` が `clean_task_reports` に無い★ため成立しない(指示書AQ §1-1)。
* ★規則をベタ書きにして「本体と同じ関数を呼ぶから一致する」トートロジーを避ける★。
*/
function legacyConsecutiveInputs(
timetables: readonly ConsecutiveTimetableRow[],
reports: readonly ConsecutiveTaskReportRow[],
now: Date
): ConsecutiveInput[] {
const mainRows = timetables.filter(
(row) => row.precheck === CLEAN_TIMETABLE_PRECHECK.main
);
// ★順序キー: `ended_at` があればそれ、無ければ `started_at`★(指示書AQ §1-4)。
// ★同着は入力順の先頭★(`<` 比較なので後続で上書きされない)
const reportByTimetableId = new Map<string, ConsecutiveTaskReportRow>();
for (const report of reports) {
if (report.clean_timetable_id === null) continue; // ★NULL はキーにできない★(罠1)
const key = report.clean_timetable_id.toString();
const current = reportByTimetableId.get(key);
const orderAt = report.ended_at ?? report.started_at;
if (!current || (current.ended_at ?? current.started_at) < orderAt) {
reportByTimetableId.set(key, report);
}
}
return mainRows
.filter((row) => !isFutureSchedule(fromDbJst(row.start_at), now))
// ★結果待ち(レポートなし かつ 予定の終了+待ち時間より前)は外す★(表2 C10。D-173)。
// ★本体の `isResultPending()` を呼ばずにベタ書きする★(トートロジーを避ける。上の docstring)
.filter(
(row) =>
reportByTimetableId.has(row.id.toString()) ||
now.getTime() >= fromDbJst(row.end_at).getTime() + RESULT_WAIT_MINUTES * 60_000
)
.map((row) => {
const r = reportByTimetableId.get(row.id.toString());
return {
id: Number(row.id),
unscheduled: false,
facility_id: Number(row.facility_id),
scheduled_at: new Date(
fromDbJst(row.start_at).getTime()
).toISOString(), // ★後段で jstIsoFromDb と突き合わせるため、ここでは比較対象から外す★
// ★未実施 = レポートの行が無い★(指示書AQ §1-1)。★`clean_error` は見ない★
// ★1回ごとの完了率は `runCompletionRate()` を呼ぶ★(指示書AU 段階2-4。テストの中に規則を写さない —
// 規則は `lib/judge/__tests__/run-completion-rate.test.ts` の表1 で固定している)
completion_rate: r === undefined ? null : runCompletionRate(r),
departed: r !== undefined,
// ★レポートが無い行は 0★(指示書AI 段階2。本体側と同じ規則をここにも写す)
clean_area: r?.clean_area ?? 0,
task_area: r?.task_area ?? 0,
};
});
}
// ---------------------------------------------------------------- 段階0 の実測スナップショット
/**
* ★段階0(refactor 前)にローカルDB `dwatch_local` から実測した施設9の全62行★。
* `[清掃ログid, scheduled_at, completion_rate, ★段階0 の consecutive_problem_days★]`
*
* ★施設9 を選んだ理由★: ★清掃エリアが2つ(1日に2行)ある★ので同一日OR畳み込み(D-109 A案)が
* 効いており、かつ ★実測の最大連続日数6日★ を含むため。
* ★この表の値を書き換えないこと★ — 書き換えたら「挙動が変わっていない」ことの証拠が消える。
*/
const LOCAL_DB_FACILITY_9: readonly [number, string, number | null, number][] = [
[1008, "2026-09-13T02:30:00+09:00", 9.9, 6],
[1017, "2026-09-08T02:30:00+09:00", 22, 1],
[1018, "2026-09-09T02:30:00+09:00", 18, 2],
[1019, "2026-09-10T02:30:00+09:00", 25, 3],
[1020, "2026-09-11T02:30:00+09:00", 15, 4],
[1021, "2026-09-12T02:30:00+09:00", 12, 5],
[1038, "2026-09-07T02:30:00+09:00", 75.9, 0],
[1048, "2026-09-06T02:30:00+09:00", 96.9, 1],
[1058, "2026-09-05T02:30:00+09:00", 93.8, 0],
[1068, "2026-09-04T02:30:00+09:00", 94.6, 1],
[1078, "2026-09-03T02:30:00+09:00", 94.6, 0],
[1088, "2026-09-02T02:30:00+09:00", 98.1, 1],
[1098, "2026-09-01T02:30:00+09:00", 94.5, 0],
[1108, "2026-08-31T02:30:00+09:00", 98.5, 2],
[1118, "2026-08-30T02:30:00+09:00", 8.5, 1],
[1128, "2026-08-29T02:30:00+09:00", 95.9, 0],
[1138, "2026-08-28T02:30:00+09:00", 97.7, 1],
[1148, "2026-08-27T02:30:00+09:00", 98.2, 0],
[1158, "2026-08-26T02:30:00+09:00", null, 3],
[1168, "2026-08-25T02:30:00+09:00", 9.1, 2],
[1178, "2026-08-24T02:30:00+09:00", 99.4, 1],
[1188, "2026-08-23T02:30:00+09:00", 96.6, 0],
[1198, "2026-08-22T02:30:00+09:00", 76.4, 2],
[1208, "2026-08-21T02:30:00+09:00", 76.2, 1],
[1218, "2026-08-20T02:30:00+09:00", 93.7, 0],
[1228, "2026-08-19T02:30:00+09:00", 98.1, 1],
[1238, "2026-08-18T02:30:00+09:00", 95.1, 0],
[1248, "2026-08-17T02:30:00+09:00", 99.1, 1],
[1258, "2026-08-16T02:30:00+09:00", 96.2, 0],
[1268, "2026-08-15T02:30:00+09:00", 94.7, 1],
[1278, "2026-08-14T02:30:00+09:00", 94.7, 0],
[3008, "2026-08-14T01:20:00+09:00", 73.6, 0],
[3017, "2026-08-15T01:20:00+09:00", 5.6, 1],
[3026, "2026-08-16T01:20:00+09:00", 98.5, 0],
[3035, "2026-08-17T01:20:00+09:00", 30.6, 1],
[3044, "2026-08-18T01:20:00+09:00", 85.1, 0],
[3053, "2026-08-19T01:20:00+09:00", 8.3, 1],
[3062, "2026-08-20T01:20:00+09:00", 92.8, 0],
[3071, "2026-08-21T01:20:00+09:00", 53.8, 1],
[3080, "2026-08-22T01:20:00+09:00", null, 2],
[3089, "2026-08-23T01:20:00+09:00", 79.2, 0],
[3098, "2026-08-24T01:20:00+09:00", 4.4, 1],
[3107, "2026-08-25T01:20:00+09:00", 98.1, 2],
[3116, "2026-08-26T01:20:00+09:00", 36.6, 3],
[3125, "2026-08-27T01:20:00+09:00", 87.4, 0],
[3134, "2026-08-28T01:20:00+09:00", 0.9, 1],
[3143, "2026-08-29T01:20:00+09:00", 95.9, 0],
[3152, "2026-08-30T01:20:00+09:00", 57.9, 1],
[3161, "2026-08-31T01:20:00+09:00", null, 2],
[3170, "2026-09-01T01:20:00+09:00", 71.2, 0],
[3179, "2026-09-02T01:20:00+09:00", 1, 1],
[3188, "2026-09-03T01:20:00+09:00", 99.4, 0],
[3197, "2026-09-04T01:20:00+09:00", 50.3, 1],
[3206, "2026-09-05T01:20:00+09:00", 76.1, 0],
[3215, "2026-09-06T01:20:00+09:00", 2.8, 1],
[3224, "2026-09-07T01:20:00+09:00", 93.7, 0],
[3233, "2026-09-08T01:20:00+09:00", 60.3, 1],
[3242, "2026-09-09T01:20:00+09:00", null, 2],
[3251, "2026-09-10T01:20:00+09:00", 88.6, 3],
[3260, "2026-09-11T01:20:00+09:00", 6.2, 4],
[3269, "2026-09-12T01:20:00+09:00", 94.7, 5],
[3278, "2026-09-13T01:20:00+09:00", 38.4, 6],
];
/** 段階0 実測: `db.cleanLogs()` の全件数(= `loadConsecutiveInputs()` が返すべき件数) */
const LOCAL_DB_TOTAL_MAIN_ROWS = 538;
const FACILITY_9_NAME = "メガバリューマート三ツ橋中央ショッピングセンター店";
/** ★段階0 実測★: `facilityPerformance()` の施設9の `consecutive_problem_days` */
const LOCAL_DB_FACILITY_PERFORMANCE_9: readonly {
period: "weekly" | "monthly";
year: number;
month: number;
week: number;
consecutive_problem_days: number;
}[] = [
{ period: "monthly", year: 2026, month: 8, week: 0, consecutive_problem_days: 2 },
{ period: "monthly", year: 2026, month: 9, week: 0, consecutive_problem_days: 6 },
{ period: "weekly", year: 2026, month: 9, week: 2, consecutive_problem_days: 5 },
{ period: "weekly", year: 2026, month: 9, week: 3, consecutive_problem_days: 6 },
];
/** ★段階0 実測★: `continuousProblems()` の施設9の全行(★`week: 5` は行が出ない★) */
const LOCAL_DB_CONTINUOUS_PROBLEMS_9: readonly {
period: "weekly" | "monthly";
year: number;
month: number;
week: number;
item: Record<string, unknown> | null;
}[] = [
{
period: "weekly",
year: 2026,
month: 8,
week: 3,
item: {
facility_id: 9,
facility_name: FACILITY_9_NAME,
consecutive_problem_days: 1,
escalation_level: "L1",
last_problem_on: "2026-08-15",
summary: "{n}日連続で完了率が△以下",
launch_failed_days: 0,
},
},
{
period: "weekly",
year: 2026,
month: 8,
week: 4,
item: {
facility_id: 9,
facility_name: FACILITY_9_NAME,
consecutive_problem_days: 2,
escalation_level: "L2",
last_problem_on: "2026-08-22",
summary: "{n}日連続で△以下(うち{m}日は出発失敗)",
launch_failed_days: 1,
},
},
// ★この週だけ行が出ない(連続0日)★ — 「全期間を読んでいれば必ず行が出る」ではないことの固定
{ period: "weekly", year: 2026, month: 8, week: 5, item: null },
{
period: "weekly",
year: 2026,
month: 8,
week: 6,
item: {
facility_id: 9,
facility_name: FACILITY_9_NAME,
consecutive_problem_days: 2,
escalation_level: "L2",
last_problem_on: "2026-08-31",
summary: "{n}日連続で△以下(うち{m}日は出発失敗)",
launch_failed_days: 1,
},
},
{
period: "monthly",
year: 2026,
month: 8,
week: 0,
item: {
facility_id: 9,
facility_name: FACILITY_9_NAME,
consecutive_problem_days: 2,
escalation_level: "L2",
last_problem_on: "2026-08-31",
summary: "{n}日連続で△以下(うち{m}日は出発失敗)",
launch_failed_days: 1,
},
},
{
period: "weekly",
year: 2026,
month: 9,
week: 2,
item: {
facility_id: 9,
facility_name: FACILITY_9_NAME,
consecutive_problem_days: 5,
escalation_level: "L3",
last_problem_on: "2026-09-12",
summary: "{n}日連続で△以下(うち{m}日は出発失敗)",
launch_failed_days: 1,
},
},
{
period: "weekly",
year: 2026,
month: 9,
week: 3,
item: {
facility_id: 9,
facility_name: FACILITY_9_NAME,
consecutive_problem_days: 6,
escalation_level: "L3",
last_problem_on: "2026-09-13",
summary: "{n}日連続で△以下(うち{m}日は出発失敗)",
launch_failed_days: 1,
},
},
{
period: "monthly",
year: 2026,
month: 9,
week: 0,
item: {
facility_id: 9,
facility_name: FACILITY_9_NAME,
consecutive_problem_days: 6,
escalation_level: "L3",
last_problem_on: "2026-09-13",
summary: "{n}日連続で△以下(うち{m}日は出発失敗)",
launch_failed_days: 1,
},
},
];
/**
* 施設9 の実測行から、DB に入っているのと同じ形の行を組み立てる。
*
* ★★完了率が `null`(未実施)の行はレポートを作らない★★(指示書AQ §1-1)—
* ★旧実装では `clean_error = 1` の履歴行を作っていた★が、`clean_task_reports` には
* その列が無く、★未実施は「行が無いこと」でしか表せない★。
* ★★段階0 の実測スナップショット(連続日数)は1つも変わらないこと★★が合格条件。
*/
function facility9Rows(): {
timetables: ConsecutiveTimetableRow[];
reports: ConsecutiveTaskReportRow[];
} {
const timetables: ConsecutiveTimetableRow[] = [];
const reports: ConsecutiveTaskReportRow[] = [];
for (const [id, jstIso, rate] of LOCAL_DB_FACILITY_9) {
timetables.push(timetable(id, 9, jstIso));
if (rate === null) continue; // ★未実施 = レポートの行が無い★
reports.push(taskReport(id, rate, jstIso));
}
return { timetables, reports };
}
/** ★段階0 の実測より後の時刻★(未来除外が効かない基準時刻。実測時と同じ状態を再現する) */
const NOW_AFTER_LOCAL_DB = new Date("2026-09-30T00:00:00+09:00");
// ---------------------------------------------------------------- テスト
describe("loadConsecutiveInputs(指示書AC 段階1 — 連続日数の入力を1本に切り出す)", () => {
it("読む列を `select` で絞り、`include` を一切しない(★この2本だけが広い範囲を読むため★)", async () => {
const { prisma, calls } = stubPrisma(
[timetable(1, 1, "2026-09-01T02:00:00+09:00")],
[taskReport(1, 50)]
);
await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
expect(calls.cleanTimetable).toEqual([
{
// ★範囲なしのときは `start_at` のキーごと付けない★。
// ★削除済みの施設・清掃エリアの行は入力に入れない(指示書BB U1・表1 V15)。`select` は変えない(5-12)★
where: { precheck: CLEAN_TIMETABLE_PRECHECK.main, ...FACILITY_SECTION_VISIBLE_WHERE },
// ★`end_at` は結果待ちの判定用(D-173)★
select: { id: true, facility_id: true, start_at: true, end_at: true, precheck: true },
},
]);
// ★★履歴にも同じ引数が渡る★★(指示書AU 段階3b。`where` を2回書かない)
expect(calls.cleanTimetableHistory).toEqual(calls.cleanTimetable);
expect(calls.cleanTaskReport).toEqual([
{
// ★レポートは「引いた本清掃の id」で引き直す(全件読みに戻さない)★
where: { clean_timetable_id: { in: [BigInt(1)] } },
select: {
clean_timetable_id: true,
clean_ratio: true,
// ★★`clean_error` は `clean_task_reports` に無い★★(指示書AQ §1-1)。
// ★この行が復活したら未実施の判定規則が戻っている★
// ★順序キーのフォールバック★(`ended_at` が nullable。指示書AQ §1-4)
started_at: true,
ended_at: true,
// ★旧: 要レスキューの急落判定(R-3)の面積の重み付けに読んでいた2列(指示書AI 段階2)★。
// ★2026-09-30 から R-3 は1回ごとの完了率の単純平均で、この2列は完了率の平均には使わない★(指示書AQ 決定4。D-155 以降の1回ごとの完了率は面積から `runCompletionRate()` が作るので、この2列はそのために読んでいる)。
// ★これ以上増やすときの条件は `loadConsecutiveInputs()` の docstring★
clean_area: true,
task_area: true,
},
},
]);
// ★`include` を足していないこと(キーが `where` と `select` だけ)★
for (const args of [...calls.cleanTimetable, ...calls.cleanTimetableHistory, ...calls.cleanTaskReport]) {
expect(Object.keys(args as object).sort()).toEqual(["select", "where"]);
}
});
it("★履歴(`clean_timetable_histories`)だけにある予定も読み、レポートを引き直す★(指示書AU 段階3b)", async () => {
const { prisma, calls } = stubPrisma(
[timetable(1, 1, "2026-09-01T02:00:00+09:00")],
[taskReport(1, 50), taskReport(2, 80)],
[timetable(2, 1, "2026-09-02T02:00:00+09:00")]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
expect(rows.map((r) => r.id).sort()).toEqual([1, 2]);
// ★レポートは合わせた後の id で引く(履歴の予定に紐づく行も取れる)★
const reportIds = (calls.cleanTaskReport as { where: { clean_timetable_id: { in: bigint[] } } }[])
.flatMap((c) => c.where.clean_timetable_id.in)
.map(String)
.sort();
expect(reportIds).toEqual(["1", "2"]);
});
it("本清掃が0件なら履歴のクエリを1本も撃たない(`in: []` の空撃ちをしない)", async () => {
const { prisma, calls } = stubPrisma([], []);
const rows = await loadConsecutiveInputs(
prisma,
NOW_AFTER_LOCAL_DB,
CONSECUTIVE_RANGE_UNBOUNDED
);
expect(rows).toEqual([]);
expect(calls.cleanTaskReport).toEqual([]);
});
it("本清掃だけに絞る(事前チェック行は入れない)", async () => {
const { prisma } = stubPrisma(
[
timetable(1, 1, "2026-09-01T02:00:00+09:00"),
timetable(2, 1, "2026-09-01T01:00:00+09:00", 60),
timetable(3, 1, "2026-09-01T01:30:00+09:00", 30),
],
[taskReport(1, 50), taskReport(2, 0), taskReport(3, 0)]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
expect(rows.map((r) => r.id)).toEqual([1]);
});
it("★`isFutureSchedule()` を外さない★ — 予定時刻がまだ来ていない本清掃を除外する(D-106 / D-107 / D-110)", async () => {
const now = new Date("2026-09-10T03:00:00+09:00");
const { prisma } = stubPrisma(
[
timetable(1, 1, "2026-09-10T02:00:00+09:00"),
timetable(2, 1, "2026-09-10T03:00:00+09:00"), // ★同時刻は「来ていない」に入れない★
timetable(3, 1, "2026-09-11T02:00:00+09:00"), // 未来 → 除外
],
// ★id 2 にもレポートを付ける★ — 無いと結果待ち(D-173)で外れ、`isFutureSchedule()` の境界を確かめられない
[taskReport(1, 50), taskReport(2, 50)]
);
const rows = await loadConsecutiveInputs(prisma, now, CONSECUTIVE_RANGE_UNBOUNDED);
expect(rows.map((r) => r.id)).toEqual([1, 2]);
// ★未来を外さないと履歴の無い行が「未実施(出発失敗)」として連続日数に混ざる★
expect(rows.find((r) => r.id === 3)).toBeUndefined();
});
it("★結果待ち(D-173。表2 C10)は未来の予定と同じく入力から外す★ — 確定した出発失敗は残す", async () => {
// 予定 02:00〜03:00、W=120(表1 と同じ時刻)。now=04:59 は結果待ち、レポートありは残る
const day = "2026-09-10";
const { prisma } = stubPrisma(
[
timetable(1, 1, `${day}T02:00:00+09:00`, CLEAN_TIMETABLE_PRECHECK.main, `${day}T03:00:00+09:00`), // レポートなし → 結果待ち
timetable(2, 1, `${day}T02:00:00+09:00`, CLEAN_TIMETABLE_PRECHECK.main, `${day}T03:00:00+09:00`), // レポートあり
timetable(3, 1, `${day}T00:00:00+09:00`, CLEAN_TIMETABLE_PRECHECK.main, `${day}T02:59:00+09:00`), // レポートなし・04:59 で確定
],
[taskReport(2, 50)]
);
const pending = await loadConsecutiveInputs(
prisma,
new Date(`${day}T04:59:00+09:00`),
CONSECUTIVE_RANGE_UNBOUNDED
);
expect(pending.map((r) => r.id).sort()).toEqual([2, 3]);
expect(pending.find((r) => r.id === 3)?.departed).toBe(false);
// ★終了+120分ちょうど(05:00)で確定 → 出発失敗として入力に戻る(表1 P6)★
const settled = await loadConsecutiveInputs(
prisma,
new Date(`${day}T05:00:00+09:00`),
CONSECUTIVE_RANGE_UNBOUNDED
);
expect(settled.map((r) => r.id).sort()).toEqual([1, 2, 3]);
expect(settled.find((r) => r.id === 1)?.departed).toBe(false);
});
it("`start_at` を壁時計値として読む(`fromDbJst()` / `jstIsoFromDb()` を通す。D-115)", async () => {
const { prisma } = stubPrisma(
[timetable(1, 7, "2026-09-10T20:00:00+09:00")],
[taskReport(1, 88.6)]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
expect(rows).toEqual([
{
id: 1,
unscheduled: false,
facility_id: 7,
scheduled_at: "2026-09-10T20:00:00+09:00",
completion_rate: 88.6,
departed: true,
// ★`taskReport()` の既定(面積 88.6/100)。この検証の対象は日時の読み方であって面積ではない★
clean_area: 88.6,
task_area: 100,
},
]);
});
it("★未実施=レポートの行が無いこと★。完了率 0(面積 0/100)は 0 のまま(0%実施に化けさせない)", async () => {
const { prisma } = stubPrisma(
[
timetable(1, 1, "2026-09-01T02:00:00+09:00"), // ★レポートなし → 未実施★
timetable(2, 1, "2026-09-02T02:00:00+09:00"), // ★0% で実施★
timetable(3, 1, "2026-09-03T02:00:00+09:00"), // ★レポートなし → 未実施★
],
[taskReport(2, 0)]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
// ★「0%で実施した」と「未実施」は別物★(指示書U 落とし穴1。判定はどちらも × なので
// ★画面もテストも赤くならないまま壊れる★ところ)
expect(rows.map((r) => r.completion_rate)).toEqual([null, 0, null]);
});
it("レポートが複数あるときは順序キーが最新の1件を採る(指示書AQ §1-4)", async () => {
const { prisma } = stubPrisma(
[timetable(1, 1, "2026-09-01T02:00:00+09:00")],
[
taskReport(1, 10, "2026-09-01T03:00:00+09:00"),
taskReport(1, 90, "2026-09-01T05:00:00+09:00"),
taskReport(1, 40, "2026-09-01T04:00:00+09:00"),
]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
expect(rows[0].completion_rate).toBe(90);
});
it("★面積の生値をそのまま持ち帰る(レポートなしは 0)★(指示書AI 段階2。★R-3 は面積で重み付けしない★ — 指示書AQ 決定4)", async () => {
const { prisma } = stubPrisma(
[
timetable(1, 1, "2026-09-01T02:00:00+09:00"),
timetable(2, 1, "2026-09-02T02:00:00+09:00"),
timetable(3, 1, "2026-09-03T02:00:00+09:00"),
],
[
taskReport(1, 90, "2026-09-01T05:00:00+09:00", 900, 1000),
// ★面積0(`task_area = 0`)で実施したレポート★(★未実施ではない★。表5 U2)
taskReport(2, 0, "2026-09-02T05:00:00+09:00", 0, 0),
// id=3 はレポートなし = ★未実施★
]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
expect(rows.map((r) => [r.clean_area, r.task_area])).toEqual([
[900, 1000],
[0, 0],
// ★★レポートが無い行は `undefined` ではなく 0★★(Prisma 版と fixtures 版の形を揃えるため。
// 旧: optional にすると R-3 の面積加重が静かに壊れる、という理由だった)
[0, 0],
]);
// ★★完了率は1回ごとの面積比(`runCompletionRate()`。D-155)★★ —
// id=1 は 900/1000 = 90、★id=2 は `task_area = 0` なので 0(P-5=B)★、id=3 はレポートなしで null
expect(rows.map((r) => r.completion_rate)).toEqual([90, 0, null]);
// ★★未実施は `departed` で見る★★(【未決1】案A。D-156)— ★id=2 は出発している★
expect(rows.map((r) => r.departed)).toEqual([true, true, false]);
});
it("★独立に書いた素朴な実装と完全一致する★(本体のヘルパーを1つも使わない突き合わせ)", () => {
const now = new Date("2026-09-10T03:00:00+09:00");
const timetables = [
timetable(1, 1, "2026-09-08T02:00:00+09:00"),
timetable(2, 1, "2026-09-09T02:00:00+09:00"), // ★レポートなし = 未実施★
timetable(3, 1, "2026-09-10T02:00:00+09:00"),
timetable(4, 1, "2026-09-11T02:00:00+09:00"), // 未来
timetable(5, 2, "2026-09-08T01:00:00+09:00", 60), // 事前チェック
timetable(6, 2, "2026-09-08T02:00:00+09:00"),
timetable(7, 2, "2026-09-09T02:00:00+09:00"), // ★`ended_at` が NULL のレポート★
];
const reports = [
taskReport(1, 95),
// ★id=2 のレポートは作らない(未実施)★
taskReport(3, 0),
taskReport(4, 10),
taskReport(5, 0),
taskReport(6, 70),
// ★`clean_timetable_id` が NULL の行を混ぜる(罠1)★ — どの清掃にも結びつかない
taskReport(null, 100),
// ★`ended_at` が NULL の行を混ぜる(罠3 / §1-4)★
taskReport(7, 55, null, 55, 100, "2026-09-09T03:00:00+09:00"),
// ★`task_area = 0` の行(完了率 0。P-5=B・出発はしている。表5 U2)★
taskReport(6, 80, "2026-09-14T05:00:00+09:00", 10, 0),
];
const actual = consecutiveInputsFromRows(timetables, reports, now, []);
const legacy = legacyConsecutiveInputs(timetables, reports, now);
// ★`scheduled_at` の作り方だけは legacy 側の写しで表現できないので、id と値で突き合わせる★
expect(actual.map((r) => [r.id, r.facility_id, r.completion_rate])).toEqual(
legacy.map((r) => [r.id, r.facility_id, r.completion_rate])
);
expect(actual.map((r) => r.scheduled_at)).toEqual([
"2026-09-08T02:00:00+09:00",
"2026-09-09T02:00:00+09:00",
"2026-09-10T02:00:00+09:00",
"2026-09-08T02:00:00+09:00",
"2026-09-09T02:00:00+09:00",
]);
});
});
describe("指示書AQ — 参照元を `clean_task_reports` に差し替える(期待値表 Q1〜Q10)", () => {
it("Q3 ★`clean_timetable_id` が NULL のレポートは `IN` に一致せず、その清掃は「未実施」になる★(罠1)", async () => {
const { prisma } = stubPrisma(
[
timetable(1, 1, "2026-09-01T02:00:00+09:00"),
timetable(2, 1, "2026-09-02T02:00:00+09:00"),
],
[
taskReport(1, 95),
// ★本来は id=2 の結果だが `clean_timetable_id` が埋まっていない行★
// (指示書AQ 決定5 — TeamWorks が今後埋める前提)
taskReport(null, 95),
]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
// ★★行が消えるのではなく「未実施」に化けるのが危険なところ★★(fail-silent)。
// ★95% で実施されていても画面には未実施として出る★
expect(rows.map((r) => [r.id, r.completion_rate])).toEqual([
[1, 95],
[2, null],
]);
});
it("Q3c ★`clean_timetable_id` が 0(予定外。TeamWorks 回答 2026-10-27)のレポートも引き当たらない・0 を `IN` に入れない★", async () => {
const { prisma, calls } = stubPrisma(
[
// ★予定の id は自動採番で 0 にならないが、仮に 0 の予定があっても予定外のレポートを引き当てないこと★
timetable(0, 1, "2026-09-01T02:00:00+09:00"),
timetable(2, 1, "2026-09-02T02:00:00+09:00"),
],
[taskReport(0, 95), taskReport(2, 80)]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
const inIds = calls.cleanTaskReport.flatMap(
(call) => (call as { where: { clean_timetable_id: { in: bigint[] } } }).where.clean_timetable_id.in
);
expect(inIds).toEqual([BigInt(2)]);
// ★予定外(0)のレポートの 95% は、どの予定の完了率にもならない★
expect(rows.map((r) => [r.id, r.completion_rate])).toEqual([
[0, null],
[2, 80],
]);
});
it("Q3b ★NULL のレポートは `latestReportByTimetableId()`(担当C)でも落ちる★(Map のキーにできない)", () => {
const map = latestReportByTimetableId([
taskReport(null, 10),
taskReport(null, 20),
taskReport(1, 30),
]);
// ★★NULL 同士を1つのグループにまとめないこと★★(まとめると `null` キーで引けてしまう)
expect(map.size).toBe(1);
expect(map.get("1")?.clean_ratio).toBe(30);
});
it("Q5 ★`ended_at` が NULL の行は `started_at` を順序キーにする★(指示書AQ §1-4)", async () => {
const { prisma } = stubPrisma(
[timetable(1, 1, "2026-09-01T02:00:00+09:00")],
[
// ★`ended_at` あり(03:00)★
taskReport(1, 10, "2026-09-01T03:00:00+09:00", 10, 100, "2026-09-01T02:30:00+09:00"),
// ★`ended_at` なし → `started_at`(05:00)が順序キー★ → こちらが最新
taskReport(1, 90, null, 90, 100, "2026-09-01T05:00:00+09:00"),
]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
// ★`ended_at` だけを見て NULL 行を落とすと 10 になる★
expect(rows[0].completion_rate).toBe(90);
});
it("Q6 ★両方の `ended_at` が NULL なら `started_at` が新しいほう★", async () => {
const { prisma } = stubPrisma(
[timetable(1, 1, "2026-09-01T02:00:00+09:00")],
[
taskReport(1, 10, null, 10, 100, "2026-09-01T03:00:00+09:00"),
taskReport(1, 90, null, 90, 100, "2026-09-01T05:00:00+09:00"),
taskReport(1, 40, null, 40, 100, "2026-09-01T04:00:00+09:00"),
]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
expect(rows[0].completion_rate).toBe(90);
});
it("Q7 ★順序キーが同着なら入力順の先頭★(`clean_status_histories` 時代と同じ規則)", () => {
const map = latestReportByTimetableId([
taskReport(1, 11, "2026-09-01T03:00:00+09:00"),
taskReport(1, 22, "2026-09-01T03:00:00+09:00"),
]);
expect(map.get("1")?.clean_ratio).toBe(11);
});
// ★★順序キーそのものの単体テストはここに書かない★★ — 担当C が
// `reportOrderKey()` を ★意図的に export していない★(規則を1箇所に閉じるため。
// `lib/db/clean-log-mapping.ts`)。★このファイルは Q5 / Q6 で**挙動として**固定する★。
it("Q1 / Q2 / Q8 ★未実施はレポートの行が無いことだけで決まる★(`completionRateFromReport()`)", () => {
// ★★`status`(3/4/5/6)で分岐しないこと★★ — 判定には使わない(D-155 項2 / 指示書AS D12)
expect(completionRateFromReport(undefined)).toBeNull();
// ★完了率は面積から(表1。`clean_ratio` 列は読まない)★
expect(completionRateFromReport({ clean_area: 50, task_area: 100, clean_ratio: 50 })).toBe(50);
// ★「0%で実施した」を `null` に化けさせない★
expect(completionRateFromReport({ clean_area: 0, task_area: 100, clean_ratio: 0 })).toBe(0);
// ★平均側では丸めない★(`CLAUDE.md` §4.3。89.5 は ○ のまま)
expect(completionRateFromReport({ clean_area: 89.5, task_area: 100, clean_ratio: 89.5 })).toBe(
89.5
);
});
// ★★表5(指示書AS §4。【未決1】案A。D-156)— `departed` の詰め方★★
// | # | 清掃 | departed | completion_rate |
// | U1 | レポートの行が無い | false | null |
// | U2 | ★行がある・task_area = 0★ | ★true★ | ★0.0★(P-5=B) |
// | U3 | 行がある・50/100 | true | 50.0 |
// | U4 | 行がある・100/100 | true | 100.0 |
it("表5 U1〜U4: `departed` はレポートの行の有無で詰める(完了率 null とは別)", () => {
const now = new Date("2026-09-10T03:00:00+09:00");
const rows = consecutiveInputsFromRows(
[
timetable(1, 1, "2026-09-01T02:00:00+09:00"), // U1
timetable(2, 1, "2026-09-02T02:00:00+09:00"), // U2
timetable(3, 1, "2026-09-03T02:00:00+09:00"), // U3
timetable(4, 1, "2026-09-04T02:00:00+09:00"), // U4
],
[taskReport(2, 0, undefined, 10, 0), taskReport(3, 50), taskReport(4, 100)],
now,
[]
);
expect(rows.map((r) => [r.id, r.departed, r.completion_rate])).toEqual([
[1, false, null],
[2, true, 0],
[3, true, 50],
[4, true, 100],
]);
});
});
describe("段階0(refactor 前)のローカルDB実測値と一致すること", () => {
it("#1 本清掃の行だけを全件返す(施設9 の実測62行。全体は 538 件)", async () => {
const { timetables, reports } = facility9Rows();
// ★事前チェック行と未来の行を混ぜても件数が変わらないこと★
const withNoise = [
...timetables,
timetable(90001, 9, "2026-09-13T01:30:00+09:00", 60),
timetable(90002, 9, "2026-10-01T02:30:00+09:00"),
];
const { prisma } = stubPrisma(withNoise, reports);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
expect(rows).toHaveLength(LOCAL_DB_FACILITY_9.length);
expect(rows).toHaveLength(62);
// ★段階0 実測の全件数(`db.cleanLogs()` = 538件)はこの施設9の62件を含む★
expect(LOCAL_DB_TOTAL_MAIN_ROWS).toBeGreaterThan(rows.length);
});
it("#2 `computeConsecutiveProblemDays()` の結果が段階0 のスナップショットと完全一致する", async () => {
const { timetables, reports } = facility9Rows();
const { prisma } = stubPrisma(timetables, reports);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
const consecutiveById = computeConsecutiveProblemDays(rows);
const actual = [...consecutiveById.entries()].sort((a, b) => a[0] - b[0]);
const expected = LOCAL_DB_FACILITY_9.map(
([id, , , days]): [number, number] => [id, days]
).sort((a, b) => a[0] - b[0]);
expect(actual).toEqual(expected);
});
it("#3 `facilityPerformance()` の `consecutive_problem_days` が段階0 と一致する", async () => {
const { timetables, reports } = facility9Rows();
const { prisma } = stubPrisma(timetables, reports);
const entries = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
const consecutiveByFacilityDate = consecutiveProblemDaysByFacilityDate(entries);
for (const expected of LOCAL_DB_FACILITY_PERFORMANCE_9) {
const range = periodRange(
expected.period,
expected.year,
expected.month,
expected.week
);
expect(range).not.toBeNull();
const days = consecutiveProblemDaysAtPeriodEnd(
entries,
consecutiveByFacilityDate,
9,
range as { from: string; to: string }
);
expect({ ...expected, consecutive_problem_days: days }).toEqual(expected);
}
});
it("#4 `continuousProblems()` の行が段階0 と一致する(行が出ない期間も含む)", async () => {
const { timetables, reports } = facility9Rows();
const { prisma } = stubPrisma(timetables, reports);
const entries = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
const consecutiveByFacilityDate = consecutiveProblemDaysByFacilityDate(entries);
const facilityNameById = new Map<number, string>([[9, FACILITY_9_NAME]]);
for (const expected of LOCAL_DB_CONTINUOUS_PROBLEMS_9) {
const range = periodRange(
expected.period,
expected.year,
expected.month,
expected.week
);
expect(range).not.toBeNull();
const items = continuousProblemsInPeriod(
entries,
consecutiveByFacilityDate,
facilityNameById,
range as { from: string; to: string }
);
expect(items).toEqual(expected.item === null ? [] : [expected.item]);
}
});
});
describe("合成データ(段階1 期待値表 #5 / #6)", () => {
it("#5 同一施設・2台 — 1日目にA、2日目にBが問題なら★2日連続★(D-109 A案)", async () => {
// 施設1 に清掃エリアが2つ(ロボットA: id 1,2 / ロボットB: id 11,12)
const { prisma } = stubPrisma(
[
timetable(1, 1, "2026-09-01T01:00:00+09:00"), // A: 問題あり
timetable(11, 1, "2026-09-01T02:00:00+09:00"), // B: 正常
timetable(2, 1, "2026-09-02T01:00:00+09:00"), // A: 正常
timetable(12, 1, "2026-09-02T02:00:00+09:00"), // B: 問題あり
],
[taskReport(1, 30), taskReport(11, 95), taskReport(2, 95), taskReport(12, 30)]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
const map = computeConsecutiveProblemDays(rows);
expect(map.get(1)).toBe(1);
expect(map.get(11)).toBe(1); // ★同じ日の全行が同じ値になる★
expect(map.get(2)).toBe(2);
expect(map.get(12)).toBe(2);
});
it("#6 清掃予定のない日を挟んでも連続が途切れない(D-026。「連続」はスケジュール存在日の連続)", async () => {
const { prisma } = stubPrisma(
[
timetable(1, 1, "2026-09-01T02:00:00+09:00"),
// 9/2・9/3 は清掃予定なし(行そのものが無い)
timetable(2, 1, "2026-09-04T02:00:00+09:00"),
timetable(3, 1, "2026-09-05T02:00:00+09:00"),
],
[taskReport(1, 30), taskReport(2, 30), taskReport(3, 30)]
);
const rows = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, CONSECUTIVE_RANGE_UNBOUNDED);
const map = computeConsecutiveProblemDays(rows);
expect(map.get(1)).toBe(1);
expect(map.get(2)).toBe(2);
expect(map.get(3)).toBe(3);
});
});
// ---------------------------------------------------------------- 段階6(遡り範囲を有界にする)
/**
* 指示書AC ★段階6★ の期待値表(7行)。
*
* | # | 入力 | 期待 |
* |---|---|---|
* | 1 | 連続が上限未満 | 段階5(全期間読み)と同じ数字 |
* | 2 | 連続が上限を超える | 上限で頭打ち。かつ「以上」と分かる表示(`capped: true`) |
* | 3 | 清掃予定のない日を挟む | 連続が途切れない(D-026。★カレンダー日で遡ると壊れるところ★) |
* | 4 | `/detail-log` と `/reports` | 同じ施設・同じ日で同じ数字(D-109 の再発防止) |
* | 5 | 管理画面で上限を大きくした | クエリの遡り範囲も追随して広がる |
* | 6 | 管理画面で上限を小さくした | 表示も遡り範囲も小さいほうに追随する |
* | 7 | 上限を超える連続日数のデータ | `capped=true` が実際に出る(=「上限 +1日ぶん」読めていることの検出) |
*
* ★★#7 が無いと、`+1` を忘れても #1〜#6 は全部緑のままです★★
* (`rawDays` が上限を超えられなくなるだけで、他の行はどれも落ちない)。
*/
/** `YYYY-MM-DD` に日数を足す(★実行環境のタイムゾーンに依存しない UTC 演算★) */
function jstDatePlus(date: string, days: number): string {
return new Date(new Date(`${date}T00:00:00Z`).getTime() + days * 86_400_000)
.toISOString()
.slice(0, 10);
}
/**
* 「問題あり(完了率 5% = ×)」の清掃を `days` 日ぶん作る。
* @param stepDays 清掃予定日の間隔(★1 なら毎日、3 なら2日おきに予定が無い★。D-026 の検証用)
*/
function problemRun(
facilityId: number,
lastDate: string,
days: number,
startId: number,
stepDays = 1
): {
timetables: ConsecutiveTimetableRow[];
reports: ConsecutiveTaskReportRow[];
lastId: number;
} {
const timetables: ConsecutiveTimetableRow[] = [];
const reports: ConsecutiveTaskReportRow[] = [];
for (let i = 0; i < days; i++) {
const date = jstDatePlus(lastDate, -(days - 1 - i) * stepDays);
const id = startId + i;
timetables.push(timetable(id, facilityId, `${date}T02:00:00+09:00`));
reports.push(taskReport(id, 5, `${date}T03:00:00+09:00`));
}
return { timetables, reports, lastId: startId + days - 1 };
}
/** その範囲で読んだときの、指定ログの ★丸める前の★ 連続日数と、実際の遡り下限 */
async function rawConsecutiveDays(
rows: { timetables: ConsecutiveTimetableRow[]; reports: ConsecutiveTaskReportRow[] },
range: ConsecutiveDbRange,
logId: number
): Promise<{ days: number; gte: Date | undefined }> {
const { prisma, calls } = stubPrisma(rows.timetables, rows.reports);
const entries = await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, range);
const map = computeConsecutiveProblemDays(entries);
const args = calls.cleanTimetable[0] as { where?: { start_at?: { gte?: Date } } };
return { days: map.get(logId) ?? 0, gte: args.where?.start_at?.gte };
}
/** 遡り下限(`gte`)として期待される「DBの壁時計値」 */
function expectedGte(dateFrom: string, maxDays: number): Date {
const from = consecutiveLookbackFrom(dateFrom, maxDays);
expect(from).not.toBeNull();
return dbWallClock(`${from as string}T00:00:00+09:00`);
}
describe("consecutiveLookbackFrom / consecutiveLookbackCalendarDays(純粋関数)", () => {
it("★下限なし(`null`)はそのまま `null`★ — 全期間を読む(単一ID取得の経路)", () => {
expect(consecutiveLookbackFrom(null, CONSECUTIVE_DAYS_DISPLAY.defaultMax)).toBeNull();
// 形式が不正な文字列も「指定なし」(検証は `parseCleanLogQuery()` の仕事)
expect(consecutiveLookbackFrom("2026/09/13", 30)).toBeNull();
});
it("★★遡り幅は必ず「上限 +1日ぶん」以上★★(#7 の検出条件。★ここを割ると `capped` が永久に false★)", () => {
for (const maxDays of [
CONSECUTIVE_DAYS_DISPLAY.minMax,
CONSECUTIVE_DAYS_DISPLAY.defaultMax,
CONSECUTIVE_DAYS_DISPLAY.maxMax,
]) {
expect(consecutiveLookbackCalendarDays(maxDays)).toBeGreaterThanOrEqual(maxDays + 1);
}
// ★`plusDays` を 0 にしないこと★(設定値そのものを固定しておく)
expect(CONSECUTIVE_LOOKBACK.plusDays).toBeGreaterThanOrEqual(1);
expect(CONSECUTIVE_LOOKBACK.calendarDaysPerScheduleDay).toBeGreaterThanOrEqual(1);
});
it("★カレンダー日で数え、スケジュール存在日より**広く**取る★(D-026 の安全側)", () => {
// 上限30日 → (30 + 1) × 7 = 217 カレンダー日ぶん遡る
expect(consecutiveLookbackCalendarDays(30)).toBe(217);
expect(consecutiveLookbackFrom("2026-09-13", 30)).toBe(jstDatePlus("2026-09-13", -217));
});
});
describe("段階6 — 連続日数のクエリを有界にする", () => {
it("#1 連続が上限未満なら、段階5(全期間読み)と同じ数字になる", async () => {
useSavedConsecutiveDaysMax(undefined); // 既定値 30
const rows = problemRun(1, "2026-09-13", 5, 10_000);
const bounded = await rawConsecutiveDays(
rows,
{ date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
rows.lastId
);
const unbounded = await rawConsecutiveDays(
rows,
CONSECUTIVE_RANGE_UNBOUNDED,
rows.lastId
);
expect(bounded.days).toBe(5);
expect(bounded.days).toBe(unbounded.days);
expect(
capConsecutiveDays(bounded.days, CONSECUTIVE_DAYS_DISPLAY.defaultMax).capped
).toBe(false);
});
it("#2 連続が上限を超えたら上限で頭打ちになり、「以上」と分かる表示になる", async () => {
const maxDays = 3;
useSavedConsecutiveDaysMax(maxDays);
const rows = problemRun(1, "2026-09-13", 10, 20_000);
const { days } = await rawConsecutiveDays(
rows,
{ date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
rows.lastId
);
expect(days).toBe(10); // ★判定に使う生値は頭打ちにしない(CLAUDE.md §4.3)★
expect(capConsecutiveDays(days, maxDays)).toEqual({ days: 3, capped: true });
});
it("#3 ★清掃予定のない日を挟んでも連続が途切れない★(D-026。カレンダー日で遡ると壊れるところ)", async () => {
const maxDays = 3;
useSavedConsecutiveDaysMax(maxDays);
// 3日おきの清掃予定を4回 → 9/04・9/07・9/10・9/13(★スケジュール存在日では4日連続★)
const rows = problemRun(1, "2026-09-13", 4, 30_000, 3);
const { days, gte } = await rawConsecutiveDays(
rows,
{ date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
rows.lastId
);
// ★★「上限+1」をカレンダー日として遡る実装(9/09 まで)だと 2 になる★★
expect(days).toBe(4);
// 実際の遡り下限は (3+1)×7 = 28 カレンダー日前(= 8/16)
expect(gte).toEqual(expectedGte("2026-09-13", maxDays));
});
it("#4 `/detail-log`(1日)と `/reports`(週・月)で同じ施設・同じ日の数字が一致する", async () => {
useSavedConsecutiveDaysMax(undefined);
const rows = problemRun(1, "2026-09-13", 8, 40_000);
// `/detail-log` … `cleanLogs()` は画面の日付範囲をそのまま渡す
const detailLog = await rawConsecutiveDays(
rows,
{ date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
rows.lastId
);
// `/reports` … `continuousProblems()` / `facilityPerformance()` は `periodRange()` の範囲
const weekly = periodRange("weekly", 2026, 9, 3) as { from: string; to: string };
const monthly = periodRange("monthly", 2026, 9, 0) as { from: string; to: string };
const reportsWeekly = await rawConsecutiveDays(
rows,
{ date_from: weekly.from, date_to: weekly.to, facility_ids: null },
rows.lastId
);
const reportsMonthly = await rawConsecutiveDays(
rows,
{ date_from: monthly.from, date_to: monthly.to, facility_ids: null },
rows.lastId
);
expect(detailLog.days).toBe(8);
expect(reportsWeekly.days).toBe(8);
expect(reportsMonthly.days).toBe(8);
});
it("#4b 上限を超える連続でも、3経路とも同じ「{上限}日以上」になる(D-109 の再発防止)", async () => {
const maxDays = 3;
useSavedConsecutiveDaysMax(maxDays);
const rows = problemRun(1, "2026-09-13", 60, 45_000);
const weekly = periodRange("weekly", 2026, 9, 3) as { from: string; to: string };
const shown = [];
for (const range of [
{ date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
{ date_from: weekly.from, date_to: weekly.to, facility_ids: null },
CONSECUTIVE_RANGE_UNBOUNDED,
]) {
const { days } = await rawConsecutiveDays(rows, range, rows.lastId);
shown.push(capConsecutiveDays(days, maxDays));
}
// ★生値は経路ごとに違いうる(読める範囲が違う)が、★★画面に出る値は一致する★★
expect(shown).toEqual([
{ days: 3, capped: true },
{ days: 3, capped: true },
{ days: 3, capped: true },
]);
});
it("#5 ★管理画面で上限を大きくすると、クエリの遡り範囲も追随して広がる★", async () => {
const rows = problemRun(1, "2026-09-13", 100, 50_000);
const range: ConsecutiveDbRange = { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null };
useSavedConsecutiveDaysMax(3);
const small = await rawConsecutiveDays(rows, range, rows.lastId);
useSavedConsecutiveDaysMax(90);
const large = await rawConsecutiveDays(rows, range, rows.lastId);
// ★遡り下限が実際に古くなっていること★
expect(small.gte).toEqual(expectedGte("2026-09-13", 3));
expect(large.gte).toEqual(expectedGte("2026-09-13", 90));
expect(large.gte?.getTime()).toBeLessThan(small.gte?.getTime() ?? 0);
// ★★上限を上げたぶん、実際に読めた行数(=生値)も増えること★★ —
// ★設定だけ大きくしてクエリが古い範囲のままだと、ここが変わらない(静かな不整合。申し送り⑪)★
expect(small.days).toBe(consecutiveLookbackCalendarDays(3) + 1); // 28日前〜当日 = 29日
expect(large.days).toBe(100); // 637日ぶん読めるので全部入る
expect(capConsecutiveDays(large.days, 90)).toEqual({ days: 90, capped: true });
});
it("#6 ★管理画面で上限を小さくすると、表示も遡り範囲も小さいほうに追随する★", async () => {
const rows = problemRun(1, "2026-09-13", 100, 60_000);
const range: ConsecutiveDbRange = { date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null };
useSavedConsecutiveDaysMax(CONSECUTIVE_DAYS_DISPLAY.defaultMax);
const before = await rawConsecutiveDays(rows, range, rows.lastId);
useSavedConsecutiveDaysMax(CONSECUTIVE_DAYS_DISPLAY.minMax);
const after = await rawConsecutiveDays(rows, range, rows.lastId);
expect(after.gte?.getTime()).toBeGreaterThan(before.gte?.getTime() ?? 0);
expect(after.days).toBeLessThan(before.days);
expect(capConsecutiveDays(after.days, CONSECUTIVE_DAYS_DISPLAY.minMax)).toEqual({
days: CONSECUTIVE_DAYS_DISPLAY.minMax,
capped: true,
});
});
it("#7 ★★上限をちょうど1日超える連続で `capped: true` が出る★★(=「上限 +1日ぶん」読めている証拠)", async () => {
const maxDays = CONSECUTIVE_DAYS_DISPLAY.minMax; // 3
useSavedConsecutiveDaysMax(maxDays);
// ★ちょうど「上限 +1日」ぶんだけ問題が続く★
const rows = problemRun(1, "2026-09-13", maxDays + 1, 70_000);
const { days } = await rawConsecutiveDays(
rows,
{ date_from: "2026-09-13", date_to: "2026-09-13", facility_ids: null },
rows.lastId
);
// ★★上限ぴったりまでしか読まない実装だと `days === maxDays` になり、下の `capped` が false に★★
expect(days).toBe(maxDays + 1);
expect(capConsecutiveDays(days, maxDays).capped).toBe(true);
});
});
// ★表10 N1〜N3(指示書BB A6・D16・D-182)★ — `facility_ids` の `null`(絞らない)と `[]`(0行)を取り違えない(罠1)
describe("loadConsecutiveInputs — 施設で絞る(指示書BB 表10 N1〜N3)", () => {
function timetableWheres(calls: Record<string, unknown[]>) {
return [...calls.cleanTimetable, ...calls.cleanTimetableHistory].map(
(c) => (c as { where: Record<string, unknown> }).where
);
}
it("N1: `facility_ids: null` → 予定の `where` に `facility_id` の条件が無い(今と同じ SQL)", async () => {
const { prisma, calls } = stubPrisma([timetable(1, 1, "2026-09-01T02:00:00+09:00")], []);
await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, { date_from: null, date_to: null, facility_ids: null });
const wheres = timetableWheres(calls);
expect(wheres).toHaveLength(2);
for (const where of wheres) expect(where).not.toHaveProperty("facility_id");
});
it("N2: `facility_ids: [1, 3]` → 2表とも `facility_id: { in: [1n, 3n] }`", async () => {
const { prisma, calls } = stubPrisma([timetable(1, 1, "2026-09-01T02:00:00+09:00")], []);
await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, { date_from: null, date_to: null, facility_ids: [1, 3] });
const wheres = timetableWheres(calls);
expect(wheres).toHaveLength(2);
for (const where of wheres) expect(where.facility_id).toEqual({ in: [BigInt(1), BigInt(3)] });
});
it("★N3: `facility_ids: []` → `facility_id: { in: [] }`(`null` と取り違えない。罠1)★", async () => {
const { prisma, calls } = stubPrisma([timetable(1, 1, "2026-09-01T02:00:00+09:00")], []);
await loadConsecutiveInputs(prisma, NOW_AFTER_LOCAL_DB, { date_from: null, date_to: null, facility_ids: [] });
for (const where of timetableWheres(calls)) expect(where.facility_id).toEqual({ in: [] });
});
});
// ══════════════════════════════════════════════════════════════════════════════
// ★★指示書BG §2-3 — 予定外タスクも連続日数・要レスキュー・連続問題の入力に入れる★★
// ══════════════════════════════════════════════════════════════════════════════
/**
* 前提(指示書BG §3): 今 = 2026-10-29 10:00 JST、施設 F1(id 1)。
*
* ★表U4(監督が書いた。数字を変えない)★
* | # | 施設 F1 の日ごとの結果 | 期待 |
* |---|---|---|
* | U4-1 | 10-26 予定 ×、10-27 予定外だけ(完了率 50)、10-28 予定 △ | 3日連続 → L3 |
* | U4-2 | 10-27 に予定 ◎ と 予定外 △ | その日は問題日(OR) |
* | U4-3 | 10-27 予定外だけ(完了率 95) | 問題日ではない → 連続が切れる |
*
* ★表U1 のうち連続日数の入力にかかるもの★(U1-2 / U1-3 / U1-4 / U1-5)も下で固める。
*/
describe("指示書BG §2-3 — 予定外タスクを連続日数の入力に入れる", () => {
const NOW = new Date("2026-10-29T10:00:00+09:00");
const F1 = 1;
/** 予定の行とそのレポート(完了率 `rate`。`null` はレポートなし=未実施) */
function scheduled(id: number, jstIso: string, rate: number | null) {
return {
timetable: timetable(id, F1, jstIso),
reports: rate === null ? [] : [taskReport(id, rate, null, rate, 100, jstIso)],
};
}
function inputs(
runs: { timetable: ConsecutiveTimetableRow; reports: ConsecutiveTaskReportRow[] }[],
unscheduled: ConsecutiveUnscheduledReportRow[]
): ConsecutiveInput[] {
return consecutiveInputsFromRows(
runs.map((r) => r.timetable),
runs.flatMap((r) => r.reports),
NOW,
unscheduled
);
}
/** その施設のその日の連続日数(施設 × 日付で引く。予定外の行も同じ値) */
function daysOn(rows: readonly ConsecutiveInput[], date: string): number | undefined {
return consecutiveProblemDaysByFacilityDate(rows).get(facilityDateKey(F1, date));
}
it("U4-1 10-26 予定 ×、10-27 予定外だけ(50)、10-28 予定 △ → 3日連続 → L3", () => {
const rows = inputs(
[scheduled(1, "2026-10-26T21:00:00+09:00", 5), scheduled(3, "2026-10-28T21:00:00+09:00", 40)],
[unscheduledReport(F1, "2026-10-27T21:00:00+09:00", 50)]
);
expect(daysOn(rows, "2026-10-28")).toBe(3);
expect(judgeEscalationLevel(daysOn(rows, "2026-10-28") ?? 0)).toBe("L3");
// ★予定のある行は id の Map でも同じ値★(`computeConsecutiveProblemDays()`。予定外の行は id が無いので載らない)
const byId = computeConsecutiveProblemDays(rows);
expect(byId.get(3)).toBe(3);
expect([...byId.keys()].sort()).toEqual([1, 3]);
// ★予定外の行の日(10-27)も連続2日目★
expect(daysOn(rows, "2026-10-27")).toBe(2);
});
it("U4-2 10-27 に予定 ◎ と予定外 △ → その日は問題日(OR)", () => {
const rows = inputs(
[scheduled(2, "2026-10-27T21:00:00+09:00", 95)],
[unscheduledReport(F1, "2026-10-27T23:00:00+09:00", 40)]
);
expect(foldProblemDaysByDate(rows)).toEqual([{ date: "2026-10-27", isProblem: true }]);
expect(daysOn(rows, "2026-10-27")).toBe(1);
});
it("U4-3 10-27 予定外だけ(95)→ 問題日ではない → 連続が切れる", () => {
const rows = inputs(
[scheduled(1, "2026-10-26T21:00:00+09:00", 5), scheduled(3, "2026-10-28T21:00:00+09:00", 40)],
[unscheduledReport(F1, "2026-10-27T21:00:00+09:00", 95)]
);
expect(foldProblemDaysByDate(rows).map((d) => [d.date, d.isProblem])).toEqual([
["2026-10-26", true],
["2026-10-27", false],
["2026-10-28", true],
]);
expect(daysOn(rows, "2026-10-28")).toBe(1);
expect(judgeEscalationLevel(1)).toBe("L1");
});
it("U1-2 / U1-4 予定外の `clean_area` 0・`task_area` 0 → 完了率 0・出発済み(未実施ではない)", () => {
const rows = inputs(
[],
[
unscheduledReport(F1, "2026-10-27T21:00:00+09:00", 0, 100),
unscheduledReport(F1, "2026-10-28T21:00:00+09:00", 10, 0),
]
);
expect(rows.map((r) => [r.id, r.unscheduled, r.departed, r.completion_rate])).toEqual([
[null, true, true, 0],
[null, true, true, 0],
]);
});
it("U1-3 `clean_timetable_id` が 0 と NULL → どちらも予定外として同じ扱い", () => {
const zero = inputs([], [unscheduledReport(F1, "2026-10-28T21:00:00+09:00", 50, 100, 0)]);
const nul = inputs([], [unscheduledReport(F1, "2026-10-28T21:00:00+09:00", 50, 100, null)]);
expect(zero).toEqual(nul);
expect(zero[0]).toEqual({
id: null,
unscheduled: true,
facility_id: F1,
// ★日付は `started_at`(実際に始めた時刻)★
scheduled_at: "2026-10-28T21:00:00+09:00",
completion_rate: 50,
departed: true,
clean_area: 50,
task_area: 100,
});
});
it("予定の id を持つ行が予定外として渡されても入れない(SQL の写しが漏れても二重に数えない)", () => {
const leaked: ConsecutiveUnscheduledReportRow = {
...unscheduledReport(F1, "2026-10-28T21:00:00+09:00", 50),
clean_timetable_id: BigInt(7),
};
expect(inputs([], [leaked])).toEqual([]);
});
it("予定外の行は結果待ちにならない(始めたのが今の直前でも入る。予定外は結果が届いている)", () => {
const rows = inputs([], [unscheduledReport(F1, "2026-10-29T09:30:00+09:00", 80)]);
expect(rows).toHaveLength(1);
});
it("U1-5 loadConsecutiveInputs: 3本目で予定外を読む(`OR`(NULL・0)・削除済みの除外・期間は `started_at`・施設の絞り込み)", async () => {
const { prisma, calls } = stubPrisma(
[timetable(1, F1, "2026-10-28T21:00:00+09:00")],
[taskReport(1, 90)],
[],
[
unscheduledReport(F1, "2026-10-28T23:00:00+09:00", 40),
// ★他の施設(絞り込みで外れる)★
unscheduledReport(2, "2026-10-28T23:00:00+09:00", 40),
]
);
const rows = await loadConsecutiveInputs(prisma, NOW, {
date_from: "2026-10-28",
date_to: "2026-10-28",
facility_ids: [F1],
});
expect(calls.cleanTaskReportUnscheduled).toHaveLength(1);
const args = calls.cleanTaskReportUnscheduled[0] as {
where: ConsecutiveUnscheduledReportWhere;
select: Record<string, true>;
};
expect(args.where.OR).toEqual([{ clean_timetable_id: null }, { clean_timetable_id: BigInt(0) }]);
// ★削除済みの施設・清掃エリアは外す(表U1 U1-5)★
expect(args.where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
expect(args.where.facility_id).toEqual({ in: [BigInt(F1)] });
// ★期間は予定の行と同じ遡り範囲(`start_at` と同じ値)を `started_at` に当てる★
const timetableWhere = (calls.cleanTimetable[0] as { where: { start_at: unknown } }).where;
expect(args.where.started_at).toEqual(timetableWhere.start_at);
// ★列は2本目と同じ6列 + `facility_id`★(`include` は付けない)
expect(Object.keys(args).sort()).toEqual(["select", "where"]);
expect(Object.keys(args.select).sort()).toEqual(
["clean_area", "clean_ratio", "clean_timetable_id", "ended_at", "facility_id", "started_at", "task_area"].sort()
);
// ★施設 F1 の予定1件 + 予定外1件★(施設2 の予定外は絞り込みで外れる)
expect(rows.map((r) => [r.id, r.unscheduled, r.facility_id])).toEqual([
[1, false, F1],
[null, true, F1],
]);
});
it("loadConsecutiveInputs: 予定が0件でも予定外は読む(予定外だけの施設も連続日数を持つ)", async () => {
const { prisma, calls } = stubPrisma([], [], [], [unscheduledReport(F1, "2026-10-28T23:00:00+09:00", 40)]);
const rows = await loadConsecutiveInputs(prisma, NOW, CONSECUTIVE_RANGE_UNBOUNDED);
// ★2本目(予定の id で引く)は撃たない★
expect(calls.cleanTaskReport).toEqual([]);
expect(rows.map((r) => [r.id, r.unscheduled])).toEqual([[null, true]]);
});
});
/**
* ★指示書BG §2-3 — 要レスキュー★(`buildRescueRequired()` → `judgeRescue()`。判定は変えない)。
* - R-1(未実施3日連続): 予定外は未実施にならない
* - R-2(L3): 予定外だけの日も連続を作る(表U4 U4-1)
* - R-3(急落): 予定外もその日の完了率の平均に入る
*/
describe("指示書BG §2-3 — 要レスキューに予定外タスクを入れる", () => {
const NOW = new Date("2026-10-29T10:00:00+09:00");
const NAMES = [{ id: 1, facility_name: "F1" }];
function rescue(
timetables: ConsecutiveTimetableRow[],
reports: ConsecutiveTaskReportRow[],
unscheduled: ConsecutiveUnscheduledReportRow[]
) {
return buildRescueRequired(consecutiveInputsFromRows(timetables, reports, NOW, unscheduled), NAMES, "2026-10-29");
}
it("R-1: 未実施の日に予定外が走っても未実施の日のまま(予定外は未実施を打ち消さない)→ 3日連続で要レスキュー", () => {
const items = rescue(
[
timetable(1, 1, "2026-10-26T21:00:00+09:00"),
timetable(2, 1, "2026-10-27T21:00:00+09:00"),
timetable(3, 1, "2026-10-28T21:00:00+09:00"),
],
[],
[unscheduledReport(1, "2026-10-27T23:00:00+09:00", 95)]
);
expect(items).toHaveLength(1);
expect(items[0].reasons).toContain("not_executed_streak");
expect(items[0].not_executed_days).toBe(3);
});
it("R-1: 予定外だけの日は「未実施でない日」として未実施の連続を切る(予定外は未実施にならない)", () => {
const items = rescue(
[timetable(1, 1, "2026-10-26T21:00:00+09:00"), timetable(3, 1, "2026-10-28T21:00:00+09:00")],
[],
[unscheduledReport(1, "2026-10-27T21:00:00+09:00", 95)]
);
// ★10-26 と 10-28 の未実施は問題日だが、10-27(予定外 95)で連続が切れる → L1・未実施1日 → 要レスキューでない★
expect(items).toEqual([]);
});
it("R-2: 表U4 U4-1 の施設は L3 で要レスキュー(予定外だけの日が連続を作る)", () => {
const items = rescue(
[timetable(1, 1, "2026-10-26T21:00:00+09:00"), timetable(3, 1, "2026-10-28T21:00:00+09:00")],
[
taskReport(1, 5, null, 5, 100, "2026-10-26T21:00:00+09:00"),
taskReport(3, 40, null, 40, 100, "2026-10-28T21:00:00+09:00"),
],
[unscheduledReport(1, "2026-10-27T21:00:00+09:00", 50)]
);
expect(items).toHaveLength(1);
expect(items[0].consecutive_problem_days).toBe(3);
expect(items[0].reasons).toContain("escalation_l3");
});
it("R-3: 予定外の完了率もその日の平均に入る(10-27 予定 80 → 10-28 予定 80 + 予定外 20 = 平均 50 → 急落)", () => {
const items = rescue(
[timetable(2, 1, "2026-10-27T21:00:00+09:00"), timetable(3, 1, "2026-10-28T21:00:00+09:00")],
[
taskReport(2, 80, null, 80, 100, "2026-10-27T21:00:00+09:00"),
taskReport(3, 80, null, 80, 100, "2026-10-28T21:00:00+09:00"),
],
[unscheduledReport(1, "2026-10-28T23:00:00+09:00", 20)]
);
expect(items).toHaveLength(1);
expect(items[0].reasons).toEqual(["completion_drop"]);
expect(items[0].previous_completion_rate).toBe(80);
expect(items[0].completion_rate).toBe(50);
expect(items[0].evaluated_on).toBe("2026-10-28");
});
it("評価日の行が予定外だけでも連続日数を引ける(施設 × 日付で引く)", () => {
const items = rescue(
[timetable(1, 1, "2026-10-26T21:00:00+09:00"), timetable(2, 1, "2026-10-27T21:00:00+09:00")],
[
taskReport(1, 5, null, 5, 100, "2026-10-26T21:00:00+09:00"),
taskReport(2, 5, null, 5, 100, "2026-10-27T21:00:00+09:00"),
],
[unscheduledReport(1, "2026-10-28T21:00:00+09:00", 5)]
);
expect(items).toHaveLength(1);
expect(items[0].evaluated_on).toBe("2026-10-28");
expect(items[0].consecutive_problem_days).toBe(3);
});
});
/** ★指示書BG §2-3 — 連続問題施設(レポート)★: 期間内の最終エントリが予定外でも連続日数を引く */
describe("指示書BG §2-3 — 連続問題施設に予定外タスクを入れる", () => {
it("期間 10-28 の最終エントリ(予定外)から3日連続・L3", () => {
const now = new Date("2026-10-29T10:00:00+09:00");
const entries = consecutiveInputsFromRows(
[timetable(1, 1, "2026-10-26T21:00:00+09:00"), timetable(2, 1, "2026-10-27T21:00:00+09:00")],
[taskReport(1, 5, null, 5, 100, "2026-10-26T21:00:00+09:00")],
now,
[
unscheduledReport(1, "2026-10-27T23:00:00+09:00", 50),
unscheduledReport(1, "2026-10-28T21:00:00+09:00", 40),
]
);
const items = continuousProblemsInPeriod(
entries,
consecutiveProblemDaysByFacilityDate(entries),
new Map([[1, "F1"]]),
{ from: "2026-10-28", to: "2026-10-28" }
);
expect(items).toEqual([
expect.objectContaining({
facility_id: 1,
consecutive_problem_days: 3,
escalation_level: "L3",
last_problem_on: "2026-10-28",
// ★10-27 の予定は未実施(予定外は別の1回)→ 出発失敗の日は1日★
launch_failed_days: 1,
}),
]);
});
});