workbench(vault): filter entries by status, metadata text, and facets (#763)
Make a populated vault navigable beyond opaque digests: - status facet: 'all' + the distinct epistemic_status values present in the loaded entries (derived, never a hardcoded closed set — the status vocabulary is engine-owned) - text search now reaches metadata keys/values, not just the epistemic labels + index - boolean facets: 'Has proposition' (propositional_form) and 'Has promotion digest' (promotion_certificate_digest); dropped 'has replay hash' — no such field exists - filter-empty stays filter-empty (not persistence guidance) No new endpoint; filters operate on already-loaded entries.
This commit is contained in:
parent
74dc7c7a3e
commit
3e8f40b685
2 changed files with 190 additions and 18 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -22,14 +22,14 @@ const entries: VaultEntry[] = [
|
|||
entry_index: 0,
|
||||
epistemic_status: "coherent",
|
||||
epistemic_state: "verified",
|
||||
metadata: { concept: "truth" },
|
||||
metadata: { concept: "truth", propositional_form: "alpha causes beta" },
|
||||
versor_digest: "sha256:aaaaaaaaaaaaaaaaaaaa",
|
||||
},
|
||||
{
|
||||
entry_index: 1,
|
||||
epistemic_status: "speculative",
|
||||
epistemic_state: "inferred",
|
||||
metadata: { concept: "beauty" },
|
||||
metadata: { concept: "beauty", promotion_certificate_digest: "sha256:certcert" },
|
||||
versor_digest: "sha256:bbbbbbbbbbbbbbbbbbbb",
|
||||
},
|
||||
];
|
||||
|
|
@ -184,9 +184,12 @@ describe("VaultRoute", () => {
|
|||
|
||||
expect(await screen.findByText("entry_count")).toBeInTheDocument();
|
||||
expect(screen.getByText("reproject_interval")).toBeInTheDocument();
|
||||
expect(screen.getByText("coherent")).toBeInTheDocument();
|
||||
expect(screen.getByText("verified")).toBeInTheDocument();
|
||||
expect(screen.getByText("speculative")).toBeInTheDocument();
|
||||
// Scope row pills to the listbox — the status <select> also renders
|
||||
// "coherent"/"speculative" as <option> text.
|
||||
const list = screen.getByRole("listbox", { name: "Vault entries" });
|
||||
expect(within(list).getByText("coherent")).toBeInTheDocument();
|
||||
expect(within(list).getByText("verified")).toBeInTheDocument();
|
||||
expect(within(list).getByText("speculative")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never invents a similarity / relevance score (exact-recall doctrine)", async () => {
|
||||
|
|
@ -202,7 +205,9 @@ describe("VaultRoute", () => {
|
|||
const user = userEvent.setup();
|
||||
renderRoute();
|
||||
|
||||
await user.click(await screen.findByText("speculative"));
|
||||
// Click entry 1's state pill ("inferred") — unambiguous; "speculative"
|
||||
// now also appears as a status <select> option.
|
||||
await user.click(await screen.findByText("inferred"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("subject")).toHaveTextContent("vault_entry:1"),
|
||||
|
|
@ -216,9 +221,97 @@ describe("VaultRoute", () => {
|
|||
|
||||
const list = await screen.findByRole("listbox", { name: "Vault entries" });
|
||||
list.focus();
|
||||
expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true");
|
||||
// Scope to the listbox: the status <select> also owns role="option" nodes.
|
||||
expect(within(list).getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await user.keyboard("j");
|
||||
expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true");
|
||||
expect(within(list).getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
// Scope row counting to the listbox — the status <select> also owns options.
|
||||
function visibleRows() {
|
||||
return within(
|
||||
screen.getByRole("listbox", { name: "Vault entries" }),
|
||||
).getAllByRole("option");
|
||||
}
|
||||
|
||||
it("status facet narrows entries to the chosen epistemic_status", async () => {
|
||||
stubVault();
|
||||
const user = userEvent.setup();
|
||||
renderRoute();
|
||||
|
||||
await screen.findByText("entry_count");
|
||||
expect(visibleRows()).toHaveLength(2);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByRole("combobox", { name: "Filter by epistemic status" }),
|
||||
"coherent",
|
||||
);
|
||||
const rows = visibleRows();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toHaveTextContent("verified"); // entry 0 (coherent/verified)
|
||||
});
|
||||
|
||||
it("'Has proposition' facet keeps only entries carrying propositional_form", async () => {
|
||||
stubVault();
|
||||
const user = userEvent.setup();
|
||||
renderRoute();
|
||||
|
||||
await screen.findByText("entry_count");
|
||||
await user.click(screen.getByRole("switch", { name: "Has proposition" }));
|
||||
|
||||
const rows = visibleRows();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toHaveTextContent("verified"); // entry 0 has propositional_form
|
||||
});
|
||||
|
||||
it("'Has promotion digest' facet keeps only certified entries", async () => {
|
||||
stubVault();
|
||||
const user = userEvent.setup();
|
||||
renderRoute();
|
||||
|
||||
await screen.findByText("entry_count");
|
||||
await user.click(screen.getByRole("switch", { name: "Has promotion digest" }));
|
||||
|
||||
const rows = visibleRows();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toHaveTextContent("inferred"); // entry 1 (speculative/inferred) has the digest
|
||||
});
|
||||
|
||||
it("text search reaches metadata values, not just epistemic labels", async () => {
|
||||
stubVault();
|
||||
const user = userEvent.setup();
|
||||
renderRoute();
|
||||
|
||||
await screen.findByText("entry_count");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Filter by status, state, index, or metadata"),
|
||||
"beauty",
|
||||
);
|
||||
|
||||
// SearchInput debounces ~150ms before it propagates onChange.
|
||||
await waitFor(() => expect(visibleRows()).toHaveLength(1));
|
||||
expect(visibleRows()[0]).toHaveTextContent("inferred"); // entry 1 metadata.concept = "beauty"
|
||||
});
|
||||
|
||||
it("a filter that matches nothing stays filter-empty, not persistence guidance", async () => {
|
||||
stubVault();
|
||||
const user = userEvent.setup();
|
||||
renderRoute();
|
||||
|
||||
await screen.findByText("entry_count");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Filter by status, state, index, or metadata"),
|
||||
"no-such-entry",
|
||||
);
|
||||
|
||||
// SearchInput debounces ~150ms before it propagates onChange.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No vault entries match this filter.")).toBeInTheDocument(),
|
||||
);
|
||||
// NOT the absence/persistence guidance
|
||||
expect(
|
||||
screen.queryByText(/No persisted vault snapshot is available\./),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -148,9 +148,54 @@ function EntryCountToolbar({ count }: { count: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
// True when an entry's open metadata carries a non-empty value at `key`.
|
||||
function metaPresent(entry: VaultEntry, key: string): boolean {
|
||||
const value = entry.metadata?.[key];
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
}
|
||||
|
||||
// Flatten an entry's metadata to a searchable string (keys + values), so the
|
||||
// text filter reaches stored content, not just the epistemic labels.
|
||||
function metadataText(metadata: Record<string, unknown> | undefined): string {
|
||||
if (!metadata) return "";
|
||||
return Object.entries(metadata)
|
||||
.map(([k, v]) => `${k} ${v !== null && typeof v === "object" ? JSON.stringify(v) : String(v)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function FacetToggle({
|
||||
label,
|
||||
active,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={active}
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
className={`inline-flex h-6 items-center rounded-md border px-2 text-xs transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] ${
|
||||
active
|
||||
? "border-[var(--color-selected-border)] bg-[var(--color-selected-bg)] text-[var(--color-text-primary)]"
|
||||
: "border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] text-[var(--color-text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function VaultRoute() {
|
||||
const { subject, setSubject, setInspectorOpen } = useEvidenceSubject();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [hasPropForm, setHasPropForm] = useState(false);
|
||||
const [hasPromotionDigest, setHasPromotionDigest] = useState(false);
|
||||
|
||||
const summaryQuery = useVaultSummary();
|
||||
const summary = summaryQuery.data;
|
||||
|
|
@ -161,16 +206,26 @@ export function VaultRoute() {
|
|||
subject.kind === "vault_entry" ? subject.entryIndex : null;
|
||||
|
||||
const entries = entriesQuery.data ?? [];
|
||||
|
||||
// Status options are derived from the loaded entries, never a hardcoded set —
|
||||
// the epistemic_status vocabulary is engine-owned.
|
||||
const statusOptions = useMemo(() => {
|
||||
const present = new Set(entries.map((entry) => entry.epistemic_status));
|
||||
return ["all", ...Array.from(present).sort()];
|
||||
}, [entries]);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return entries;
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
entry.epistemic_status.toLowerCase().includes(q) ||
|
||||
entry.epistemic_state.toLowerCase().includes(q) ||
|
||||
String(entry.entry_index).includes(q),
|
||||
);
|
||||
}, [search, entries]);
|
||||
return entries.filter((entry) => {
|
||||
if (statusFilter !== "all" && entry.epistemic_status !== statusFilter) return false;
|
||||
if (hasPropForm && !metaPresent(entry, "propositional_form")) return false;
|
||||
if (hasPromotionDigest && !metaPresent(entry, "promotion_certificate_digest")) return false;
|
||||
if (!q) return true;
|
||||
const haystack =
|
||||
`${entry.epistemic_status} ${entry.epistemic_state} ${entry.entry_index} ${metadataText(entry.metadata)}`.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}, [search, statusFilter, hasPropForm, hasPromotionDigest, entries]);
|
||||
|
||||
// Hydrate a URL-restored (identity-only) vault_entry subject with its data.
|
||||
useEffect(() => {
|
||||
|
|
@ -253,10 +308,34 @@ export function VaultRoute() {
|
|||
<div className="grid min-h-0 gap-3">
|
||||
<VaultSummaryStrip summary={summary} />
|
||||
<SearchInput
|
||||
placeholder="Filter by epistemic status, state, or index"
|
||||
placeholder="Filter by status, state, index, or metadata"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
aria-label="Filter by epistemic status"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] px-2 py-1 text-sm text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||
>
|
||||
{statusOptions.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status === "all" ? "all statuses" : status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<FacetToggle
|
||||
label="Has proposition"
|
||||
active={hasPropForm}
|
||||
onToggle={() => setHasPropForm((value) => !value)}
|
||||
/>
|
||||
<FacetToggle
|
||||
label="Has promotion digest"
|
||||
active={hasPromotionDigest}
|
||||
onToggle={() => setHasPromotionDigest((value) => !value)}
|
||||
/>
|
||||
</div>
|
||||
{entriesQuery.isLoading ? (
|
||||
<LoadingState label="Loading vault entries..." />
|
||||
) : entriesQuery.isError ? (
|
||||
|
|
|
|||
Loading…
Reference in a new issue