import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch } from "@/lib/api/client";
import {
ALL_OPTION_VALUE,
appendClassificationParams,
buildCustomerOptions,
buildGroupOptions,
hasGroupChoices,
isClassificationAvailable,
loadClassificationMasters,
normalizeSelection,
parseOptionValue,
UNAVAILABLE_MASTERS,
} from "@/lib/masters/options";
import type { Customer, Group, ListResponse } from "@/lib/types";
/**
* 分類(ホールディングス → 屋号)のカスケード絞り込み(指示書AK 段階7 の下準備)の期待値表。
*
* ------------------------------------------------------------------------
* ★★何を守るためのテストか★★
* ------------------------------------------------------------------------
* - ★マスタの取得に失敗したら絞り込みUIを出さないこと★(`isClassificationAvailable()` が false)。
* ★空の `<select>` は D-053 で消した「押しても何も起きないUI」への逆戻り★(落とし穴11)
* - ★ホールディングスを変えたら屋号の候補がその傘下だけになること★
* - ★傘下でない屋号の選択が残らないこと★ — 残ると AND 条件で必ず0件になり、
* ★「0件ヒット」か「絞り込みの壊れ」かを誰も判別できない★(落とし穴1・2 と同じ形)
* - ★`0` と `1abc` を ID として通さないこと★(D-011 のセンチネル / D-123 で踏んだ罠)
*/
vi.mock("@/lib/api/client", () => ({ apiFetch: vi.fn() }));
const apiFetchMock = vi.mocked(apiFetch);
const GROUPS: Group[] = [
{ id: 1, group_name: "ホールディングスA" },
{ id: 2, group_name: "ホールディングスB" },
];
const CUSTOMERS: Customer[] = [
{ id: 10, customer_name: "屋号A1", group_id: 1 },
{ id: 20, customer_name: "屋号A2", group_id: 1 },
{ id: 30, customer_name: "屋号B1", group_id: 2 },
{ id: 40, customer_name: "屋号なし所属", group_id: null },
];
function listOf<T>(items: T[]): ListResponse<T> {
return { items, meta: { total: items.length, page: 1, per_page: items.length } };
}
beforeEach(() => {
apiFetchMock.mockReset();
});
// ---------------------------------------------------------------- 取得
describe("loadClassificationMasters", () => {
it("2本とも成功したら ok: true で中身を返す", async () => {
apiFetchMock.mockImplementation(async (path: string) =>
path.startsWith("/api/groups") ? listOf(GROUPS) : listOf(CUSTOMERS)
);
const masters = await loadClassificationMasters();
expect(masters.ok).toBe(true);
expect(masters.groups).toEqual(GROUPS);
expect(masters.customers).toEqual(CUSTOMERS);
expect(isClassificationAvailable(masters)).toBe(true);
expect(hasGroupChoices(masters)).toBe(true);
});
it("★取得は2本だけ★(カスケードのために往復を増やさない)", async () => {
apiFetchMock.mockImplementation(async (path: string) =>
path.startsWith("/api/groups") ? listOf(GROUPS) : listOf(CUSTOMERS)
);
await loadClassificationMasters();
expect(apiFetchMock).toHaveBeenCalledTimes(2);
const paths = apiFetchMock.mock.calls.map((call) => call[0]);
expect(paths).toContain("/api/groups");
// ★`?group_id=` を付けて取り直さない★ — 手元で `filterCustomersByGroup()` する
expect(paths).toContain("/api/customers");
});
it("★どちらか1本でも失敗したら available: false 相当になる★(例外は投げない)", async () => {
apiFetchMock.mockImplementation(async (path: string) => {
if (path.startsWith("/api/groups")) return listOf(GROUPS);
throw new Error("boom");
});
const masters = await loadClassificationMasters();
expect(masters).toEqual(UNAVAILABLE_MASTERS);
expect(masters.ok).toBe(false);
expect(isClassificationAvailable(masters)).toBe(false);
expect(hasGroupChoices(masters)).toBe(false);
});
it("2本とも失敗しても例外を投げない", async () => {
apiFetchMock.mockRejectedValue(new Error("boom"));
await expect(loadClassificationMasters()).resolves.toEqual(
UNAVAILABLE_MASTERS
);
});
});
describe("isClassificationAvailable / hasGroupChoices", () => {
it("★屋号が0件なら出さない★(「全屋号」しか並ばないセレクトを作らない)", () => {
expect(
isClassificationAvailable({ ok: true, groups: GROUPS, customers: [] })
).toBe(false);
});
it("★ホールディングスが0件でも屋号があれば絞り込み自体は出す★", () => {
const masters = { ok: true, groups: [], customers: CUSTOMERS };
expect(isClassificationAvailable(masters)).toBe(true);
// ただしホールディングスのセレクトは出さない
expect(hasGroupChoices(masters)).toBe(false);
});
});
// ---------------------------------------------------------------- 選択肢
describe("buildGroupOptions", () => {
it("先頭が「全件」の行で、値は空文字", () => {
const options = buildGroupOptions(GROUPS, "全ホールディングス");
expect(options[0]).toEqual({
value: ALL_OPTION_VALUE,
label: "全ホールディングス",
});
expect(options[0].value).toBe("");
expect(options.slice(1)).toEqual([
{ value: "1", label: "ホールディングスA" },
{ value: "2", label: "ホールディングスB" },
]);
});
it("★並べ替えない★(/groups の id 昇順をそのまま使う)", () => {
const shuffled: Group[] = [
{ id: 2, group_name: "ホールディングスB" },
{ id: 1, group_name: "ホールディングスA" },
];
expect(buildGroupOptions(shuffled, "全").map((o) => o.value)).toEqual([
"",
"2",
"1",
]);
});
});
describe("buildCustomerOptions", () => {
it("ホールディングス未選択なら全屋号(所属なしも含む)", () => {
const options = buildCustomerOptions(CUSTOMERS, null, "全屋号");
expect(options.map((o) => o.value)).toEqual(["", "10", "20", "30", "40"]);
});
it("★ホールディングスを選ぶと傘下だけになる★", () => {
expect(
buildCustomerOptions(CUSTOMERS, 1, "全屋号").map((o) => o.label)
).toEqual(["全屋号", "屋号A1", "屋号A2"]);
expect(
buildCustomerOptions(CUSTOMERS, 2, "全屋号").map((o) => o.label)
).toEqual(["全屋号", "屋号B1"]);
});
it("★実在しないホールディングスなら「全屋号」だけ★(全件に化けない)", () => {
// ★落とし穴1・2 と同じ形★: 0件を「絞り込みなし」に倒すと全件表示に化ける
expect(buildCustomerOptions(CUSTOMERS, 999, "全屋号")).toEqual([
{ value: ALL_OPTION_VALUE, label: "全屋号" },
]);
});
it("★`group_id: null`(所属なし)は傘下に数えない★", () => {
expect(
buildCustomerOptions(CUSTOMERS, 1, "全屋号").map((o) => o.value)
).not.toContain("40");
});
});
// ---------------------------------------------------------------- 値の解釈
describe("parseOptionValue", () => {
const cases: Array<[string, number | null]> = [
["", null], // 「全件」の行
[" ", null],
["1", 1],
["42", 42],
["0", null], // ★ホールディングスなしのセンチネル。有効なIDではない(D-011)★
["1abc", null], // ★parseInt なら 1 として通る(D-123)★
["-1", null],
["1.5", null],
["1e9", null],
["all", null],
];
it.each(cases)("%s → %s", (input, expected) => {
expect(parseOptionValue(input)).toBe(expected);
});
});
// ---------------------------------------------------------------- カスケードの整合
describe("normalizeSelection", () => {
it("★ホールディングスを変えて傘下から外れた屋号は選択を外す★", () => {
expect(
normalizeSelection({ groupId: 2, customerId: 10 }, CUSTOMERS)
).toEqual({ groupId: 2, customerId: null });
});
it("傘下に残っている屋号はそのまま", () => {
const selection = { groupId: 1, customerId: 20 };
expect(normalizeSelection(selection, CUSTOMERS)).toEqual(selection);
});
it("ホールディングス未選択ならどの屋号でも残す", () => {
const selection = { groupId: null, customerId: 40 };
expect(normalizeSelection(selection, CUSTOMERS)).toEqual(selection);
});
it("屋号未選択なら何もしない", () => {
const selection = { groupId: 1, customerId: null };
expect(normalizeSelection(selection, CUSTOMERS)).toEqual(selection);
});
it("★マスタ未取得(屋号0件)のときは選択を消さない★", () => {
// 「傘下に居ない」と「まだ一覧を知らない」を取り違えない
const selection = { groupId: 1, customerId: 10 };
expect(normalizeSelection(selection, [])).toEqual(selection);
});
});
// ---------------------------------------------------------------- クエリ
describe("appendClassificationParams", () => {
it("両方指定したら両方載る(APIは AND で解釈する)", () => {
const params = appendClassificationParams(new URLSearchParams("date=2026-09-15"), {
groupId: 1,
customerId: 10,
});
expect(params.toString()).toBe("date=2026-09-15&group_id=1&customer_id=10");
});
it("★未指定のキーは付けない★", () => {
expect(
appendClassificationParams(new URLSearchParams(), {
groupId: null,
customerId: 10,
}).toString()
).toBe("customer_id=10");
expect(
appendClassificationParams(new URLSearchParams(), {
groupId: null,
customerId: null,
}).toString()
).toBe("");
});
it("★0 を漏らさない★(存在しない「グループ0」で絞り込ませない。落とし穴3)", () => {
expect(
appendClassificationParams(new URLSearchParams(), {
groupId: 0,
customerId: 0,
}).toString()
).toBe("");
});
});