Newer
Older
client_dwatch / lib / facilities / __tests__ / resolve-by-name.test.ts
import { describe, expect, it } from "vitest";

import { facilityIdsMatchingName } from "@/lib/facilities/resolve-by-name";
import { includesFoldName } from "@/lib/format/search-text";
import type { FacilityNameRef } from "@/lib/types";

/**
 * 指示書AC 段階2 ★D-6 / C-1★ — 施設名 → 施設ID の解決(★純粋関数の部分★)。
 *
 * ★★ここは「解決関数そのもの」のテストです★★。
 *   ★★これだけでは不十分★★ — ★ルートが `facility_ids` を渡し忘れても赤くなりません★。
 *   ★API ルートから `db` までを通した経路のテストは
 *   `app/api/clean-logs/__tests__/route.test.ts`(期待値表 #6d / #6e)★。
 *
 * ★照合規則は `lib/format/search-text.ts` の `includesFoldName` が正★(指示書AG D-1 / D-5)。
 * ★★このファイルに照合規則を書き下さないこと★★ — 規則が2箇所に散る。
 */

/**
 * ★実在の店名を使わないための架空名★(`CLAUDE.md` §7 / `docs/fixtures.md`)。
 * ★中点入りの店名★を再現するため、`ドン` ではなく `ドソ` にしてある
 * (記号ゆれの検証が目的で、実在名である必要は無い)。
 */
const REFS: readonly FacilityNameRef[] = [
  { id: 1, facility_name: "ドソ・キホーテ戸塚" },
  { id: 2, facility_name: "ドソ・キホーテ四街道" },
  { id: 3, facility_name: "メガドソ・キホーテ 港南" },
  { id: 4, facility_name: "フレッシュタウン大和店" },
  { id: 5, facility_name: "バリュー-マート北町店" },
];

describe("facilityIdsMatchingName(指示書AC 段階2 D-6)", () => {
  it("★★空文字は `null`(=施設で絞らない)★★。★`[]` を返さないこと★", () => {
    // ★`[]` は「0件ヒット」の意味★ — 空文字で `[]` を返すと、施設名を入力していないのに
    //   一覧が空になる(罠1 / `CleanLogDbQuery.facility_ids` の型コメント)
    expect(facilityIdsMatchingName(REFS, "")).toBeNull();
  });

  it("★★1件も当たらなければ `[]`(=0件ヒット)★★。★`null` を返さないこと★", () => {
    // ★`null` を返すと「条件なし」になり、★0件のはずが全件表示に化ける★
    expect(facilityIdsMatchingName(REFS, "存在しない店名")).toEqual([]);
  });

  it("★#6d 記号違いで当たる★(中点を無視する。指示書AG の本命)", () => {
    expect(facilityIdsMatchingName(REFS, "ドソキホーテ")).toEqual([1, 2, 3]);
  });

  it("★部分一致なので複数施設に当たりうる★(だから戻り値は配列)", () => {
    expect(facilityIdsMatchingName(REFS, "ドソキ")).toEqual([1, 2, 3]);
    expect(facilityIdsMatchingName(REFS, "戸塚")).toEqual([1]);
  });

  it("空白・ハイフン類も無視される(★規則は `includesFoldName` の写しではなく委譲★)", () => {
    expect(facilityIdsMatchingName(REFS, "ドソキホーテ港南")).toEqual([3]);
    expect(facilityIdsMatchingName(REFS, "バリューマート")).toEqual([5]);
    // ★委譲していることの確認★ — 同じ判定が `includesFoldName` 単体でも成り立つ
    expect(includesFoldName("バリュー-マート北町店", "バリューマート")).toBe(true);
  });

  it("★`contains` の罠(`%` をエスケープしない)が発生しない★", () => {
    // ★Prisma の `contains: "%"` は施設が全部ヒットする(指示書AC C-1 の実測)★。
    //   ★本案は施設名を SQL に渡さないので、JS の `.includes()` の挙動がそのまま出る★
    expect(facilityIdsMatchingName(REFS, "%")).toEqual([]);
  });

  // ★★2026-09-14 に反転させた★★(ユーザー判断: 記号のみ検索は弾く。案A)
  //   反転前の期待値は ★`[1, 2, 3, 4, 5]`(全施設 = 絞り込み無しと同じ)★ だった。
  //   `foldName("・") === ""` → `includes("")` が常に true、という `foldName` の帰結を
  //   そのまま通していたため。★検索欄に何か入力しているのに全件が出る★のは
  //   「絞り込めている」と誤認させるので、★`includesFoldName` 側で false に倒した★。
  it("★記号だけの入力は1件も当たらない★(`[]` = 0件ヒット。2026-09-14 に反転)", () => {
    // ★`null`(条件なし)ではなく `[]`(0件ヒット)★ —
    //   `null` を返すと `facility_ids` の条件自体が消えて、★0件が全件表示に化ける★(罠1)
    expect(facilityIdsMatchingName(REFS, "・")).toEqual([]);
    expect(facilityIdsMatchingName(REFS, "_")).toEqual([]);
    // ★空文字(未指定)は反転していない★ — 素通しのまま(`null`)
    expect(facilityIdsMatchingName(REFS, "")).toBeNull();
  });

  it("★#6e 論理削除された施設も解決対象に含まれる★(罠7)", () => {
    // ★解決用の読み取りが `deleted_at` で絞られていなければ、削除済みの施設も
    //   `refs` に入ってくる(`loadFacilityNameIndex()`)。ここはそれを前提に解決する★
    const withDeleted: FacilityNameRef[] = [
      ...REFS,
      { id: 99, facility_name: "ドソ・キホーテ旧店舗" },
    ];
    expect(facilityIdsMatchingName(withDeleted, "旧店舗")).toEqual([99]);
    expect(facilityIdsMatchingName(withDeleted, "ドソキホーテ")).toContain(99);
  });

  it("施設マスタが空でも例外にならない(`[]` を返す)", () => {
    expect(facilityIdsMatchingName([], "何か")).toEqual([]);
    expect(facilityIdsMatchingName([], "")).toBeNull();
  });

  it("★入力の配列を壊さない(純粋関数)★", () => {
    const before = [...REFS];
    facilityIdsMatchingName(REFS, "ドソキ");
    expect(REFS).toEqual(before);
  });
});