Newer
Older
client_dwatch / lib / format / __tests__ / search-text.test.ts
/**
 * 検索用の文字列正規化(`lib/format/search-text.ts`)のテスト。指示書AG 段階2。
 *
 * ------------------------------------------------------------------------
 * ★期待値表(実装より先に確定させたもの。CLAUDE.md §6-2)★
 * ------------------------------------------------------------------------
 * | #  | 入力(needle)      | 対象(haystack)        | includesFoldName | includesFoldPlain |
 * |----|---------------------|-------------------------|------------------|-------------------|
 * | 1  | ドンキホーテ        | ドン・キホーテ戸塚      | ★true★          | false             |
 * | 2  | ドン・キホーテ      | ドン・キホーテ戸塚      | true             | true              |
 * | 3  | 戸塚                | ドン・キホーテ戸塚      | true             | true              |
 * | 4  | ドンキ              | ドン・キホーテ戸塚      | ★true★          | false             |
 * | 5  | ドンキホーテ港南    | MEGAドン・キホーテ 港南 | ★true★          | false             |
 * | 6  | cc1                 | PUDU CC1                | true             | ★true★           |
 * | 7  | PUDUCC1             | PUDU CC1                | ★true★          | ★false★          |
 * | 8  | 8111r               | 8111R5628060011         | true             | ★true★           |
 * | 9  | ★23★               | ★東京都○○区1-2-3★     | ★true(だから住所には使わない)★ | ★false★ |
 * | 10 | ★10001★            | ★PUDU-CC1-0001★        | ★true(だからシリアルには使わない)★ | ★false★ |
 * | 11 | 空文字              | 何でも                  | ★true(素通し)★ | ★true★           |
 * | 12 | ドン                 | ドン・キホーテ戸塚      | ★false(D-4)★  | false             |
 * | 13 | どん                | ドン・キホーテ戸塚      | ★false(D-4)★  | false             |
 * | 14 | ★メガドンキホーテ★ | ★MEGAドン・キホーテ 港南★ | ★false(D-4。残る穴)★ | false     |
 * | 15 | ★`・`(記号だけ)★ | ドン・キホーテ戸塚      | ★★false(2026-09-14 に反転)★★ | ★true(記号を残すので当たる)★ |
 * | 16 | ★`-`(記号だけ)★  | ★PUDU-CC1-0001★        | ★false★         | ★★true(シリアル検索は生きる)★★ |
 * | 17 | ★ドン・(記号入り)★| ドン・キホーテ戸塚     | ★true(記号だけではない)★ | true      |
 * ------------------------------------------------------------------------
 *
 * ★#9 と #10 の行を消さないこと★ — 「だから住所とシリアルには `foldName` を使わない」
 *   という D-2 の根拠そのもの。消すと後任が「揃っていないから揃えよう」と
 *   `foldName` に寄せてしまい、番地とシリアルの桁境界が静かに壊れる。
 *
 * ★#12〜#14 は「実装していないこと」を固定するテスト★(D-4)。
 *   ユーザーがカナ・全半角統一と英字カナ読み替えを選ばなかった、という決定の記録である。
 *   これらを true にしたくなったら、勝手に足さずに仕様確認からやり直すこと。
 *
 * ★★#15〜#17 は 2026-09-14 に反転させた行★★(ユーザー判断: 記号のみ検索は弾く。案A)。
 *   ★反転前は #15 / #16 の `includesFoldName` が true(=記号だけ打つと全件)だった★。
 *   ★#11(未指定)は反転していない★ — 空文字は「絞り込まない」という指示なので素通し。
 *   ★`includesFoldPlain` の列は1行も変えていない★ — #16 の true が消えると
 *   ★`-` でシリアルを探せなくなる★。
 */
import { describe, expect, it } from "vitest";
import {
  foldName,
  foldPlain,
  includesFoldName,
  includesFoldPlain,
} from "@/lib/format/search-text";

describe("foldPlain", () => {
  it("大文字小文字だけを揃える", () => {
    expect(foldPlain("PUDU CC1")).toBe("pudu cc1");
    expect(foldPlain("8111R5628060011")).toBe("8111r5628060011");
  });

  it("★記号は残す★(住所の番地・シリアルの桁境界を壊さないため)", () => {
    expect(foldPlain("東京都○○区1-2-3")).toBe("東京都○○区1-2-3");
    expect(foldPlain("PUDU-CC1-0001")).toBe("pudu-cc1-0001");
    expect(foldPlain("ドン・キホーテ戸塚")).toBe("ドン・キホーテ戸塚");
  });
});

describe("foldName", () => {
  it("大文字小文字を揃え、記号を除く", () => {
    expect(foldName("MEGAドン・キホーテ 港南")).toBe("megaドンキホテ港南");
  });

  it("記号だけの入力は空文字になる(=絞り込み無しと同じ扱いになる)", () => {
    expect(foldName("・")).toBe("");
    expect(foldName("  ")).toBe("");
  });

  // ★D-3 の除去対象を1文字ずつ固定する★
  //   まとめて1件のテストにすると、1文字だけ抜けても他の文字で緑になってしまう。
  describe("除く記号(D-3)を1文字ずつ", () => {
    const cases: ReadonlyArray<[string, string]> = [
      ["中点 U+30FB", "あ・い"],
      ["中点 U+FF65(半角)", "あ・い"],
      ["中点 U+00B7", "あ·い"],
      ["中点 U+2022", "あ•い"],
      ["長音 U+30FC", "あーい"],
      ["半角スペース U+0020", "あ い"],
      ["全角スペース U+3000", "あ い"],
      ["タブ U+0009", "あ\tい"],
      ["ハイフン U+002D", "あ-い"],
      ["ハイフン U+2010", "あ‐い"],
      ["ハイフン U+2011", "あ‑い"],
      ["ハイフン U+2012", "あ‒い"],
      ["ハイフン U+2013", "あ–い"],
      ["ハイフン U+2014", "あ—い"],
      ["ハイフン U+2015", "あ―い"],
      ["全角ハイフン U+FF0D", "あ-い"],
      ["アンダースコア U+005F", "あ_い"],
    ];

    for (const [label, input] of cases) {
      it(`${label} を除く`, () => {
        expect(foldName(input)).toBe("あい");
      });
    }
  });

  // ★除かない文字★ — 集合を広げすぎていないことを固定する
  it("スラッシュ・括弧・読点は除かない(D-3 の4種類以外は除かない)", () => {
    expect(foldName("あ/い")).toBe("あ/い");
    expect(foldName("あ(い)")).toBe("あ(い)");
    expect(foldName("あ、い")).toBe("あ、い");
    expect(foldName("あ.い")).toBe("あ.い");
  });

  it("★改行は除かない★(D-3 の空白3種は半角・全角スペースとタブのみ)", () => {
    expect(foldName("あ\nい")).toBe("あ\nい");
  });
});

describe("includesFoldName / includesFoldPlain の期待値表", () => {
  const table: ReadonlyArray<{
    no: number;
    needle: string;
    haystack: string;
    name: boolean;
    plain: boolean;
  }> = [
    { no: 1, needle: "ドンキホーテ", haystack: "ドン・キホーテ戸塚", name: true, plain: false },
    { no: 2, needle: "ドン・キホーテ", haystack: "ドン・キホーテ戸塚", name: true, plain: true },
    { no: 3, needle: "戸塚", haystack: "ドン・キホーテ戸塚", name: true, plain: true },
    { no: 4, needle: "ドンキ", haystack: "ドン・キホーテ戸塚", name: true, plain: false },
    { no: 5, needle: "ドンキホーテ港南", haystack: "MEGAドン・キホーテ 港南", name: true, plain: false },
    { no: 6, needle: "cc1", haystack: "PUDU CC1", name: true, plain: true },
    { no: 7, needle: "PUDUCC1", haystack: "PUDU CC1", name: true, plain: false },
    { no: 8, needle: "8111r", haystack: "8111R5628060011", name: true, plain: true },
    // ★9・10 は「だから住所とシリアルに foldName を使わない」の根拠(D-2)★
    { no: 9, needle: "23", haystack: "東京都○○区1-2-3", name: true, plain: false },
    { no: 10, needle: "10001", haystack: "PUDU-CC1-0001", name: true, plain: false },
    { no: 11, needle: "", haystack: "ドン・キホーテ戸塚", name: true, plain: true },
    // ★12〜14 は D-4(実装しないと決めたこと)の記録★
    { no: 12, needle: "ドン", haystack: "ドン・キホーテ戸塚", name: false, plain: false },
    { no: 13, needle: "どん", haystack: "ドン・キホーテ戸塚", name: false, plain: false },
    { no: 14, needle: "メガドンキホーテ", haystack: "MEGAドン・キホーテ 港南", name: false, plain: false },
    // ★15〜17 は 2026-09-14 に反転させた行(記号だけの入力を弾く。案A)★
    //   15・16 の name は反転前 true(=記号だけ打つと全件)だった
    { no: 15, needle: "・", haystack: "ドン・キホーテ戸塚", name: false, plain: true },
    { no: 16, needle: "-", haystack: "PUDU-CC1-0001", name: false, plain: true },
    { no: 17, needle: "ドン・", haystack: "ドン・キホーテ戸塚", name: true, plain: true },
  ];

  for (const { no, needle, haystack, name, plain } of table) {
    it(`#${no} "${needle}" in "${haystack}" → foldName:${name} / foldPlain:${plain}`, () => {
      expect(includesFoldName(haystack, needle)).toBe(name);
      expect(includesFoldPlain(haystack, needle)).toBe(plain);
    });
  }
});

describe("includesFoldName / includesFoldPlain の性質", () => {
  // ★D-5★ 片側だけ正規化する間違いを封じるための関数なので、両側に効くことを固定する
  it("データ側に記号があっても、入力側に記号があっても引ける(両側に掛かる)", () => {
    // データ側に中点・入力側に無し
    expect(includesFoldName("ドン・キホーテ戸塚", "ドンキホーテ")).toBe(true);
    // 入力側に中点・データ側に無し
    expect(includesFoldName("ドンキホーテ戸塚", "ドン・キホーテ")).toBe(true);
    // 両側に記号(位置違い)
    expect(includesFoldName("ドン・キホーテ 戸塚", "キホーテ・戸塚")).toBe(true);
  });

  it("該当しない語は両方 false", () => {
    expect(includesFoldName("ドン・キホーテ戸塚", "ZZZ-NOMATCH")).toBe(false);
    expect(includesFoldPlain("8111R5628060011", "ZZZ-NOMATCH")).toBe(false);
  });

  it("空の haystack でも落ちない", () => {
    expect(includesFoldName("", "")).toBe(true);
    expect(includesFoldName("", "ドン")).toBe(false);
    expect(includesFoldPlain("", "ドン")).toBe(false);
  });
});

/**
 * ★記号だけの入力は「当たらない」★(★ユーザー判断 2026-09-14。案A★)
 *
 * ★★2026-09-14 に反転させた挙動★★ — 反転前は `foldName("・") === ""` の帰結で
 *   `includes("")` が常に true になり、★記号だけ打つと全件が出ていた★。
 *   ★検索したのに全件が出る★のは 0件と違って画面上は成功に見えるため、
 *   打ち間違い(キーボード切替ミスで `-` だけ残る等)に気づけない。
 *
 * ★境界は「未指定(空文字)」と「記号だけ」の1点だけ★。
 *   ★空文字は反転していない★(素通しのまま)。ここを巻き込むと
 *   ★施設名を入力していないのに一覧が空になる★。
 */
describe("★記号だけの入力★(2026-09-14 に反転。案A)", () => {
  const HAYSTACK = "MEGAドン・キホーテ 港南";

  it("★未指定(空文字)は素通し★(=ここは反転していない)", () => {
    expect(includesFoldName(HAYSTACK, "")).toBe(true);
    expect(includesFoldName("", "")).toBe(true);
  });

  // ★D-3 の17文字を1文字ずつ★ — まとめて1件にすると、1文字だけ漏れても緑になる
  describe("D-3 の除去対象17文字を1文字ずつ → false", () => {
    const cases: ReadonlyArray<[string, string]> = [
      ["中点 U+30FB", "・"],
      ["中点 U+FF65(半角)", "・"],
      ["中点 U+00B7", "·"],
      ["中点 U+2022", "•"],
      ["長音 U+30FC", "ー"],
      ["半角スペース U+0020", " "],
      ["全角スペース U+3000", " "],
      ["タブ U+0009", "\t"],
      ["ハイフン U+002D", "-"],
      ["ハイフン U+2010", "‐"],
      ["ハイフン U+2011", "‑"],
      ["ハイフン U+2012", "‒"],
      ["ハイフン U+2013", "–"],
      ["ハイフン U+2014", "—"],
      ["ハイフン U+2015", "―"],
      ["全角ハイフン U+FF0D", "-"],
      ["アンダースコア U+005F", "_"],
    ];

    for (const [label, needle] of cases) {
      it(`${label} だけの入力は当たらない`, () => {
        // ★needle は空文字ではない★(空文字と同じ道に落ちていないことの確認)
        expect(needle).not.toBe("");
        expect(foldName(needle)).toBe("");
        expect(includesFoldName(HAYSTACK, needle)).toBe(false);
      });
    }
  });

  it("★記号の組み合わせ★も当たらない", () => {
    for (const needle of ["・ー", "--", "・ -", "__", "ー・ー", "  \t", "-・-"]) {
      expect(includesFoldName(HAYSTACK, needle)).toBe(false);
    }
  });

  it("★記号を含むが記号だけではない入力は従来どおり当たる★", () => {
    expect(includesFoldName("ドン・キホーテ戸塚", "ドン・")).toBe(true);
    expect(includesFoldName("ドン・キホーテ戸塚", "・キホーテ")).toBe(true);
    expect(includesFoldName("ドン・キホーテ戸塚", "ドン キホーテ")).toBe(true);
    expect(includesFoldName(HAYSTACK, "ドンキホーテ 港南")).toBe(true);
    // 記号を含んだうえで一致しない語は、これまでどおり false
    expect(includesFoldName(HAYSTACK, "-ZZZ-")).toBe(false);
  });

  it("★haystack が記号だけでも落ちない★", () => {
    expect(includesFoldName("・", "・")).toBe(false);
    expect(includesFoldName("・", "")).toBe(true);
    expect(includesFoldName("・", "ドン")).toBe(false);
  });

  /**
   * ★★`includesFoldPlain` は変えていない★★(★揃えないのが正しい★)
   *   `foldPlain` は記号を残すので `-` はそのまま照合され、
   *   ★シリアル `PUDU-CC1-0001` に当たる = 意味のある検索★。
   *   ★ここに同じ早期 return を足すと、ハイフンでシリアルを探せなくなる★。
   */
  it("★`includesFoldPlain` は記号だけの入力を弾かない★(シリアル検索が生きる)", () => {
    expect(includesFoldPlain("PUDU-CC1-0001", "-")).toBe(true);
    expect(includesFoldPlain("DS-001", "-")).toBe(true);
    // ★記号を持たないシリアルには当たらない★ = ちゃんと絞り込みとして働いている
    expect(includesFoldPlain("8111R5628060011", "-")).toBe(false);
    // 中点・空白も「そのまま探す」だけ(弾かない)
    expect(includesFoldPlain("ドン・キホーテ戸塚", "・")).toBe(true);
    expect(includesFoldPlain("MEGAドン・キホーテ 港南", " ")).toBe(true);
    expect(includesFoldPlain("8111R5628060011", "・")).toBe(false);
  });
});