/**
* 予定を `clean_timetables` と `clean_timetable_histories` の両方から読む(指示書AU 段階3b-6。
* TeamWorks 第11回 P-7=C。D-158)。
*
* ★本書 3b-6 の表 T1〜T8 が正★(実装より先に書いた)。
*
* | # | 入力 | 期待 |
* |---|---|---|
* | T1 | 本表だけに id=1 | id=1 が1件 |
* | T2 | 履歴だけに id=2 | id=2 が1件(影チェックで本表に無い) |
* | T3 | 両方に id=3(同じ中身) | 1件。中身は本表のもの |
* | T4 | 両方に id=4(`start_at` が違う。どちらも期間内) | 1件・本表の `start_at` |
* | T5 | id=5: 本表は期間外、履歴は期間内 | 0件(影チェックで履歴を捨てる) |
* | T6 | 履歴だけの id が0件 | 影チェックのクエリを撃たない |
* | T7 | 2表に同じ `where` が渡る | 両方の呼び出し引数が `toEqual` で一致 |
* | T8 | `parent_id IN` の塊をまたいで同じ id | flat 後に1件 |
*
* ★DBには接続しない★(スタブの prisma。`where` の `in` / `gte` / `lt` / 等値と `select` だけを再現する)。
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import {
findTimetableById,
findTimetables,
findTimetablesByChunks,
mergeTimetableRows,
type TimetableSourcePrisma,
} from "@/lib/db/timetable-source";
import { logEvent } from "@/lib/logger";
vi.mock("@/lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logger")>();
return { ...actual, logEvent: vi.fn() };
});
// ---------------------------------------------------------------- スタブ(DBの代わり)
interface Row {
id: bigint;
facility_id: bigint;
section_id: bigint;
parent_id: bigint;
precheck: number;
start_at: Date;
}
type Table = "cleanTimetable" | "cleanTimetableHistory";
function matches(row: Row, where: Record<string, unknown> | undefined): boolean {
if (!where) return true;
for (const [field, cond] of Object.entries(where)) {
const value = row[field as keyof Row];
if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
const c = cond as { in?: unknown[]; gte?: Date; lt?: Date };
if ("in" in c) {
if (c.in === undefined) throw new Error("[stub] in: undefined(罠1)");
if (!c.in.some((x) => String(x) === String(value))) return false;
}
if (c.gte && (value as Date).getTime() < c.gte.getTime()) return false;
if (c.lt && (value as Date).getTime() >= c.lt.getTime()) return false;
} else if (String(value) !== String(cond)) {
return false;
}
}
return true;
}
function project(row: Row, select: Record<string, unknown> | undefined): Record<string, unknown> {
if (!select) return { ...row };
const out: Record<string, unknown> = {};
for (const key of Object.keys(select)) out[key] = row[key as keyof Row];
return out;
}
function stub(main: Row[], history: Row[]) {
const calls: { table: Table; method: "findMany" | "findUnique"; args: Record<string, unknown> }[] = [];
const delegate = (table: Table, rows: Row[]) => ({
async findMany(args: object) {
const a = args as { where?: Record<string, unknown>; select?: Record<string, unknown> };
calls.push({ table, method: "findMany", args: a });
return rows.filter((row) => matches(row, a.where)).map((row) => project(row, a.select));
},
async findUnique(args: object) {
const a = args as { where: { id: bigint }; select?: Record<string, unknown> };
calls.push({ table, method: "findUnique", args: a });
const row = rows.find((r) => r.id === a.where.id);
return row ? project(row, a.select) : null;
},
});
const prisma = {
cleanTimetable: delegate("cleanTimetable", main),
cleanTimetableHistory: delegate("cleanTimetableHistory", history),
} satisfies TimetableSourcePrisma;
return { prisma, calls };
}
/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115) */
function wall(jstIso: string): Date {
return new Date(jstIso.replace("+09:00", "Z"));
}
function row(id: number, startAtJst: string, overrides: Partial<Row> = {}): Row {
return {
id: BigInt(id),
facility_id: BigInt(1),
section_id: BigInt(10),
parent_id: BigInt(0),
precheck: CLEAN_TIMETABLE_PRECHECK.main,
start_at: wall(startAtJst),
...overrides,
};
}
const IN_RANGE = "2026-10-03T02:00:00+09:00";
const IN_RANGE_2 = "2026-10-04T02:00:00+09:00";
const OUT_OF_RANGE = "2026-09-20T02:00:00+09:00";
/** 期間で絞る本清掃の引数(`reportKpis()` 当期と同じ形) */
function rangeArgs() {
return {
where: {
precheck: CLEAN_TIMETABLE_PRECHECK.main,
start_at: { gte: wall("2026-10-01T00:00:00+09:00"), lt: wall("2026-10-08T00:00:00+09:00") },
},
select: { id: true, facility_id: true, section_id: true, start_at: true },
} as const;
}
function ids(rows: readonly { id: bigint }[]): string[] {
return rows.map((r) => r.id.toString()).sort();
}
beforeEach(() => {
vi.mocked(logEvent).mockClear();
});
// ---------------------------------------------------------------- テスト
describe("findTimetables(指示書AU 段階3b-6 T1〜T7)", () => {
it("T1 本表だけに id=1 → 1件", async () => {
const { prisma } = stub([row(1, IN_RANGE)], []);
const rows = await findTimetables(prisma, rangeArgs());
expect(ids(rows)).toEqual(["1"]);
});
it("T2 履歴だけに id=2 → 1件(影チェックで本表に無い)", async () => {
const { prisma, calls } = stub([], [row(2, IN_RANGE)]);
const rows = await findTimetables(prisma, rangeArgs());
expect(ids(rows)).toEqual(["2"]);
// ★影チェック(履歴だけに出た id を本表で引く)が1本撃たれている★
const shadow = calls.filter((c) => c.table === "cleanTimetable" && "id" in (c.args.where as object));
expect(shadow).toHaveLength(1);
expect((shadow[0].args.where as { id: { in: bigint[] } }).id.in).toEqual([BigInt(2)]);
});
it("T3 両方に id=3(同じ中身)→ ★1件★。中身は本表のもの・warn は出ない", async () => {
const main = row(3, IN_RANGE);
const { prisma } = stub([main], [row(3, IN_RANGE)]);
const rows = await findTimetables(prisma, rangeArgs());
expect(rows).toHaveLength(1);
expect(rows[0]).toEqual(project(main, rangeArgs().select));
expect(vi.mocked(logEvent)).not.toHaveBeenCalled();
});
it("T4 両方に id=4(`start_at` が違う。どちらも期間内)→ ★1件・本表の `start_at`★。違いを warn に出す(Q2)", async () => {
const { prisma } = stub([row(4, IN_RANGE)], [row(4, IN_RANGE_2)]);
const rows = await findTimetables(prisma, rangeArgs());
expect(rows).toHaveLength(1);
expect(rows[0].start_at).toEqual(wall(IN_RANGE));
expect(vi.mocked(logEvent)).toHaveBeenCalledWith("warn", {
event: "db.timetable_conflict",
timetable_id: "4",
adopted_table: "clean_timetables",
columns: ["start_at"],
main_start_at: IN_RANGE,
history_start_at: IN_RANGE_2,
});
});
it("T5 id=5: 本表は期間外・履歴は期間内 → ★0件★(影チェックで履歴を捨てる)。違いを warn に出す(Q2)", async () => {
const { prisma } = stub([row(5, OUT_OF_RANGE)], [row(5, IN_RANGE)]);
const rows = await findTimetables(prisma, rangeArgs());
expect(rows).toEqual([]);
expect(vi.mocked(logEvent)).toHaveBeenCalledTimes(1);
expect(vi.mocked(logEvent).mock.calls[0][1]).toMatchObject({
event: "db.timetable_conflict",
timetable_id: "5",
columns: ["start_at"],
main_start_at: OUT_OF_RANGE,
history_start_at: IN_RANGE,
});
});
it("T5' 本表は本清掃でない(precheck が違う)・履歴は本清掃 → 0件(本表が勝つ)", async () => {
const { prisma } = stub([row(6, IN_RANGE, { precheck: 60, parent_id: BigInt(1) })], [row(6, IN_RANGE)]);
const rows = await findTimetables(prisma, rangeArgs());
expect(rows).toEqual([]);
});
it("T6 履歴だけの id が0件 → 影チェックのクエリを撃たない", async () => {
const { prisma, calls } = stub([row(1, IN_RANGE), row(3, IN_RANGE)], [row(3, IN_RANGE)]);
await findTimetables(prisma, rangeArgs());
// ★本表・履歴とも1本ずつだけ★
expect(calls.map((c) => c.table)).toEqual(["cleanTimetable", "cleanTimetableHistory"]);
});
it("T7 2表に同じ `where`(引数)が渡る", async () => {
const { prisma, calls } = stub([row(1, IN_RANGE)], [row(2, IN_RANGE)]);
await findTimetables(prisma, rangeArgs());
const mainCall = calls.find((c) => c.table === "cleanTimetable");
const historyCall = calls.find((c) => c.table === "cleanTimetableHistory");
expect(mainCall?.args).toEqual(rangeArgs());
expect(historyCall?.args).toEqual(mainCall?.args);
});
it("本表どうし・履歴どうしの並びは保証しないが、本表の行と履歴だけの行がすべて出る", async () => {
const { prisma } = stub([row(1, IN_RANGE), row(3, IN_RANGE)], [row(2, IN_RANGE), row(3, IN_RANGE)]);
const rows = await findTimetables(prisma, rangeArgs());
expect(ids(rows)).toEqual(["1", "2", "3"]);
});
it("`include` の行(全列)も合わせられる(`facility_id` / `section_id` の違いも warn に出す)", async () => {
const { prisma } = stub(
[row(7, IN_RANGE)],
[row(7, IN_RANGE, { facility_id: BigInt(2), section_id: BigInt(20) })]
);
const rows = await findTimetables(prisma, {
where: { precheck: CLEAN_TIMETABLE_PRECHECK.main },
});
expect(rows).toHaveLength(1);
expect(rows[0].facility_id).toBe(BigInt(1));
expect(vi.mocked(logEvent).mock.calls[0][1]).toEqual({
event: "db.timetable_conflict",
timetable_id: "7",
adopted_table: "clean_timetables",
columns: ["facility_id", "section_id"],
main_facility_id: "1",
history_facility_id: "2",
main_section_id: "10",
history_section_id: "20",
});
});
});
describe("findTimetablesByChunks(指示書AU 段階3b-3 / T8)", () => {
it("T8 `parent_id IN` の塊をまたいで同じ id → flat 後に1件(本表の行)", async () => {
// ★id=8 は本表では親 101、履歴では親 102 → 別の塊に入る★
const { prisma, calls } = stub(
[row(8, IN_RANGE, { precheck: 60, parent_id: BigInt(101) })],
[row(8, IN_RANGE, { precheck: 60, parent_id: BigInt(102) }), row(9, IN_RANGE, { precheck: 30, parent_id: BigInt(102) })]
);
const rows = await findTimetablesByChunks(prisma, [[BigInt(101)], [BigInt(102)]], (chunk) => ({
where: { parent_id: { in: chunk } },
select: { id: true, parent_id: true, precheck: true },
}));
expect(ids(rows)).toEqual(["8", "9"]);
expect(rows.find((r) => r.id === BigInt(8))?.parent_id).toBe(BigInt(101));
// ★塊ごとに、2表へ同じ引数★
for (const chunk of [[BigInt(101)], [BigInt(102)]]) {
const args = { where: { parent_id: { in: chunk } }, select: { id: true, parent_id: true, precheck: true } };
expect(calls.filter((c) => c.table === "cleanTimetable" && c.method === "findMany").map((c) => c.args)).toContainEqual(args);
expect(calls.filter((c) => c.table === "cleanTimetableHistory").map((c) => c.args)).toContainEqual(args);
}
});
it("塊が0個ならクエリを1本も撃たない", async () => {
const { prisma, calls } = stub([row(1, IN_RANGE)], [row(2, IN_RANGE)]);
const rows = await findTimetablesByChunks(prisma, [], (chunk: bigint[]) => ({
where: { id: { in: chunk } },
select: { id: true },
}));
expect(rows).toEqual([]);
expect(calls).toEqual([]);
});
});
describe("mergeTimetableRows(純粋関数)", () => {
it("本表を全部採り、履歴は本表にも影にも無い id だけ足す", () => {
const merged = mergeTimetableRows(
[{ id: BigInt(1), v: "main" }],
[{ id: BigInt(1), v: "history" }, { id: BigInt(2), v: "history" }, { id: BigInt(3), v: "history" }],
new Set(["3"])
);
expect(merged).toEqual([{ id: BigInt(1), v: "main" }, { id: BigInt(2), v: "history" }]);
});
it("★履歴の中で同じ id が2回出たら例外★(呼び出しの誤り)", () => {
expect(() =>
mergeTimetableRows([], [{ id: BigInt(1) }, { id: BigInt(1) }], new Set())
).toThrow(/clean_timetable_histories/);
});
});
describe("findTimetableById(`precheckItemById()` 用。3b-2 の 5)", () => {
const select = { id: true, parent_id: true, precheck: true } as const;
it("本表にあれば本表の行(履歴は引かない)", async () => {
const { prisma, calls } = stub([row(1, IN_RANGE)], [row(1, IN_RANGE, { precheck: 60 })]);
const found = await findTimetableById(prisma, BigInt(1), select);
expect(found).toEqual({ id: BigInt(1), parent_id: BigInt(0), precheck: CLEAN_TIMETABLE_PRECHECK.main });
expect(calls.map((c) => c.table)).toEqual(["cleanTimetable"]);
});
it("本表に無ければ履歴の行", async () => {
const { prisma, calls } = stub([], [row(2, IN_RANGE, { precheck: 60, parent_id: BigInt(1) })]);
const found = await findTimetableById(prisma, BigInt(2), select);
expect(found).toEqual({ id: BigInt(2), parent_id: BigInt(1), precheck: 60 });
// ★両方に同じ引数★
expect(calls.map((c) => c.args)).toEqual([
{ where: { id: BigInt(2) }, select },
{ where: { id: BigInt(2) }, select },
]);
});
it("どちらにも無ければ null", async () => {
const { prisma } = stub([], []);
expect(await findTimetableById(prisma, BigInt(3), select)).toBeNull();
});
});