/**
* ダッシュボードの「直近の清掃」の選び方の期待値表(D-159 / 指示書AV §3)
*
* ★★表1 は監督確定(D-159 / 指示書AV §3)。変えないこと★★(`CLAUDE.md` §0-2・§6-2・§9.4)。
* ★表2・表3 は指示書AV で表1 から導いたもの★(指示書AV §11 Q10 で監督が確定)。
*
* 条件: 現在 = 2026-10-06 09:00 JST、遡り 7日(`DASHBOARD_LATEST_RUN`)。
*
* ★★D-173(ユーザー判断 2026-10-07)で「落ち着き 30分」をやめた★★(表2 C6。監督確定)—
* ★候補は「予定の終了 ≤ 現在」★。終わってからレポートが届くまでのあいだは、行の側の旗
* `result_pending`(`isResultPending()`。データソースが詰める)で「結果待ち」と出す。
* ★L7・L8 は境界の位置だけを新しい規則に合わせて書き換えた★(確かめること=「終わった予定だけ・境界は含む」は同じ)。
* 旧: L7 = 10/6 06:00〜08:30(終了+30分ちょうど)/ L8 = 10/6 06:00〜08:31(終了+30分が 09:01)。
*
* 表1(`pickLatestRunPerSection()`)
* | # | エリア | 予定(開始〜終了)と出発 | 選ばれる予定 | 確かめること |
* | L1 | A | 10/4 19:00〜21:10 出発 / 10/5 19:00〜21:10 出発 | 10/5 19:00 | 新しいほう |
* | L2 | B | 10/5 02:00〜04:10 出発 / 10/6 02:00〜04:10 出発 | 10/6 02:00 | 日付が変わった後の清掃も拾う |
* | L3 | C | 9/28 19:00〜21:10 だけ | なし(出さない) | 7日より前は出さない |
* | L4 | D | 9/29 09:00〜11:10 だけ | 9/29 09:00 | 境界: ちょうど7日前は含む |
* | L5 | E | 9/29 08:59〜11:09 だけ | なし | 境界: 7日前より1分古いと出さない |
* | L6 | F | 10/5 08:00〜10:10 出発 / 10/6 08:00〜10:10(終了前・結果待ち)| 10/5 08:00 | 終わっていない予定(清掃中)は採らない |
* | L7 | G | 10/6 06:00〜09:00 出発 | 10/6 06:00 | 境界: 終了ちょうど(09:00)は採る(D-173) |
* | L8 | H | 10/5 06:00〜08:00 出発 / 10/6 06:00〜09:01 出発 | 10/5 06:00 | 境界: 終了が09:01 だと採らない(D-173)|
* | L9 | I | 10/5 19:00〜21:10 出発しなかった(レポート行なし) | 10/5 19:00(未実施) | 出発失敗も含める |
* | L10 | J・K | J: 10/5 19:00〜21:10 / K: 10/5 22:00〜23:30(同じ店舗) | J・K それぞれ1件(計2件)| エリアごと |
* サマリ: 選ばれるのは A・B・D・F・G・H・I・J・K の9行(I 以外は出発している)。
*
* 表2(指示書AV で導いたもの)
* | # | 入力 | 期待 |
* | E1′ | 行の id に対応する `CleanLogRunMeta` が無い(★指示書BB A2 で書き換え。旧 E1: 例外★) | ★例外にしない・その行は選ばない★。`missingRunMetaIds()` がその id を返す(ログはルート) |
* | E1″ | 全行にメタが無い | `[]`(例外にしない) |
* | E2 | 同じ id の `CleanLogRunMeta` が2件 | 例外 |
* | E3 | 行が0件 | `[]` |
* | E4 | 表1 の入力を逆順に並べて渡す | 戻りは入力の順を保つ |
* | E5 | 同じエリアで開始が同じ2件(id 5 と 9) | id 9 |
* | E6 | `CleanLogRunMeta` はあるが行が無い id(未来の予定など) | 無視(例外にしない) |
*
* 表3(`latestRunWindow(now, lookbackDays)`。指示書AV で導いたもの。JST の YYYY-MM-DD・両端を含む)
* | # | now(JST) | lookbackDays | date_from | date_to |
* | W1 | 2026-10-06 09:00 | 7 | 2026-09-29 | 2026-10-06 |
* | W2 | 2026-10-06 00:00 | 7 | 2026-09-29 | 2026-10-06 |
* | W3 | 2026-10-05 23:59:59 | 7 | 2026-09-28 | 2026-10-05 |
* | W4 | 2026-09-30 09:00 | 7 | 2026-09-23 | 2026-09-30 |
*
* ★7 は直書きしない★(`CLAUDE.md` §7)。`DASHBOARD_LATEST_RUN` から読み、
* 表1 の前提と一致することだけを1件で確かめる。
*/
import { describe, expect, it } from "vitest";
import { DASHBOARD_LATEST_RUN } from "@/lib/config";
import { computeSummary } from "@/lib/dashboard/aggregate";
import {
latestRunWindow,
missingRunMetaIds,
pickLatestRunPerSection,
} from "@/lib/dashboard/latest-run";
import { judgeGrade } from "@/lib/judge/grade";
import type { CleanLogItem, CleanLogRunMeta } from "@/lib/types";
const NOW = new Date("2026-10-06T09:00:00+09:00");
const OPTIONS = { now: NOW, ...DASHBOARD_LATEST_RUN };
/** 表1 の1行(行とメタの両方を作るための入力) */
interface Run {
id: number;
section_key: string;
facility_id: number;
start: string;
end: string;
departed: boolean;
/** ★結果待ち★(D-173)。★データソースが詰める旗★なので、ここでは入力として渡す */
result_pending: boolean;
completion_rate: number | null;
}
function run(params: {
id: number;
section: string;
facility_id?: number;
start: string;
end: string;
departed?: boolean;
result_pending?: boolean;
completion_rate?: number | null;
}): Run {
const departed = params.departed ?? true;
// ★出発した行は結果待ちにならない★(レポートがある = `isResultPending()` は false)
const resultPending = departed ? false : (params.result_pending ?? false);
return {
id: params.id,
section_key: `section:${params.section}`,
facility_id: params.facility_id ?? params.id,
start: params.start,
end: params.end,
departed,
result_pending: resultPending,
completion_rate: departed ? (params.completion_rate ?? 95) : null,
};
}
/** 清掃ログの行。★grade は既存の判定(`judgeGrade()`)で作る★ */
function rowOf(r: Run): CleanLogItem {
return {
id: r.id,
row_key: `tt-${r.id}`,
report_id: null,
unscheduled: false,
facility_id: r.facility_id,
facility_name: `施設${r.facility_id}`,
model_name: "PUDU CC1",
serial_number: `SER-${r.id}`,
scheduled_at: r.start,
status_1h: "待機中",
status_1h_minutes: 60,
status_30m: "待機中",
status_30m_minutes: 30,
departed: r.departed,
task_status: r.departed ? 4 : null,
result_pending: r.result_pending,
completion_rate: r.completion_rate,
grade: judgeGrade(r.completion_rate),
consecutive_problem_days: 0,
escalation_level: null,
};
}
function metaOf(r: Run): CleanLogRunMeta {
// ★鍵は `row_key`★(指示書BG §2-2。旧: id)
return { row_key: `tt-${r.id}`, section_key: r.section_key, planned_end_at: r.end };
}
const T = (s: string) => `${s}+09:00`;
/** 表1 の入力(★選ばれない予定も全部入れる★。指示書AV §3「テストの書き方の注意」) */
const TABLE1: Array<{ no: string; runs: Run[]; expected: number[] }> = [
{
no: "L1 新しいほう",
runs: [
run({ id: 101, section: "A", start: T("2026-10-04T19:00:00"), end: T("2026-10-04T21:10:00"), completion_rate: 95 }),
run({ id: 102, section: "A", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00"), completion_rate: 95 }),
],
expected: [102],
},
{
no: "L2 日付が変わった後の清掃も拾う",
runs: [
run({ id: 201, section: "B", start: T("2026-10-05T02:00:00"), end: T("2026-10-05T04:10:00"), completion_rate: 80 }),
run({ id: 202, section: "B", start: T("2026-10-06T02:00:00"), end: T("2026-10-06T04:10:00"), completion_rate: 80 }),
],
expected: [202],
},
{
no: "L3 7日より前は出さない",
runs: [
run({ id: 301, section: "C", start: T("2026-09-28T19:00:00"), end: T("2026-09-28T21:10:00") }),
],
expected: [],
},
{
no: "L4 境界: ちょうど7日前は含む",
runs: [
run({ id: 401, section: "D", start: T("2026-09-29T09:00:00"), end: T("2026-09-29T11:10:00"), completion_rate: 50 }),
],
expected: [401],
},
{
no: "L5 境界: 7日前より1分古いと出さない",
runs: [
run({ id: 501, section: "E", start: T("2026-09-29T08:59:00"), end: T("2026-09-29T11:09:00") }),
],
expected: [],
},
{
no: "L6 終わっていない予定は採らない",
runs: [
run({ id: 601, section: "F", start: T("2026-10-05T08:00:00"), end: T("2026-10-05T10:10:00"), completion_rate: 5 }),
// ★開始は過ぎているが終わっていない★ので `cleanLogs()` は実際にこの行を返す(`departed: false`)。
// ★清掃中なので結果待ち★(D-173。`isResultPending()` 表1 P4 と同じ形)
run({ id: 602, section: "F", start: T("2026-10-06T08:00:00"), end: T("2026-10-06T10:10:00"), departed: false, result_pending: true }),
],
expected: [601],
},
{
no: "L7 境界: 終了ちょうど(09:00)は採る(D-173)",
runs: [
run({ id: 701, section: "G", start: T("2026-10-06T06:00:00"), end: T("2026-10-06T09:00:00"), completion_rate: 100 }),
],
expected: [701],
},
{
no: "L8 境界: 終了が09:01 だと採らない(D-173)",
runs: [
run({ id: 801, section: "H", start: T("2026-10-05T06:00:00"), end: T("2026-10-05T08:00:00"), completion_rate: 72 }),
run({ id: 802, section: "H", start: T("2026-10-06T06:00:00"), end: T("2026-10-06T09:01:00"), completion_rate: 72 }),
],
expected: [801],
},
{
no: "L9 出発失敗も含める",
runs: [
run({ id: 901, section: "I", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00"), departed: false }),
],
expected: [901],
},
{
no: "L10 エリアごと(同じ店舗の2エリア)",
runs: [
run({ id: 1001, section: "J", facility_id: 10, start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00"), completion_rate: 90 }),
run({ id: 1101, section: "K", facility_id: 10, start: T("2026-10-05T22:00:00"), end: T("2026-10-05T23:30:00"), completion_rate: 9.9 }),
],
expected: [1001, 1101],
},
];
const ALL_RUNS = TABLE1.flatMap((c) => c.runs);
/** データソースの並び(scheduled_at 降順 → id 昇順。`lib/db/index.ts` の `orderBy`)に揃える */
const ALL_ROWS = [...ALL_RUNS]
.sort((a, b) => Date.parse(b.start) - Date.parse(a.start) || a.id - b.id)
.map(rowOf);
const ALL_META = ALL_RUNS.map(metaOf);
const EXPECTED_ALL = TABLE1.flatMap((c) => c.expected);
/** ★表1・表2 の行は予定のある行だけ★(id は必ず数値)。予定外の行を含むテストは `keys()` で見る */
const ids = (rows: readonly CleanLogItem[]) => rows.map((r) => r.id as number);
const keys = (rows: readonly CleanLogItem[]) => rows.map((r) => r.row_key);
const idSet = (rows: readonly CleanLogItem[]) => [...ids(rows)].sort((a, b) => a - b);
describe("DASHBOARD_LATEST_RUN — 表1 の前提", () => {
it("遡り7日(表1 の条件と一致する)", () => {
expect(DASHBOARD_LATEST_RUN.lookbackDays).toBe(7);
});
it("★落ち着き時間(`settleMinutes`)は持たない★(D-173 で削除。表2 C6)", () => {
expect(DASHBOARD_LATEST_RUN).not.toHaveProperty("settleMinutes");
});
});
/**
* ★表2 C6(D-173。監督確定)★ — 候補は「予定の終了 ≤ 現在」。清掃中(開始 ≤ 現在 < 終了)は候補にしない。
* 例: 現在 03:10、02:00〜03:00 の清掃にレポートなし → それが直近として選ばれ、結果待ち。
*/
describe("pickLatestRunPerSection — 結果待ち(表2 C6。D-173)", () => {
const now = new Date("2026-10-07T03:10:00+09:00");
const opts = { now, ...DASHBOARD_LATEST_RUN };
it("C6 現在 03:10・02:00〜03:00 にレポートなし → 選ばれ、結果待ちとして数えられる", () => {
const prev = run({ id: 1, section: "P", start: T("2026-10-06T02:00:00"), end: T("2026-10-06T03:00:00"), completion_rate: 95 });
const latest = run({ id: 2, section: "P", start: T("2026-10-07T02:00:00"), end: T("2026-10-07T03:00:00"), departed: false, result_pending: true });
const picked = pickLatestRunPerSection([latest, prev].map(rowOf), [latest, prev].map(metaOf), opts);
expect(ids(picked)).toEqual([2]);
expect(picked[0].result_pending).toBe(true);
// ★結果待ちは未実施にも成功率の分母にも入らない★(C5)
expect(computeSummary(picked)).toEqual({ success: 0, failed: 0, not_executed: 0, pending: 1, success_rate: null });
});
it("C6 清掃中(02:30〜03:30。現在 03:10)は候補にしない → 前回の清掃が選ばれる", () => {
const prev = run({ id: 1, section: "P", start: T("2026-10-06T02:30:00"), end: T("2026-10-06T03:30:00"), completion_rate: 95 });
const cleaning = run({ id: 2, section: "P", start: T("2026-10-07T02:30:00"), end: T("2026-10-07T03:30:00"), departed: false, result_pending: true });
expect(ids(pickLatestRunPerSection([cleaning, prev].map(rowOf), [cleaning, prev].map(metaOf), opts))).toEqual([1]);
});
it("C6 境界: 終了ちょうど(03:10)は採る / 03:11 は採らない(30分待たない)", () => {
const atNow = run({ id: 3, section: "Q", start: T("2026-10-07T02:10:00"), end: T("2026-10-07T03:10:00"), departed: false, result_pending: true });
const after = run({ id: 4, section: "R", start: T("2026-10-07T02:11:00"), end: T("2026-10-07T03:11:00"), departed: false, result_pending: true });
expect(ids(pickLatestRunPerSection([atNow, after].map(rowOf), [atNow, after].map(metaOf), opts))).toEqual([3]);
});
});
describe("pickLatestRunPerSection — 表1", () => {
it.each(TABLE1)("$no", ({ runs, expected }) => {
const picked = pickLatestRunPerSection(
runs.map(rowOf),
runs.map(metaOf),
OPTIONS
);
expect(idSet(picked)).toEqual([...expected].sort((a, b) => a - b));
});
it("全部まとめて渡すと A・B・D・F・G・H・I・J・K の9行", () => {
const picked = pickLatestRunPerSection(ALL_ROWS, ALL_META, OPTIONS);
expect(picked).toHaveLength(9);
expect(idSet(picked)).toEqual([...EXPECTED_ALL].sort((a, b) => a - b));
// I 以外は出発している
expect(picked.filter((r) => !r.departed).map((r) => r.id)).toEqual([901]);
});
it("サマリは既存の computeSummary() に選ばれた行を渡したものと等しい", () => {
const picked = pickLatestRunPerSection(ALL_ROWS, ALL_META, OPTIONS);
const expectedRows = ALL_ROWS.filter((r) => EXPECTED_ALL.includes(r.id as number));
expect(computeSummary(picked)).toEqual(computeSummary(expectedRows));
// I(出発失敗)は未実施に数えられる
expect(computeSummary(picked).not_executed).toBe(1);
});
it("★行を書き換えない★(選ばれた行は入力と同じ中身)", () => {
const picked = pickLatestRunPerSection(ALL_ROWS, ALL_META, OPTIONS);
for (const row of picked) {
expect(row).toEqual(ALL_ROWS.find((r) => r.id === row.id));
}
});
});
describe("pickLatestRunPerSection — 表2", () => {
it("★E1′ 行の id に対応するメタが無い → 例外にせず、その行は選ばない。`missingRunMetaIds()` がその id を返す★(指示書BB A2)", () => {
const withMeta = run({ id: 41, section: "Y", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00") });
const noMeta = run({ id: 4242, section: "Z", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00") });
const rows = [rowOf(withMeta), rowOf(noMeta)];
const meta = [metaOf(withMeta)];
expect(pickLatestRunPerSection(rows, meta, OPTIONS).map((r) => r.id)).toEqual([41]);
expect(missingRunMetaIds(rows, meta)).toEqual(["tt-4242"]);
});
it("E1″ 全行にメタが無い → [](例外にしない)", () => {
const r = run({ id: 4242, section: "Z", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00") });
expect(pickLatestRunPerSection([rowOf(r)], [], OPTIONS)).toEqual([]);
expect(missingRunMetaIds([rowOf(r)], [])).toEqual(["tt-4242"]);
});
it("missingRunMetaIds: 全行にメタがあれば []・行の無いメタ(E6)は数えない", () => {
const r = run({ id: 41, section: "Y", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00") });
const future = run({ id: 99, section: "Y", start: T("2026-10-07T19:00:00"), end: T("2026-10-07T21:10:00") });
expect(missingRunMetaIds([rowOf(r)], [metaOf(r), metaOf(future)])).toEqual([]);
});
it("E2 同じ id のメタが2件 → 例外", () => {
const r = run({ id: 77, section: "Z", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00") });
expect(() =>
pickLatestRunPerSection([rowOf(r)], [metaOf(r), metaOf(r)], OPTIONS)
).toThrow(/77/);
});
it("E3 行が0件 → []", () => {
expect(pickLatestRunPerSection([], [], OPTIONS)).toEqual([]);
});
it("E4 表1 の入力を逆順に渡すと、戻りは入力の順を保つ", () => {
const reversed = [...ALL_ROWS].reverse();
const picked = pickLatestRunPerSection(reversed, ALL_META, OPTIONS);
expect(ids(picked)).toEqual(
ids(reversed).filter((id) => EXPECTED_ALL.includes(id))
);
// 正順でも入力の順のまま
const forward = pickLatestRunPerSection(ALL_ROWS, ALL_META, OPTIONS);
expect(ids(forward)).toEqual(
ids(ALL_ROWS).filter((id) => EXPECTED_ALL.includes(id))
);
});
it("E5 同じエリアで開始が同じ2件(id 5 と 9)→ id 9", () => {
const a = run({ id: 5, section: "S", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00") });
const b = run({ id: 9, section: "S", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00") });
expect(ids(pickLatestRunPerSection([rowOf(a), rowOf(b)], [metaOf(a), metaOf(b)], OPTIONS))).toEqual([9]);
// 入力の順に依らない
expect(ids(pickLatestRunPerSection([rowOf(b), rowOf(a)], [metaOf(b), metaOf(a)], OPTIONS))).toEqual([9]);
});
it("E6 メタはあるが行が無い id(未来の予定など)は無視する", () => {
const r = run({ id: 11, section: "S", start: T("2026-10-05T19:00:00"), end: T("2026-10-05T21:10:00") });
const future = run({ id: 12, section: "S", start: T("2026-10-07T19:00:00"), end: T("2026-10-07T21:10:00") });
expect(ids(pickLatestRunPerSection([rowOf(r)], [metaOf(r), metaOf(future)], OPTIONS))).toEqual([11]);
});
});
/**
* ★表U3(指示書BG §3。監督が書いた。数字を変えない)★ — 予定外タスクも直近の候補にする(指示書BG §2-2)。
* 前提: 今 = 2026-10-29 10:00 JST、清掃エリア S1。
* | # | 状況 | 期待 |
* | U3-1 | 予定 10-28 21:00(成功)より後に予定外 10-28 23:00(△)がある | 直近1件は予定外 → 失敗に数える |
* | U3-2 | 予定外の `ended_at` が今より後(清掃中) | 直近の候補にしない |
* (U3-3 は `aggregate.test.ts` の `computeScheduleProgress`)
*/
describe("pickLatestRunPerSection — 予定外タスク(指示書BG 表U3)", () => {
const now = new Date("2026-10-29T10:00:00+09:00");
const opts = { now, ...DASHBOARD_LATEST_RUN };
/** 予定外の行とそのメタ(開始 = `started_at`、終了 = `ended_at`。null なら開始) */
function unscheduled(params: {
reportId: string;
section: string;
start: string;
end: string | null;
completion_rate: number;
}): { row: CleanLogItem; meta: CleanLogRunMeta } {
const rowKey = `rp-${params.reportId}`;
return {
row: {
id: null,
row_key: rowKey,
report_id: params.reportId,
unscheduled: true,
facility_id: 1,
facility_name: "施設1",
model_name: "PUDU CC1",
serial_number: "SER-1",
scheduled_at: params.start,
status_1h: null,
status_1h_minutes: null,
status_30m: null,
status_30m_minutes: null,
departed: true,
task_status: 4,
result_pending: false,
completion_rate: params.completion_rate,
grade: judgeGrade(params.completion_rate),
consecutive_problem_days: 0,
escalation_level: null,
},
meta: {
row_key: rowKey,
section_key: `section:${params.section}`,
planned_end_at: params.end ?? params.start,
},
};
}
it("U3-1 予定 10-28 21:00(成功)より後に予定外 10-28 23:00(△)→ 直近は予定外・失敗に数える", () => {
const sched = run({ id: 1, section: "S1", start: T("2026-10-28T21:00:00"), end: T("2026-10-28T22:30:00"), completion_rate: 95 });
const u = unscheduled({
reportId: "9200000000000000001",
section: "S1",
start: T("2026-10-28T23:00:00"),
end: T("2026-10-29T00:30:00"),
completion_rate: 40,
});
const picked = pickLatestRunPerSection([u.row, rowOf(sched)], [metaOf(sched), u.meta], opts);
expect(keys(picked)).toEqual(["rp-9200000000000000001"]);
expect(computeSummary(picked)).toEqual({ success: 0, failed: 1, not_executed: 0, pending: 0, success_rate: 0 });
});
it("U3-2 予定外の `ended_at` が今より後(清掃中)→ 候補にしない(前の予定が選ばれる)", () => {
const sched = run({ id: 1, section: "S1", start: T("2026-10-28T21:00:00"), end: T("2026-10-28T22:30:00"), completion_rate: 95 });
const u = unscheduled({
reportId: "9200000000000000002",
section: "S1",
start: T("2026-10-29T09:00:00"),
end: T("2026-10-29T10:01:00"),
completion_rate: 40,
});
expect(ids(pickLatestRunPerSection([u.row, rowOf(sched)], [metaOf(sched), u.meta], opts))).toEqual([1]);
});
it("予定外の `ended_at` が null → 開始で代用(開始 ≤ 今なら候補)", () => {
const u = unscheduled({
reportId: "9200000000000000003",
section: "S2",
start: T("2026-10-29T09:59:00"),
end: null,
completion_rate: 95,
});
expect(keys(pickLatestRunPerSection([u.row], [u.meta], opts))).toEqual(["rp-9200000000000000003"]);
});
it("予定外も遡り7日より前は出さない(`started_at` で数える)", () => {
const u = unscheduled({
reportId: "9200000000000000004",
section: "S3",
start: T("2026-10-22T09:59:00"),
end: T("2026-10-22T11:00:00"),
completion_rate: 95,
});
expect(pickLatestRunPerSection([u.row], [u.meta], opts)).toEqual([]);
});
it("同じエリアで開始が同じ: 予定のある行を採る。予定外どうしは `report_id` の大きいほう", () => {
const sched = run({ id: 7, section: "S4", start: T("2026-10-28T21:00:00"), end: T("2026-10-28T22:00:00") });
const u1 = unscheduled({ reportId: "9200000000000000010", section: "S4", start: T("2026-10-28T21:00:00"), end: T("2026-10-28T22:00:00"), completion_rate: 95 });
const u2 = unscheduled({ reportId: "9200000000000000011", section: "S4", start: T("2026-10-28T21:00:00"), end: T("2026-10-28T22:00:00"), completion_rate: 95 });
expect(keys(pickLatestRunPerSection([u2.row, u1.row, rowOf(sched)], [u1.meta, u2.meta, metaOf(sched)], opts))).toEqual(["tt-7"]);
expect(keys(pickLatestRunPerSection([u1.row, u2.row], [u1.meta, u2.meta], opts))).toEqual(["rp-9200000000000000011"]);
expect(keys(pickLatestRunPerSection([u2.row, u1.row], [u2.meta, u1.meta], opts))).toEqual(["rp-9200000000000000011"]);
});
it("予定外の行にメタが無い → 選ばず、`missingRunMetaIds()` がその `row_key` を返す", () => {
const u = unscheduled({ reportId: "9200000000000000005", section: "S5", start: T("2026-10-28T21:00:00"), end: T("2026-10-28T22:00:00"), completion_rate: 95 });
expect(pickLatestRunPerSection([u.row], [], opts)).toEqual([]);
expect(missingRunMetaIds([u.row], [])).toEqual(["rp-9200000000000000005"]);
});
});
describe("latestRunWindow — 表3", () => {
it.each([
{ no: "W1", now: "2026-10-06T09:00:00+09:00", date_from: "2026-09-29", date_to: "2026-10-06" },
{ no: "W2", now: "2026-10-06T00:00:00+09:00", date_from: "2026-09-29", date_to: "2026-10-06" },
{ no: "W3", now: "2026-10-05T23:59:59+09:00", date_from: "2026-09-28", date_to: "2026-10-05" },
{ no: "W4", now: "2026-09-30T09:00:00+09:00", date_from: "2026-09-23", date_to: "2026-09-30" },
])("$no now=$now → $date_from 〜 $date_to", ({ now, date_from, date_to }) => {
expect(
latestRunWindow(new Date(now), DASHBOARD_LATEST_RUN.lookbackDays)
).toEqual({ date_from, date_to });
});
});