diff --git a/workbench-ui/src/app/vault/VaultRoute.test.tsx b/workbench-ui/src/app/vault/VaultRoute.test.tsx index e14487e5..f864db00 100644 --- a/workbench-ui/src/app/vault/VaultRoute.test.tsx +++ b/workbench-ui/src/app/vault/VaultRoute.test.tsx @@ -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 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 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(); }); }); diff --git a/workbench-ui/src/app/vault/VaultRoute.tsx b/workbench-ui/src/app/vault/VaultRoute.tsx index 290263c4..80a0f5b8 100644 --- a/workbench-ui/src/app/vault/VaultRoute.tsx +++ b/workbench-ui/src/app/vault/VaultRoute.tsx @@ -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 | 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 ( + + ); +} + 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() {
+
+ + setHasPropForm((value) => !value)} + /> + setHasPromotionDigest((value) => !value)} + /> +
{entriesQuery.isLoading ? ( ) : entriesQuery.isError ? (