Add pure filter/lock logic with Vitest tests

Adds src/app/table-filters.ts (DataRow type, FilterType, distinctValues,
inferFilterType, matchesFilter, nextLockedRow) with 16 Vitest tests covering
type inference thresholds, date-range encoding, and row-lock toggling.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-06-18 09:42:18 +08:00
parent f8dcc268c6
commit 4faa960591
5 changed files with 993 additions and 3 deletions

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import {
CATEGORY_THRESHOLD,
distinctValues,
inferFilterType,
matchesFilter,
nextLockedRow,
} from "./table-filters";
const D = (rows: Record<string, unknown>[]) => rows;
describe("distinctValues", () => {
it("returns [] for empty data", () => {
expect(distinctValues("x", [])).toEqual([]);
});
it("skips null/undefined/empty and dedupes", () => {
const data = D([{ x: "a" }, { x: "b" }, { x: "a" }, { x: null }, { x: "" }, { x: undefined }]);
expect(distinctValues("x", data).sort()).toEqual(["a", "b"]);
});
it("stringifies numbers", () => {
expect(distinctValues("x", D([{ x: 1 }, { x: 2 }, { x: 1 }])).sort()).toEqual(["1", "2"]);
});
});
describe("inferFilterType", () => {
it("date when all non-empty values are YYYY-MM-DD", () => {
const data = D([{ d: "2024-01-01" }, { d: "2025-12-31" }, { d: null }]);
expect(inferFilterType("d", data)).toBe("date");
});
it("category when distinct count <= threshold", () => {
const data = D([{ x: "A" }, { x: "B" }, { x: "C" }]);
expect(inferFilterType("x", data)).toBe("category");
});
it("text when distinct count exceeds threshold", () => {
const data = D(Array.from({ length: CATEGORY_THRESHOLD + 1 }, (_, i) => ({ x: String(i) })));
expect(inferFilterType("x", data)).toBe("text");
});
it("text fallback when no data", () => {
expect(inferFilterType("x", [])).toBe("text");
});
});
describe("matchesFilter", () => {
it("text: case-insensitive substring", () => {
expect(matchesFilter("text", "Pressure Gauge", "press")).toBe(true);
expect(matchesFilter("text", "Pressure Gauge", "xyz")).toBe(false);
});
it("text: treats null/undefined as empty", () => {
expect(matchesFilter("text", null, "anything")).toBe(false);
expect(matchesFilter("text", null, "")).toBe(true);
});
it("category: exact equality", () => {
expect(matchesFilter("category", "A", "A")).toBe(true);
expect(matchesFilter("category", "A", "B")).toBe(false);
});
it("date: within closed range 'start|end'", () => {
expect(matchesFilter("date", "2024-06-15", "2024-01-01|2024-12-31")).toBe(true);
expect(matchesFilter("date", "2023-06-15", "2024-01-01|2024-12-31")).toBe(false);
});
it("date: open-ended range (empty side allowed)", () => {
expect(matchesFilter("date", "2030-01-01", "|2024-12-31")).toBe(false);
expect(matchesFilter("date", "2020-01-01", "2024-01-01|")).toBe(false);
expect(matchesFilter("date", "2024-06-15", "2024-01-01|")).toBe(true);
});
it("date: empty cell never matches a non-empty filter", () => {
expect(matchesFilter("date", "", "2024-01-01|2024-12-31")).toBe(false);
expect(matchesFilter("date", null, "|")).toBe(false);
});
});
describe("nextLockedRow", () => {
it("locks when nothing locked", () => {
expect(nextLockedRow(null, "5")).toBe("5");
});
it("unlocks when clicking the locked row", () => {
expect(nextLockedRow("5", "5")).toBe(null);
});
it("switches when clicking a different row", () => {
expect(nextLockedRow("5", "7")).toBe("7");
});
});

75
src/app/table-filters.ts Normal file
View File

@@ -0,0 +1,75 @@
export interface DataRow {
[key: string]: string | number | null;
}
export type FilterType = "date" | "category" | "text";
export const CATEGORY_THRESHOLD = 50;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/** Non-empty distinct stringified values of a column. */
export function distinctValues(
key: string,
data: Record<string, unknown>[]
): string[] {
const set = new Set<string>();
for (const row of data) {
const v = row[key];
if (v === null || v === undefined) continue;
const s = String(v);
if (s !== "") set.add(s);
}
return [...set];
}
/** Infer filter UI type from the column's actual data. */
export function inferFilterType(
key: string,
data: Record<string, unknown>[]
): FilterType {
const vals = distinctValues(key, data);
if (vals.length === 0) return "text";
if (vals.every((v) => DATE_RE.test(v))) return "date";
if (vals.length <= CATEGORY_THRESHOLD) return "category";
return "text";
}
/**
* Does a cell match a single filter key?
* - text: case-insensitive substring
* - category: exact equality
* - date: range encoded as "start|end" (either side may be ""), ISO string compare
*/
export function matchesFilter(
type: FilterType,
cellValue: unknown,
filterValue: string
): boolean {
const cell =
cellValue === null || cellValue === undefined ? "" : String(cellValue);
if (type === "text") {
return cell.toLowerCase().includes(filterValue.toLowerCase());
}
if (type === "category") {
return cell === filterValue;
}
// date
if (cell === "") return false;
const sep = filterValue.indexOf("|");
if (sep < 0) return cell === filterValue; // defensive: single date exact
const start = filterValue.slice(0, sep);
const end = filterValue.slice(sep + 1);
if (start && cell < start) return false;
if (end && cell > end) return false;
return true;
}
/** Toggle row-lock: click locked row -> unlock; click other -> switch. */
export function nextLockedRow(
prev: string | null,
clickedKey: string
): string | null {
return prev === clickedKey ? null : clickedKey;
}