Newer
Older
client_dwatch / lib / dashboard / __tests__ / latest-run.test.ts
/**
 * ダッシュボードの「直近の清掃」の選び方の期待値表(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 });
  });
});