/**
* 選択窓の絞り込み(指示書BE §4-1・§8)。AND・大文字小文字・0件・分類の初出順。
*/
import { describe, expect, it } from "vitest";
import {
errorListCategories,
filterErrorLists,
groupErrorLists,
normalizeErrorListKeyword,
shouldCloseOnBackdrop,
trapFocusIndex,
} from "@/lib/errors/error-content-picker";
import type { ErrorListItem } from "@/lib/types";
function item(partial: Partial<ErrorListItem> & Pick<ErrorListItem, "id">): ErrorListItem {
return {
error_category_main_en: "ROBOT",
error_category_main_ja: "清掃ロボットエラー",
error_category_sub_en: "Safety",
error_category_sub_ja: "安全",
error_detail_en: "Emergency stop",
error_detail_ja: "非常停止",
note_required: false,
...partial,
};
}
const a = item({ id: 2, error_detail_ja: "非常停止", error_detail_en: "Emergency STOP" });
const b = item({
id: 10,
error_category_main_ja: "制御盤エラー",
error_category_main_en: "PANEL",
error_category_sub_ja: "通信",
error_category_sub_en: "Link",
error_detail_ja: "通信断",
error_detail_en: "Link lost",
});
const c = item({ id: 1, error_detail_ja: "ブラシ", error_detail_en: "Brush" });
const catalog = [a, b, c];
describe("filterErrorLists", () => {
it("キーワード無し・分類無しは id 順のまま全件", () => {
expect(filterErrorLists(catalog, { lang: "ja", keyword: "", categoryKey: null }).map((i) => i.id)).toEqual([
2, 10, 1,
]);
});
it("キーワードは部分一致・大文字小文字を区別しない(今の言語の詳細)", () => {
expect(
filterErrorLists(catalog, { lang: "en", keyword: "stop", categoryKey: null }).map((i) => i.id)
).toEqual([2]);
});
it("もう一方の言語の詳細にも当たる", () => {
expect(
filterErrorLists(catalog, { lang: "ja", keyword: "BRUSH", categoryKey: null }).map((i) => i.id)
).toEqual([1]);
});
it("分類とキーワードは AND。外れると0件", () => {
const key = errorListCategories(catalog).find((c) => c.ja === "制御盤エラー")!.key;
expect(
filterErrorLists(catalog, { lang: "ja", keyword: "非常", categoryKey: key })
).toEqual([]);
expect(
filterErrorLists(catalog, { lang: "ja", keyword: "通信", categoryKey: key }).map((i) => i.id)
).toEqual([10]);
});
it("大分類は id 順の初出(id 1 の清掃ロボットが先)", () => {
expect(errorListCategories(catalog).map((c) => c.ja)).toEqual([
"清掃ロボットエラー",
"制御盤エラー",
]);
});
it("クリア(空白だけのキーワードと分類なし)は全件に戻る", () => {
const key = errorListCategories(catalog)[0]!.key;
expect(
filterErrorLists(catalog, { lang: "ja", keyword: "非常", categoryKey: key }).length
).toBeGreaterThan(0);
expect(
filterErrorLists(catalog, { lang: "ja", keyword: " ", categoryKey: null }).map((i) => i.id)
).toEqual([2, 10, 1]);
});
it("見出しは大分類 / 小分類。小分類だけでは分けない塊が続く", () => {
const filtered = filterErrorLists([c, a], { lang: "ja", keyword: "", categoryKey: null });
expect(groupErrorLists("ja", filtered).map((g) => g.heading)).toEqual(["清掃ロボットエラー / 安全"]);
expect(groupErrorLists("ja", filtered)[0].items.map((i) => i.id)).toEqual([1, 2]);
});
});
describe("filterErrorLists のキーワード対象(詳細の ja / en だけ。分類名には当てない)", () => {
// 詳細には出ない語を小分類・大分類に入れる
const x = item({
id: 5,
error_category_main_ja: "制御盤エラー",
error_category_main_en: "PANEL ERROR",
error_category_sub_ja: "電源",
error_category_sub_en: "Power",
error_detail_ja: "落ちた",
error_detail_en: "Down",
});
const y = item({ id: 6, error_detail_ja: "別件", error_detail_en: "Other" });
// 小分類が「データ」の2行。詳細に「データ」を含むのは 21 だけ
const dataSub = item({
id: 20,
error_category_sub_ja: "データ",
error_category_sub_en: "Data",
error_detail_ja: "地図の読み込みに失敗",
error_detail_en: "Map load failed",
});
const dataDetail = item({
id: 21,
error_category_sub_ja: "データ",
error_category_sub_en: "Data",
error_detail_ja: "データが壊れています",
error_detail_en: "Corrupted",
});
const battery = item({ id: 30, error_detail_ja: "バッテリー残量が少ない", error_detail_en: "Low battery" });
const can = item({ id: 31, error_detail_ja: "CAN通信 エラー 2", error_detail_en: "CAN bus error 2" });
const list = [x, y, dataSub, dataDetail, battery, can];
const ids = (lang: "ja" | "en", keyword: string) =>
filterErrorLists(list, { lang, keyword, categoryKey: null }).map((i) => i.id);
it("詳細(日本語)に部分一致で当たる", () => {
expect(ids("ja", "落ち")).toEqual([5]);
});
it("JP の画面でも英語の詳細に当たり、EN の画面でも日本語の詳細に当たる", () => {
expect(ids("ja", "down")).toEqual([5]);
expect(ids("en", "落ちた")).toEqual([5]);
expect(ids("en", "down")).toEqual([5]);
});
it("小分類・大分類の名前には当たらない(JP / EN とも)", () => {
expect(ids("ja", "電源")).toEqual([]);
expect(ids("en", "power")).toEqual([]);
expect(ids("ja", "制御盤")).toEqual([]);
expect(ids("en", "panel")).toEqual([]);
});
it("「データ」は小分類の名前では当たらず、詳細に「データ」を含む行だけ", () => {
expect(ids("ja", "データ")).toEqual([21]);
expect(ids("en", "data")).toEqual([]);
});
it("ひらがな・半角カナでカタカナの詳細に当たる", () => {
expect(ids("ja", "ばってりー")).toEqual([30]);
expect(ids("ja", "バッテリー")).toEqual([30]);
expect(ids("ja", "でーた")).toEqual([21]);
});
it("全角英数で半角の詳細に当たる(大文字・小文字も区別しない)", () => {
expect(ids("ja", "CAN")).toEqual([31]);
expect(ids("ja", "can")).toEqual([31]);
expect(ids("en", "BUS ERROR 2")).toEqual([31]);
});
it("全角スペースは半角スペースと同じに扱う・前後の全角スペースは除く", () => {
expect(ids("ja", "通信 エラー")).toEqual([31]);
expect(ids("ja", " ばってりー ")).toEqual([30]);
expect(ids("ja", " ")).toEqual([5, 6, 20, 21, 30, 31]);
});
it("分類と AND — 選んだ大分類の外の行は、キーワードに当たっても返さない", () => {
const panel = errorListCategories(list).find((c) => c.ja === "制御盤エラー")!.key;
const robot = errorListCategories(list).find((c) => c.ja === "清掃ロボットエラー")!.key;
expect(filterErrorLists(list, { lang: "ja", keyword: "ばってりー", categoryKey: panel })).toEqual([]);
expect(
filterErrorLists(list, { lang: "ja", keyword: "ばってりー", categoryKey: robot }).map((i) => i.id)
).toEqual([30]);
expect(
filterErrorLists(list, { lang: "ja", keyword: "down", categoryKey: panel }).map((i) => i.id)
).toEqual([5]);
expect(filterErrorLists(list, { lang: "ja", keyword: "down", categoryKey: robot })).toEqual([]);
});
});
describe("normalizeErrorListKeyword", () => {
it.each([
["ばってりー", "バッテリー"],
["バッテリー", "バッテリー"],
["CAN12", "can12"],
["a b", "a b"],
["バッテリー", "バッテリー"],
])("%s → %s", (input, expected) => {
expect(normalizeErrorListKeyword(input)).toBe(expected);
});
});
describe("groupErrorLists", () => {
it("同じ小分類でも大分類が違えば別の塊(「データ」)", () => {
const robot = item({
id: 20,
error_category_main_ja: "清掃ロボットエラー",
error_category_main_en: "ROBOT ERROR",
error_category_sub_ja: "データ",
error_category_sub_en: "Data",
});
const setting = item({
id: 21,
error_category_main_ja: "清掃ロボット設定エラー",
error_category_main_en: "ROBOT SETTING ERROR",
error_category_sub_ja: "データ",
error_category_sub_en: "Data",
});
const groups = groupErrorLists("ja", [robot, setting]);
expect(groups.map((g) => g.heading)).toEqual([
"清掃ロボットエラー / データ",
"清掃ロボット設定エラー / データ",
]);
expect(groups.map((g) => g.items.map((i) => i.id))).toEqual([[20], [21]]);
expect(groupErrorLists("en", [robot, setting]).map((g) => g.heading)).toEqual([
"ROBOT ERROR / Data",
"ROBOT SETTING ERROR / Data",
]);
});
});
describe("shouldCloseOnBackdrop(背景のクリック)", () => {
it("押したのも離したのも背景のときだけ閉じる", () => {
expect(shouldCloseOnBackdrop(true, true)).toBe(true);
});
it("キーワード入力の中で押して背景で離したときは閉じない", () => {
expect(shouldCloseOnBackdrop(false, true)).toBe(false);
});
it("背景で押して窓の中で離したときも閉じない", () => {
expect(shouldCloseOnBackdrop(true, false)).toBe(false);
expect(shouldCloseOnBackdrop(false, false)).toBe(false);
});
});
describe("trapFocusIndex(Tab の閉じ込め)", () => {
it("最後で Tab は最初へ", () => {
expect(trapFocusIndex(5, 4, false)).toBe(0);
});
it("最初で Shift+Tab は最後へ", () => {
expect(trapFocusIndex(5, 0, true)).toBe(4);
});
it("途中はブラウザの既定に任せる(null)", () => {
expect(trapFocusIndex(5, 2, false)).toBeNull();
expect(trapFocusIndex(5, 2, true)).toBeNull();
expect(trapFocusIndex(5, 0, false)).toBeNull();
expect(trapFocusIndex(5, 4, true)).toBeNull();
});
it("フォーカスが窓の外(-1)なら、Tab は最初・Shift+Tab は最後へ", () => {
expect(trapFocusIndex(5, -1, false)).toBe(0);
expect(trapFocusIndex(5, -1, true)).toBe(4);
});
it("絞り込みで数が変わっても、押した時点の数で折り返す", () => {
expect(trapFocusIndex(3, 2, false)).toBe(0);
expect(trapFocusIndex(3, 0, true)).toBe(2);
expect(trapFocusIndex(1, 0, false)).toBe(0);
expect(trapFocusIndex(1, 0, true)).toBe(0);
});
it("フォーカスできる要素が無ければ null", () => {
expect(trapFocusIndex(0, -1, false)).toBeNull();
});
});