fix(filters): operator table sent labels as wire values — every non-self-named operator was broken (user report)
The client's operator map passed the display string as BOTH the map key and the OpMeta.key, so the menu clicks transmitted 'doesn't contain' / 'isn't' / 'is empty' / 'at least' / … literally. The server rightly rejected them (unknown operator → 400), the count fetch's catch hid the counter, and flipping away from such an operator never came back. Only self-named ops (contains, is, has, between…) worked. - Map keys now DERIVE from meta.key (Object.fromEntries over the op list) — wire value and label structurally cannot diverge again. - New scripts/filter_builder_harness.js: a playwright harness that drives the real drawer in headless Chromium and sweeps every author operator, capturing filter-count responses and console errors — the first UI-level regression net for the builder (the server tests could never see a client-side mis-key). - Verified live: contains 8 → doesn't contain 60 → is 8 → isn't 60 → is empty 2 → refill 8; zero console errors across the sweep.
This commit is contained in:
1 parent
68701d6b94
commit
43987262a6
3 files changed
+123
-20
No files matched your search
@@ -31,6 +31,7 @@
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"playwright": "^1.64.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Filter-builder UI regression harness (faceted plan Phase 9 follow-up).
|
||||
//
|
||||
// Drives the real bookshelf in headless Chromium: opens the filter
|
||||
// drawer, types an author value, and sweeps every author operator,
|
||||
// asserting the live count fetch behaves (200s, correct numbers, no
|
||||
// console errors). Born from the mis-keyed operator table that made
|
||||
// every non-self-named operator (doesn't contain, is empty, at least…)
|
||||
// a silent 400 — the class of bug only a real browser catches.
|
||||
//
|
||||
// Prerequisites: dev stack up (make dev / docker compose), playwright
|
||||
// installed (cd web && npm i -D playwright && npx playwright install
|
||||
// chromium — the repo root's devDependency already carries it), and a
|
||||
// reachable server.
|
||||
//
|
||||
// Usage: NODE_PATH=<repo>/node_modules node scripts/filter_builder_harness.js
|
||||
// Env: HARNESS_BASE (default http://localhost:8765), HARNESS_LOGIN,
|
||||
// HARNESS_PASSWORD.
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE = process.env.HARNESS_BASE || "http://localhost:8765";
|
||||
const LOGIN = process.env.HARNESS_LOGIN || "testuser@tests.bookhoard.internal";
|
||||
const PASSWORD = process.env.HARNESS_PASSWORD || "Test@Pass123!";
|
||||
const AUTHOR = "Andrzej Sapkowski";
|
||||
const OPS_SWEEP = ["doesn't contain", "is", "isn't", "is empty", "contains"];
|
||||
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const events = [];
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error" || m.type() === "warning") {
|
||||
events.push(`[console.${m.type()}] ${m.text()}`);
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (e) => events.push(`[pageerror] ${e.message}`));
|
||||
page.on("response", async (r) => {
|
||||
if (r.url().includes("filter-count")) {
|
||||
let body = "";
|
||||
try { body = (await r.text()).slice(0, 120); } catch {}
|
||||
events.push(`[net] filter-count ${r.status()} ${body}`);
|
||||
}
|
||||
});
|
||||
|
||||
const login = await context.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { login: LOGIN, password: PASSWORD },
|
||||
});
|
||||
const loginBody = await login.json();
|
||||
await context.addInitScript((t) => localStorage.setItem("token", t), loginBody.access_token);
|
||||
console.log("login:", login.status());
|
||||
|
||||
await page.goto(`${BASE}/bookshelf`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("library-select");
|
||||
const empty = [...el.options].find((o) => o.value === "");
|
||||
if (empty) {
|
||||
el.value = "";
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.click('button:has-text("Filters")');
|
||||
await page.waitForTimeout(300);
|
||||
const valueInput = page.locator(".fb-value").first();
|
||||
await valueInput.click();
|
||||
await valueInput.fill(AUTHOR);
|
||||
await page.waitForTimeout(1200);
|
||||
const countEl = page.locator('span[x-show*="matchCount"]').first();
|
||||
console.log("contains:", JSON.stringify(await countEl.textContent()));
|
||||
|
||||
for (const label of OPS_SWEEP) {
|
||||
await page.locator(".fb-picker-op").first().click();
|
||||
await page.waitForTimeout(150);
|
||||
await page
|
||||
.locator(".fb-menu-item:visible")
|
||||
.filter({ hasText: new RegExp(`^${label.replace(/[\W_]+/g, "\\$&")}$`) })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(1300);
|
||||
console.log(`after "${label}":`, JSON.stringify(await countEl.textContent()));
|
||||
}
|
||||
|
||||
// contains-with-empty-value is incomplete (counter hidden) — refill
|
||||
// and confirm the count returns.
|
||||
await valueInput.fill(AUTHOR);
|
||||
await page.waitForTimeout(1300);
|
||||
console.log("refilled contains:", JSON.stringify(await countEl.textContent()));
|
||||
|
||||
console.log("--- warnings/errors + count calls ---");
|
||||
for (const l of events) console.log(l);
|
||||
await browser.close();
|
||||
})().catch((e) => { console.error("HARNESS ERROR:", e.message); process.exit(1); });
|
||||
+26
-20
@@ -30,26 +30,32 @@ const op = (
|
||||
arity: OpMeta["arity"],
|
||||
): OpMeta => ({ key, label, negated, arity });
|
||||
|
||||
export const OPS: Record<string, OpMeta> = {
|
||||
contains: op("contains", "contains", false, "many"),
|
||||
not_contains: op("doesn't contain", "doesn't contain", true, "many"),
|
||||
is: op("is", "is", false, "many"),
|
||||
is_not: op("isn't", "isn't", true, "many"),
|
||||
empty: op("is empty", "is empty", false, "none"),
|
||||
is_true: op("is true", "is true", false, "none"),
|
||||
is_false: op("is false", "is false", false, "none"),
|
||||
between: op("between", "between", false, "two"),
|
||||
at_least: op("at least", "at least", false, "one"),
|
||||
at_most: op("at most", "at most", false, "one"),
|
||||
within_days: op("within the last", "within the last", false, "one"),
|
||||
has: op("has", "has", false, "none"),
|
||||
missing: op("missing", "missing", false, "none"),
|
||||
unrated: op("unrated", "unrated", false, "none"),
|
||||
in: op("in", "in", false, "many"),
|
||||
not_in: op("isn't in", "isn't in", true, "many"),
|
||||
in_any: op("in any", "in any", false, "none"),
|
||||
in_none: op("in none", "in none", false, "none"),
|
||||
};
|
||||
// The map key DERIVES from meta.key — the wire value and the display
|
||||
// label cannot drift apart again (a mis-keyed entry threw every
|
||||
// non-self-named operator out of the allow-list: "doesn't contain" was
|
||||
// being sent literally, and the server rightly refused it).
|
||||
export const OPS: Record<string, OpMeta> = Object.fromEntries(
|
||||
[
|
||||
op("contains", "contains", false, "many"),
|
||||
op("not_contains", "doesn't contain", true, "many"),
|
||||
op("is", "is", false, "many"),
|
||||
op("is_not", "isn't", true, "many"),
|
||||
op("empty", "is empty", false, "none"),
|
||||
op("is_true", "is true", false, "none"),
|
||||
op("is_false", "is false", false, "none"),
|
||||
op("between", "between", false, "two"),
|
||||
op("at_least", "at least", false, "one"),
|
||||
op("at_most", "at most", false, "one"),
|
||||
op("within_days", "within the last", false, "one"),
|
||||
op("has", "has", false, "none"),
|
||||
op("missing", "missing", false, "none"),
|
||||
op("unrated", "unrated", false, "none"),
|
||||
op("in", "in", false, "many"),
|
||||
op("not_in", "isn't in", true, "many"),
|
||||
op("in_any", "in any", false, "none"),
|
||||
op("in_none", "in none", false, "none"),
|
||||
].map((m) => [m.key, m]),
|
||||
);
|
||||
|
||||
export interface FieldMeta {
|
||||
key: string;
|
||||
|
||||
Reference in new issue
Block a user