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/markdown-it": "^14.2.0",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
|
"playwright": "^1.64.1",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"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"],
|
arity: OpMeta["arity"],
|
||||||
): OpMeta => ({ key, label, negated, arity });
|
): OpMeta => ({ key, label, negated, arity });
|
||||||
|
|
||||||
export const OPS: Record<string, OpMeta> = {
|
// The map key DERIVES from meta.key — the wire value and the display
|
||||||
contains: op("contains", "contains", false, "many"),
|
// label cannot drift apart again (a mis-keyed entry threw every
|
||||||
not_contains: op("doesn't contain", "doesn't contain", true, "many"),
|
// non-self-named operator out of the allow-list: "doesn't contain" was
|
||||||
is: op("is", "is", false, "many"),
|
// being sent literally, and the server rightly refused it).
|
||||||
is_not: op("isn't", "isn't", true, "many"),
|
export const OPS: Record<string, OpMeta> = Object.fromEntries(
|
||||||
empty: op("is empty", "is empty", false, "none"),
|
[
|
||||||
is_true: op("is true", "is true", false, "none"),
|
op("contains", "contains", false, "many"),
|
||||||
is_false: op("is false", "is false", false, "none"),
|
op("not_contains", "doesn't contain", true, "many"),
|
||||||
between: op("between", "between", false, "two"),
|
op("is", "is", false, "many"),
|
||||||
at_least: op("at least", "at least", false, "one"),
|
op("is_not", "isn't", true, "many"),
|
||||||
at_most: op("at most", "at most", false, "one"),
|
op("empty", "is empty", false, "none"),
|
||||||
within_days: op("within the last", "within the last", false, "one"),
|
op("is_true", "is true", false, "none"),
|
||||||
has: op("has", "has", false, "none"),
|
op("is_false", "is false", false, "none"),
|
||||||
missing: op("missing", "missing", false, "none"),
|
op("between", "between", false, "two"),
|
||||||
unrated: op("unrated", "unrated", false, "none"),
|
op("at_least", "at least", false, "one"),
|
||||||
in: op("in", "in", false, "many"),
|
op("at_most", "at most", false, "one"),
|
||||||
not_in: op("isn't in", "isn't in", true, "many"),
|
op("within_days", "within the last", false, "one"),
|
||||||
in_any: op("in any", "in any", false, "none"),
|
op("has", "has", false, "none"),
|
||||||
in_none: op("in none", "in none", 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 {
|
export interface FieldMeta {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
Reference in new issue
Block a user