diff --git a/packages/node-ui/src/ui/api.ts b/packages/node-ui/src/ui/api.ts index 3712ababc..073af0552 100644 --- a/packages/node-ui/src/ui/api.ts +++ b/packages/node-ui/src/ui/api.ts @@ -1,3 +1,5 @@ +import type { AssertionState } from '@origintrail-official/dkg-core'; + const BASE = ''; declare global { interface Window { __DKG_TOKEN__?: string; } @@ -766,6 +768,257 @@ export async function listAssertions( return result; } +/** Current lifecycle position of a single assertion. */ +export interface AssertionStateInfo { + /** `dkg:state` literal — created / promoted / published / finalized / discarded. */ + state: AssertionState; + /** `dkg:memoryLayer` literal mapped to the UI layer key (`WM`→`wm`, etc.). */ + layer: 'wm' | 'swm' | 'vm'; + /** + * `dkg:assertionGraph` — the assertion's DATA graph URI + * (`did:dkg:context-graph:[/]/assertion//`), + * where the assertion's actual triples live. Undefined for lifecycle + * records that predate the `dkg:assertionGraph` predicate. + */ + assertionGraph?: string; + /** `prov:wasAttributedTo` — the authoring agent's DID, when recorded. */ + createdBy?: string; +} + +/** + * Read an assertion's CURRENT lifecycle state (S4 — α verdict lock-b, + * lazy). `listAssertions` deliberately does not carry the state on each + * row (today's WM list is `dkg:memoryLayer "WM"`-filtered, so every + * listed WM row is implicitly `created`); the assertion DETAIL view + * fetches the state on mount instead. + * + * `dkg:state` + `dkg:memoryLayer` are MUTABLE literals on the assertion + * LIFECYCLE entity (the `urn:dkg:assertion:…` subject) in the `/_meta` + * graph, written by `generateAssertion{Created,Promoted,Published}Metadata` + * in `@origintrail-official/dkg-publisher`. + * + * The caller passes `AssertionInfo.graphUri`, whose SHAPE differs by layer: + * - WM (since #864 `listAssertions(wm)` partition enumeration): the + * assertion's DATA GRAPH URI + * (`did:dkg:context-graph:[/]/assertion//`). + * - SWM (`listAssertions(swm)`): the LIFECYCLE URN + * (`urn:dkg:assertion:[:]::`). + * `dkg:state` is written ONLY on the lifecycle URN (publisher + * `generateAssertion*Metadata`, `metadata.ts:1155`); `dkg:memoryLayer` + * is written on BOTH the lifecycle URN AND the data-graph URI (the + * daemon's `assertionCreate` adds the latter, `dkg-publisher.ts:~4001`). + * The two are linked by ` dkg:assertionGraph ` + * (`metadata.ts:1154`). + * + * So to reach `dkg:state` from EITHER input shape we bind `?lifecycle` + * via a UNION: it is the input directly (SWM lifecycle URN) OR it links + * to the input through `dkg:assertionGraph` (WM data-graph URI). Feeding + * a WM data-graph URI straight to `dkg:state` would never match → null → + * "state unavailable" for every WM assertion (the #864 silent-regression + * trap). The UNION keys off exactly what `listAssertions` provides for + * either layer — no `AssertionInfo` shape change, so the α data-shape + * verdict (lazy lock-b: don't extend AssertionInfo) still holds. + * + * Same `_meta`-scoped query shape as `listAssertions` / + * `useAssertionLifecycleEvents`: the explicit `GRAPH <…/_meta> { … }` + * makes the engine's `wrapWithGraph` early-return so the query runs raw + * and self-scopes to this CG's `_meta` partition. + * + * `assertionGraph` returns the data-graph URI to read triples from: the + * resolved `dkg:assertionGraph` (works for the SWM lifecycle-URN input), + * falling back to the input itself (the WM data-graph URI input). + * + * Returns `null` when no lifecycle entity resolves (e.g. a row predating + * lifecycle metadata) — the detail view treats `null` as the fetch-error + * / unavailable case and renders an all-neutral trail. + */ +export async function fetchAssertionState( + contextGraphId: string, + graphUri: string, +): Promise { + const DKG = 'http://dkg.io/ontology/'; + const PROV = 'http://www.w3.org/ns/prov#'; + const metaGraph = `did:dkg:context-graph:${contextGraphId}/_meta`; + // UNION admits both `graphUri` shapes (WM data-graph URI via the + // inverse `dkg:assertionGraph` link; SWM lifecycle URN directly). + // + // Codex round-4 — branch A binds the input AS `?lifecycle` + // UNCONDITIONALLY and resolves `dkg:assertionGraph` only OPTIONALLY, so + // a legacy/partial SWM row whose lifecycle URN carries `dkg:state` but + // NOT `dkg:assertionGraph` still resolves its state (pre-fix, branch A + // required the assertionGraph triple to bind `?lifecycle`, so such a + // row read no state → "state unavailable" though the state exists). + // The outer `?lifecycle dkg:state` then gates the row: for a WM + // data-graph-URI input, branch A binds `?lifecycle = ` + // but ` dkg:state` never matches (state lives on the + // lifecycle URN), so branch A contributes nothing and branch B handles + // WM exactly as before. So WM→B, SWM→A, SWM-no-assertionGraph→A. + const sparql = `SELECT ?state ?layer ?createdBy ?assertionGraph WHERE { + GRAPH <${metaGraph}> { + { BIND(<${graphUri}> AS ?lifecycle) + OPTIONAL { <${graphUri}> <${DKG}assertionGraph> ?assertionGraph } } + UNION + { ?lifecycle <${DKG}assertionGraph> <${graphUri}> . + BIND(<${graphUri}> AS ?assertionGraph) } + ?lifecycle <${DKG}state> ?state . + OPTIONAL { ?lifecycle <${DKG}memoryLayer> ?layer } + OPTIONAL { ?lifecycle <${PROV}wasAttributedTo> ?createdBy } + } + } LIMIT 1`; + const data = await executeQuery(sparql, contextGraphId); + const bindings: any[] = data?.result?.bindings ?? []; + const first = bindings[0]; + if (!first) return null; + const state = bv(first.state) as AssertionState | undefined; + if (!state) return null; + // `dkg:memoryLayer` is one of the `MemoryLayer` enum literals + // ("WM" / "SWM" / "VM"). Fall back to deriving the layer from the + // state when the (optional) literal is absent so a partial record + // still resolves a sane layer for the badge / CTA gate. + const rawLayer = bv(first.layer); + const layer: 'wm' | 'swm' | 'vm' = + rawLayer === 'WM' ? 'wm' : + rawLayer === 'SWM' ? 'swm' : + rawLayer === 'VM' ? 'vm' : + state === 'created' ? 'wm' : + state === 'promoted' ? 'swm' : + 'vm'; + // The data graph to read triples from. Prefer the resolved + // `dkg:assertionGraph`. Codex round-3 — the fallback to `graphUri` + // itself is only safe when the INPUT is already a data-graph URI (the + // WM shape). For an SWM input (a `urn:dkg:assertion:…` lifecycle URN) + // whose `dkg:assertionGraph` did NOT resolve (legacy/partial `_meta` + // row), echoing the URN would make `fetchAssertionTriples` query + // `GRAPH ` — a graph that never holds triples — + // and render a bogus "data" set. In that case return `undefined` so + // the Triples/Entities panes fall to their empty-state instead. + const resolvedAssertionGraph = bv(first.assertionGraph); + const inputIsLifecycleUrn = graphUri.startsWith('urn:dkg:assertion:'); + const assertionGraph = resolvedAssertionGraph + ?? (inputIsLifecycleUrn ? undefined : graphUri); + return { + state, + layer, + assertionGraph, + createdBy: bv(first.createdBy), + }; +} + +/** A single triple of an assertion's data graph. */ +export interface AssertionTriple { + subject: string; + predicate: string; + object: string; +} + +/** + * Read the triples of an assertion's DATA graph (S4 — the detail view's + * Triples / Entities / Graph panes are scoped to exactly this assertion, + * not the whole layer). `assertionGraph` is the `dkg:assertionGraph` URI + * resolved by `fetchAssertionState`. + * + * A WM assertion's triples live verbatim in its + * `…/assertion//` data graph. We query that ONE graph + * directly with an explicit `GRAPH <…> { ?s ?p ?o }` (self-scoping, same + * shape as the other `_meta` reads), so the result is the assertion's + * own content with zero cross-assertion bleed. The 5k LIMIT mirrors the + * lifecycle-events query ceiling — far above any realistic single + * assertion. + * + * NOTE: after promote the assertion's data graph empties (the triples + * move into `/_shared_memory`); a `promoted` assertion's detail view + * therefore shows an empty content set today. That is the documented + * G-BACKEND-M5 gap (a promoted SWM assertion is not yet an + * independently-inspectable object); a `created` WM assertion — S4's + * shippable scope — works in full. + */ +export async function fetchAssertionTriples( + contextGraphId: string, + assertionGraph: string, +): Promise { + const sparql = `SELECT ?s ?p ?o WHERE { + GRAPH <${assertionGraph}> { ?s ?p ?o } + } LIMIT 5000`; + const data = await executeQuery(sparql, contextGraphId); + const bindings: any[] = data?.result?.bindings ?? []; + const out: AssertionTriple[] = []; + for (const b of bindings) { + const subject = rawBindingValue(b.s); + const predicate = rawBindingValue(b.p); + const object = rawBindingValue(b.o); + if (subject == null || predicate == null || object == null) continue; + out.push({ subject, predicate, object }); + } + return out; +} + +/** + * Extract the RAW binding value WITHOUT stripping the literal quoting — + * triple objects must keep their `"…"` / `"…"^^` / `"…"@lang` form + * so the graph + table renderers can distinguish a literal from an IRI + * (downstream classifies by a leading `"`, see `useMemoryEntities`). IRIs + * come back bare. Mirror of the daemon's two binding encodings (`{value}` + * object form and N-Triples string form), like `bv` but quote-preserving. + * + * Codex round-1: for the standard SPARQL-JSON object form we now (a) escape + * `\` and `"` inside the value so a literal containing a quote stays + * well-formed, and (b) PRESERVE the datatype (`^^`) / language tag + * (`@lang`) — previously dropped, contradicting this comment. The + * N-Triples string form is already fully encoded by the daemon, so it + * passes through verbatim. + */ +/** + * Escape a literal body for the N-Triples `"…"` form: backslash + quote, + * the canonical short escapes for tab/newline/carriage-return, and any + * other C0 control char as `\\uXXXX`. Order matters — backslash first so + * the escapes we add aren't re-escaped. + */ +function escapeNTriplesLiteral(value: string): string { + let out = ""; + for (const ch of value) { + const code = ch.codePointAt(0)!; + if (ch === '\\') out += '\\\\'; + else if (ch === '"') out += '\\"'; + else if (code === 0x09) out += '\\t'; + else if (code === 0x0a) out += '\\n'; + else if (code === 0x0d) out += '\\r'; + else if (code < 0x20) out += '\\u' + code.toString(16).padStart(4, '0').toUpperCase(); + else out += ch; + } + return out; +} + +function rawBindingValue(v: unknown): string | undefined { + if (v == null) return undefined; + if (typeof v === 'object' && 'value' in (v as any)) { + const node = v as any; + if (node.type === 'literal' || node.type === 'typed-literal') { + // Escape per N-Triples so embedded quotes/backslashes/control chars + // don't break the leading-`"` classification or the renderers. + // Codex round-6 — control chars (raw \n/\r/\t in a multiline + // extracted value, plus other C0 controls) were previously left + // raw → invalid N-Triples → broke the graph/triple parsers. + const escaped = escapeNTriplesLiteral(String(node.value)); + // Standard SPARQL JSON carries the datatype on `.datatype` and the + // language on `.xml:lang` (some serialisers use `.language`). + const lang = node['xml:lang'] ?? node.language; + const datatype = node.datatype; + if (lang) return `"${escaped}"@${lang}`; + if (datatype) return `"${escaped}"^^<${datatype}>`; + return `"${escaped}"`; + } + // Codex round-5 — a blank node must come back as `_:`, not the + // bare identifier; otherwise downstream (buildMemoryEntities + the + // graph) misclassify it (a bare id is neither a leading-`"` literal + // nor a recognisable resource → bnode RDF structure disappears / + // mislabels). Branch BEFORE the generic fallthrough. + if (node.type === 'bnode') return `_:${String(node.value)}`; + return String(node.value); + } + if (typeof v === 'string') return v; + return String(v); +} + /** * Promote an assertion from WM to SWM. * diff --git a/packages/node-ui/src/ui/hooks/useAssertionState.ts b/packages/node-ui/src/ui/hooks/useAssertionState.ts new file mode 100644 index 000000000..177562377 --- /dev/null +++ b/packages/node-ui/src/ui/hooks/useAssertionState.ts @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { fetchAssertionState, type AssertionStateInfo } from '../api.js'; + +/** + * Per-mount fetch of a single assertion's CURRENT lifecycle state for + * the S4 assertion detail view (α verdict lock-b — lazy: the state is + * fetched on detail-view mount, NOT carried on every `listAssertions` + * row). `dkg:state` + `dkg:memoryLayer` are mutable literals on the + * lifecycle entity in `/_meta`; see `fetchAssertionState` in + * `api.ts` for the query shape and the data-model writers. + * + * Three terminal shapes the detail view distinguishes: + * - `{ loading: true }` — hydrating; the CTA stays hidden and + * the trail renders all-neutral with + * no "you are here" marker. + * - `{ data: AssertionStateInfo }` — resolved; drives the trail + * `is-current` halo, the badge state + * suffix, and the Promote CTA gate. + * - `{ error: string }` / data null — fetch failed OR no lifecycle + * record; the metadata panel shows + * "state unavailable" and the trail + * renders all-neutral with a quiet + * danger hint. + */ +export interface UseAssertionStateResult { + data: AssertionStateInfo | null; + loading: boolean; + error: string | null; +} + +export function useAssertionState( + contextGraphId: string | undefined, + graphUri: string | undefined, + // Codex round-1 — bump this to force a re-fetch even when the URI is + // unchanged (e.g. after a promote FROM the detail view flips the state + // in `_meta`; the same `graphUri` would otherwise skip the effect and + // leave the trail/badge/CTA stale at the pre-promote state). + refreshKey?: number, +): UseAssertionStateResult { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(Boolean(contextGraphId && graphUri)); + const [error, setError] = useState(null); + + useEffect(() => { + if (!contextGraphId || !graphUri) { + setData(null); + setLoading(false); + setError(null); + return; + } + let cancelled = false; + // Codex round-1 — clear `data` when the assertion changes so a + // consumer reading `data` during the in-flight window can't see the + // PREVIOUS assertion's state (stale state/layer/assertionGraph). The + // detail view's `assertionGraph` derives from `data` and drives the + // triples fetch, so a stale value would briefly query the wrong + // graph. Mirror of the `triplesLoading` reset discipline. + setData(null); + setLoading(true); + setError(null); + fetchAssertionState(contextGraphId, graphUri) + .then((result) => { + if (cancelled) return; + setData(result); + setLoading(false); + }) + .catch((err) => { + if (cancelled) return; + // Leave any prior data in place is pointless here — the URI + // identity changed, so a stale state would be wrong. Clear it + // and surface the error so the detail view shows "unavailable". + setData(null); + setLoading(false); + setError(err instanceof Error ? err.message : 'Failed to load assertion state'); + }); + return () => { cancelled = true; }; + }, [contextGraphId, graphUri, refreshKey]); + + return { data, loading, error }; +} diff --git a/packages/node-ui/src/ui/hooks/useMemoryEntities.ts b/packages/node-ui/src/ui/hooks/useMemoryEntities.ts index 5b5ea5ffc..89baf5ad5 100644 --- a/packages/node-ui/src/ui/hooks/useMemoryEntities.ts +++ b/packages/node-ui/src/ui/hooks/useMemoryEntities.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { postQueryDeduped } from '../api.js'; import { useMemoryGraphEvents } from './useNodeEvents.js'; import { MEMORY_LABEL_PREDICATES } from '../lib/memoryLabels.js'; +import { decodeRdfStringLiteral } from '../../rdf-literal.js'; export type TrustLevel = 'working' | 'shared' | 'verified'; export type MemoryLayerKey = 'wm' | 'swm' | 'vm'; @@ -107,7 +108,11 @@ export function canonicalEntityUri(uri: string): string { function shortLabel(uri: string): string { if (!uri) return '—'; - if (uri.startsWith('"')) return uri.replace(/^"|"$/g, ''); + // Codex round-6 — decode RDF literals (drop the `^^`/`@lang` + // suffix + unescape the body) instead of a bare outer-quote strip, so + // `"Hola"@es` renders `Hola`, not `Hola"@es`. Idempotent for a + // suffix-free `"Hola"` and a no-op for non-literal IRIs. + if (uri.startsWith('"')) return decodeRdfStringLiteral(uri); const hash = uri.lastIndexOf('#'); const slash = uri.lastIndexOf('/'); const cut = Math.max(hash, slash); @@ -487,7 +492,11 @@ export function buildEntities(layered: LayeredTriple[]): Map('overview'); const [selectedUri, setSelectedUri] = useState(null); + // S4 — an open assertion detail view. A peer of `selectedUri` (entity + // detail): only one detail overlay is open at a time, and opening an + // assertion clears any open entity (and vice-versa). The origin + // snapshot (M2) is captured at open so the breadcrumb / close restores + // the originating layer + Assertions subtab + scroll. + const [selectedAssertion, setSelectedAssertion] = useState(null); + // Codex round-11 (11-1) — the layer the selected assertion's list row came + // from ('wm' | 'swm'). Seeds AssertionDetailView's badge + graph tone so it + // shows the KNOWN-true layer during the state hydrate instead of inventing + // 'wm'. Set together with `selectedAssertion` in `openAssertionDetail`. + const [selectedAssertionLayer, setSelectedAssertionLayer] = useState<'wm' | 'swm'>('wm'); const [participantsState, setParticipantsState] = useState({ contextGraphId: null, list: [], @@ -211,6 +225,16 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // downstream memo that consumed `handleNavigate`. const selectedUriRef = useRef(null); useEffect(() => { selectedUriRef.current = selectedUri; }, [selectedUri]); + // S4 — mirror `selectedAssertion` so `openEntityDetail` (whose + // identity must stay stable) can see whether an assertion overlay is + // open without listing the state in its deps. + const selectedAssertionRef = useRef(null); + useEffect(() => { selectedAssertionRef.current = selectedAssertion; }, [selectedAssertion]); + // M2 option (b) — mirror the full entity map so `handleNavigate` can + // resolve a navigated entity's primary sub-graph synchronously + // (to decide whether to follow a cross-subgraph link) WITHOUT listing + // the map in its deps and churning the callback identity. + const entitiesRef = useRef>(new Map()); const [layerContentTabs, setLayerContentTabs] = useState>( DEFAULT_LAYER_TABS, ); @@ -278,10 +302,19 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // stays stable across navigation — listing `selectedUri` here would // recreate `openEntityDetail` on every entity click, which in turn // recreates `handleNavigate` and the cross-tab listener effect (R2-2). + // + // S4 — an entity can be opened while an assertion detail is already + // open (clicking an entity row inside the assertion view). In that + // case an origin is already captured (at assertion-open) and must be + // preserved, so the capture guard also checks `selectedAssertionRef`. + // Opening the entity replaces the assertion overlay (mutually + // exclusive) — clear it here. const openEntityDetail = useCallback((uri: string, originScrollKey?: string) => { - if (!selectedUriRef.current || !detailOriginRef.current) { + const hadDetail = selectedUriRef.current != null || selectedAssertionRef.current != null; + if (!hadDetail || !detailOriginRef.current) { detailOriginRef.current = captureDetailOrigin(originScrollKey); } + setSelectedAssertion(null); setSelectedUri(uri); }, [captureDetailOrigin]); @@ -289,13 +322,39 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { detailOriginRef.current = null; }, []); + // S4 — open an assertion's detail view. Captures the M2 origin + // snapshot (when none is pending) so the breadcrumb / close restores + // the originating layer + Assertions subtab + scroll, then clears any + // open entity detail (the two detail overlays are mutually exclusive). + const openAssertionDetail = useCallback((assertion: AssertionInfo, sourceLayer: 'wm' | 'swm') => { + if (!detailOriginRef.current) { + detailOriginRef.current = captureDetailOrigin(); + } + setSelectedUri(null); + setSelectedLayerContext(null); + // Codex round-11 (11-1) — capture the source layer the list row came + // from so AssertionDetailView seeds its badge/tone immediately (no + // `?? 'wm'` invention during the state hydrate). The list is the + // authoritative layer source: a detail only opens from a WM/SWM list. + setSelectedAssertionLayer(sourceLayer); + setSelectedAssertion(assertion); + }, [captureDetailOrigin]); + + // Consumes a pending scroll restore once the detail overlay has + // actually closed. S4 — the close path is shared by the entity AND + // the assertion overlay (`handleDetailClose` queues `origin.scroll` + // for both), so the guard must wait for BOTH to clear and the deps + // must include `selectedAssertion`. Without it, closing an assertion + // back to the same layer it opened from changes none of the other + // listed deps, the effect never re-fires, and the queued scroll is + // never restored — an M2-parity gap vs the entity-close path (7-1). useEffect(() => { - if (selectedUri) return; + if (selectedUri || selectedAssertion) return; const scroll = pendingScrollRestoreRef.current; if (!scroll) return; pendingScrollRestoreRef.current = null; restoreScroll(scroll); - }, [selectedUri, activeLayer, activeSubGraph, layerContentTabs, subGraphTabs, restoreScroll]); + }, [selectedUri, selectedAssertion, activeLayer, activeSubGraph, layerContentTabs, subGraphTabs, restoreScroll]); // Cross-tab entity open — e.g. the agent profile page in another tab // fires a CustomEvent("v10:open-entity", { contextGraphId, entityUri }) @@ -356,6 +415,9 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // DashboardView caller, which already opts in for the same // failed-vs-empty-distinct reason. const rawMemory = useMemoryEntities(contextGraphId, { signalErrors: true }); + // M2 option (b) — keep the entity-map ref in sync so `handleNavigate` + // can resolve a navigated entity's sub-graph synchronously. + useEffect(() => { entitiesRef.current = rawMemory.entities; }, [rawMemory.entities]); // SWM attribution drives the SWM graph's agent-tint legend (its // sole remaining consumer). PR #694 review — the Overview no // longer reads this stream (lifecycle source replaced it), so the @@ -593,6 +655,7 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { } } setSelectedUri(null); + setSelectedAssertion(null); setSelectedLayerContext(null); }, [clearDetailOrigin, setActiveSubGraphSync]); @@ -600,6 +663,7 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { clearDetailOrigin(); setActiveLayer(layer); setSelectedUri(null); + setSelectedAssertion(null); setSelectedLayerContext(null); // PR #793 sweep 5 Bug N — go through the sync helper so the // refs stay invariant with state. Pre-fix this path bypassed @@ -621,10 +685,6 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { setSubGraphTabs(prev => prev[slug] === tab ? prev : { ...prev, [slug]: tab }); }, []); - // M2 keeps the user's origin stable: linked entities open in the detail - // pane, but the underlying layer/sub-graph page does not silently change - // until S5 adds breadcrumbs that can make that movement visible. - // // Intent: a brand-new top-level open (no selected entity yet) resets // the layer context; in-detail navigation (a click inside an open // detail) keeps the prior layer context. We read both `selectedUri` @@ -632,17 +692,55 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // `prev` argument) so the callback identity stays stable — listing // them in deps would re-create `handleNavigate` on every navigation // and rebuild every downstream memo / callback that consumes it. + // + // M2 option (b) — silent cross-subgraph switch, ENABLED alongside S5. + // When following a link to an entity that lives in a DIFFERENT + // sub-graph than the one currently in scope, switch `activeSubGraph` + // to the target's sub-graph. Pre-S5 this was suppressed (option (a): + // no switch) because the move was invisible; now the breadcrumb makes + // it visible (`Context Graph › › Entity`), so the + // switch is acceptable. Routed through `setActiveSubGraphSync` (PR + // #793 Bug N) so the breadcrumb's React tree — and the ref-keyed + // discriminators — see the new subgraph synchronously. The M2 origin + // snapshot was captured at the FIRST open, so closing still returns to + // the originating page, not the followed-into one. + // + // Only fires when ALREADY in a sub-graph page (`activeSubGraphRef`): + // opening an entity from a plain layer list must NOT spuriously jump + // into a sub-graph just because the entity happens to belong to one. const handleNavigate = useCallback((uri: string, originScrollKey?: string, layerContext?: MemoryLayerView) => { const hadSelection = selectedUriRef.current != null; openEntityDetail(uri, originScrollKey); setSelectedLayerContext(prev => layerContext ?? (hadSelection ? prev : null)); - }, [openEntityDetail]); + const currentSubGraph = activeSubGraphRef.current; + if (currentSubGraph) { + const entity = entitiesRef.current.get(uri) ?? entitiesRef.current.get(canonicalEntityUri(uri)); + // Codex round-8 (8-3) — prefer the CURRENT subgraph for a multi-homed + // entity. M2(b) switches to `primarySubGraphOf` (first non-meta), + // which is arbitrary when the entity lives in several subgraphs. If + // the entity is ALREADY in the current subgraph, stay — don't jump to + // an arbitrary sibling. Only switch when the entity isn't in the + // current subgraph at all; then `primarySubGraphOf` is the must-move + // fallback (M2(b)'s reason for existing: un-strand an entity that + // doesn't belong to the page you're on). `subGraphs` is a Set. + if (!entity?.subGraphs.has(currentSubGraph)) { + const targetSubGraph = primarySubGraphOf(entity); + if (targetSubGraph && targetSubGraph !== currentSubGraph) { + setActiveSubGraphSync(targetSubGraph); + } + } + } + }, [openEntityDetail, setActiveSubGraphSync]); const handleDetailClose = useCallback(() => { const origin = detailOriginRef.current; detailOriginRef.current = null; setSelectedUri(null); setSelectedLayerContext(null); + // S4 — close path is shared by the entity detail AND the assertion + // detail (only one is open at a time); clear both so the same + // origin-restore serves either overlay (T16). + setSelectedAssertion(null); if (!origin) return; setActiveLayer(origin.activeLayer); // PR #793 sweep 5 Bug N — restoring the M2 origin's @@ -688,6 +786,14 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { openTab(CONTEXT_GRAPH_PRIMER_TAB); }, [openTab]); + // S5 — first breadcrumb hop ("Context Graph") navigates to the + // overview, clearing any open detail / subgraph page. Reuses the + // layer-switch reset (clears detail origin + refs) so it can't strand + // a stale subgraph/detail context. + const handleBreadcrumbOverview = useCallback(() => { + handleLayerSwitch('overview'); + }, [handleLayerSwitch]); + if (!cg) { return (
@@ -700,6 +806,38 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { // across sub-graph / layer / overview routes. const activeSubGraphBinding = activeSubGraph ? profile.forSubGraph(activeSubGraph) : null; + // S5 — the breadcrumb's trailing hop is the open detail's name (entity + // label or assertion name); null when no detail is open (the middle hop + // is then the current location). + const breadcrumbDetailLabel = selectedEntity + ? selectedEntity.label + : selectedAssertion + ? selectedAssertion.name + : null; + + // Codex round-8 (8-2) — when a detail is open the breadcrumb's middle + // hop must label the M2 ORIGIN (where the middle-hop click returns you), + // not the current location. After an M2(b) cross-subgraph follow the + // active subgraph diverges from the origin, so the current-scope label + // would name the followed-into subgraph while clicking returns to the + // origin. Read the origin snapshot (captured at detail-open) and resolve + // its subgraph display name via the profile. Only meaningful when a + // detail is open — `breadcrumbDetailLabel` gates the read, and the + // detail-open selection state drives the render, so the ref is populated + // by the time we read it here. + const breadcrumbOrigin = breadcrumbDetailLabel ? detailOriginRef.current : null; + const breadcrumbOriginSubGraph = breadcrumbOrigin?.activeSubGraph ?? null; + // Codex round-10 (10-1) — `forSubGraph` returns undefined for a subgraph + // with NO profile binding, so `.displayName` would throw before the + // breadcrumb renders (opening a detail from an unprofiled subgraph would + // crash). Optional-chain and degrade to the slug — the SAME fallback the + // no-origin-subgraph case uses. (The 9-2 chrome accesses in + // ProjectHeaderStrip already optional-chain `forSubGraph`; this is the one + // un-guarded access, which resolves only the breadcrumb label.) + const breadcrumbOriginSubGraphName = breadcrumbOriginSubGraph + ? profile.forSubGraph(breadcrumbOriginSubGraph)?.displayName ?? breadcrumbOriginSubGraph + : null; + // Codex Bug C — gate the `entities`-driven chip-count path on a // fully-loaded memory snapshot. While `useMemoryEntities` is mid- // hydration or a layer query is in-flight, `entityList` is partial @@ -715,9 +853,11 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { const chipBarEntities = memoryReady ? rawMemory.entityList : undefined; const activePage = selectedEntity ? 'entity' - : activeSubGraph - ? 'subgraph' - : activeLayer; + : selectedAssertion + ? 'assertion' + : activeSubGraph + ? 'subgraph' + : activeLayer; const participantsForCurrentGraph = participantsState.contextGraphId === cg.id ? participantsState.list : []; @@ -735,8 +875,14 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { handleSelectSubGraph(null)} + detailLabel={breadcrumbDetailLabel} + originLayer={breadcrumbOrigin?.activeLayer ?? null} + originSubGraph={breadcrumbOriginSubGraph} + originSubGraphDisplayName={breadcrumbOriginSubGraphName} + onOverview={handleBreadcrumbOverview} + onRestoreOrigin={handleDetailClose} /> {/* Layer Switcher — always visible now. Clicking a layer from within @@ -759,9 +905,28 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { allEntities={detailEntities} allTriples={detailTriples} onNavigate={handleNavigate} - onClose={handleDetailClose} contextGraphId={contextGraphId} onRefresh={rawMemory.refresh} + onOpenAgent={openAgent} + /> + )} + + {/* S4 — Assertion detail overlay. Peer of the entity detail + (mutually exclusive); the entity branch takes precedence so + opening an entity FROM the assertion view replaces it. */} + {selectedAssertion && !selectedEntity && ( + handleNavigate(uri, undefined, layer)} + onComplete={rawMemory.refresh} + onOpenAgent={openAgent} /> )} @@ -769,7 +934,7 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) { First-class peer of the layer views; the page identity, intro and chip row are shared with the All / Subgraphs-overview state (rendered below in the graph-overview branch). */} - {activeSubGraph && !selectedEntity && ( + {activeSubGraph && !selectedEntity && !selectedAssertion && ( <> )} - {!activeSubGraph && activeLayer === 'query' && !selectedEntity && ( + {!activeSubGraph && activeLayer === 'query' && !selectedEntity && !selectedAssertion && ( )} {/* Layer Detail Views */} - {!activeSubGraph && (activeLayer === 'wm' || activeLayer === 'swm' || activeLayer === 'vm') && !selectedEntity && ( + {!activeSubGraph && (activeLayer === 'wm' || activeLayer === 'swm' || activeLayer === 'vm') && !selectedEntity && !selectedAssertion && ( <> handleLayerTabChange(activeLayer, tab)} diff --git a/packages/node-ui/src/ui/views/project/components.tsx b/packages/node-ui/src/ui/views/project/components.tsx index 1d15c8620..1a68cf022 100644 --- a/packages/node-ui/src/ui/views/project/components.tsx +++ b/packages/node-ui/src/ui/views/project/components.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense } from 'react'; import type { ReactNode } from 'react'; +import type { AssertionState } from '@origintrail-official/dkg-core'; import { useFetch } from '../../hooks.js'; import { api } from '../../api-wrapper.js'; import { encodeDocTabId, resolveDocRef } from '../../lib/doc-tab-id.js'; @@ -9,17 +10,20 @@ import { listAssertions, promoteAssertion, describePromoteResult, describePromoteError, publishSharedMemory, listSwmEntities, executeQuery, writeProfileQueryCatalog, - fetchSubGraphs, - type AgentIdentity, type AssertionInfo, type PendingJoinRequest, type PromoteOutcome, type PublishResult, type SubGraphInfo, + fetchSubGraphs, fetchAssertionTriples, + type AgentIdentity, type AssertionInfo, type AssertionStateInfo, + type PendingJoinRequest, type PromoteOutcome, type PublishResult, type SubGraphInfo, } from '../../api.js'; import { ImportFilesModal } from '../../components/Modals/ImportFilesModal.js'; import { ShareProjectModal } from '../../components/Modals/ShareProjectModal.js'; import { useMemoryEntities, + buildMemoryEntities, canonicalEntityUri, isFirstClassEntity, type TrustLevel, type MemoryEntity, type Triple, type LayeredTriple, } from '../../hooks/useMemoryEntities.js'; +import { useAssertionState } from '../../hooks/useAssertionState.js'; import { decodeRdfStringLiteral } from '../../../rdf-literal.js'; import { useProjectProfile, ProjectProfileContext, useProjectProfileContext, @@ -62,6 +66,8 @@ import { matchesSearch, humanizeLabel, layerNoun, useLayerTriples, useCanonicalTriples, applyCanonicalAdmission, filterTriplesToEntities, admitTripleForScope, entityTimestamp, formatRelativeTime, formatTimelineBucket, formatTrailTimestamp, + canPromoteAssertion, assertionSubgraphLine, buildAssertionTrail, + assertionEmptyStateCopy, buildBreadcrumbHops, type LayerView, type LayerContentTab, type KAPane, type SubGraphTab, type SubGraphEntitySort, } from './helpers.js'; @@ -512,59 +518,108 @@ export function LayerSwitcher({ active, counts, onSwitch, onShare, onImport, onR export function ProjectHeaderStrip({ cg, profile, + activeLayer, activeSubGraph, - onClearSubGraph, + detailLabel, + originLayer, + originSubGraph, + originSubGraphDisplayName, + onOverview, + onRestoreOrigin, }: { cg: { id: string; name?: string; description?: string }; profile: ReturnType; + /** Current layer route (drives the middle hop when no subgraph). */ + activeLayer: LayerView; activeSubGraph: ReturnType['forSubGraph'] extends (s: string) => infer R ? R | null : null; - onClearSubGraph: () => void; + /** Open detail's name (entity / assertion), or null when none is open. */ + detailLabel?: string | null; + /** Codex round-8 (8-2) — the M2 ORIGIN's layer/subgraph (from the + * detail-origin snapshot). When a detail is open the middle hop labels + * the origin, not the current location (after an M2(b) follow they + * diverge). Null/undefined when no detail is open. */ + originLayer?: LayerView | null; + originSubGraph?: string | null; + originSubGraphDisplayName?: string | null; + /** Navigate to the Context Graph overview (first-hop click). */ + onOverview: () => void; + /** Restore the M2 origin — close the open detail back to where it was + * opened (middle-hop click when a detail is open). */ + onRestoreOrigin: () => void; }) { const name = cg.name || profile.displayName || cg.id; + // S5 — the breadcrumb is the persistent navigation + back-affordance. + // It replaces the prior project-name + subgraph-chip rendering AND the + // old `.v10-ka-back` button (deleted on the detail views). + const hops = buildBreadcrumbHops({ + contextGraphName: name, + activeLayer, + activeSubGraph: activeSubGraph?.slug ?? null, + subGraphDisplayName: activeSubGraph?.displayName ?? activeSubGraph?.slug ?? null, + detailLabel, + originLayer, + originSubGraph, + originSubGraphDisplayName, + }); + // Codex round-9 (9-2) — the strip chrome (tint + description) must track + // the SAME origin the breadcrumb label does while a detail is open. The + // 8-2 fix made the middle hop label the origin; pre-9-2 the chrome still + // read CURRENT activeSubGraph, so after an M2(b) follow the tint / + // description named the followed-into subgraph while the breadcrumb said + // origin. Drive both from ONE predicate (`detailOpen`, the same gate the + // breadcrumb label uses) so they can't re-diverge. Resolve the origin + // subgraph OBJECT from its slug via `profile.forSubGraph` (don't thread + // more scalar props). Degradation (ux-confirmed): origin had no subgraph + // (overview / non-subgraph layer) → fall back to profile.primaryColor + + // cg.description (today's behaviour); no detail open → current + // activeSubGraph; no-cross-follow → origin == current → no-op. + const detailOpen = !!detailLabel; + // When a detail is open, chrome tracks the ORIGIN: the origin subgraph + // binding if the origin had one, otherwise NO subgraph (→ the fallback + // below uses profile.primaryColor + cg.description). Critically, an + // overview / non-subgraph origin must NOT fall back to the CURRENT + // (followed-into) subgraph — that's the very divergence 9-2 fixes. When + // no detail is open, chrome derives from the current activeSubGraph. + const chromeSubGraph = detailOpen + ? (originSubGraph ? profile.forSubGraph(originSubGraph) ?? null : null) + : activeSubGraph; + const description = chromeSubGraph?.description ?? cg.description; return (
- - {activeSubGraph ? ( - <> - - - - {activeSubGraph.icon ?? '•'} - - {activeSubGraph.displayName ?? activeSubGraph.slug} - - {activeSubGraph.description && ( - - {activeSubGraph.description} - - )} - - ) : ( - cg.description && ( - - {cg.description} - - ) + + {description && ( + + {description} + )}
); @@ -2076,6 +2131,7 @@ export function LayerContent({ activeTab, onTabChange, onSelectEntity, + onSelectAssertion, onNodeClick, footer, swmAttribution, @@ -2089,6 +2145,10 @@ export function LayerContent({ activeTab: LayerContentTab; onTabChange: (tab: LayerContentTab) => void; onSelectEntity: (uri: string) => void; + /** S4 — open an assertion's detail view from the Assertions subtab. + * Codex round-11 (11-1) — carries the source layer through to the + * detail view (seeds badge/tone without inventing 'wm'). */ + onSelectAssertion?: (assertion: AssertionInfo, sourceLayer: 'wm' | 'swm') => void; onNodeClick?: (node: any) => void; footer?: React.ReactNode; /** Codex Code6 (PR #656) — optional shared SWM attribution result @@ -2191,6 +2251,7 @@ export function LayerContent({ layer={layer} onComplete={memory.refresh} scrollKey={`layer:${layer}:assertions`} + onSelectAssertion={onSelectAssertion} />
)} @@ -2948,24 +3009,26 @@ export function ContextGraphQueryView({ contextGraphId }: { contextGraphId: stri ); } -// Small helper: compute unique triples for a given layer slice of memory. - -export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey }: { - contextGraphId: string; - layer: 'wm' | 'swm'; - onComplete: () => void; - scrollKey?: string; -}) { - const { data: assertions, loading, refresh } = useFetch( - () => listAssertions(contextGraphId, layer), - [contextGraphId, layer], - 0 - ); +// ─── Shared assertion-promote action ──────────────────────── +// The single-row promote/publish flow is invoked from BOTH the +// `AssertionsList` rows AND the S4 `AssertionDetailView` Promote CTA. +// Lifting it into one hook (rather than duplicating the daemon call + +// busy/result/error bookkeeping in two places) keeps the two call sites +// in lockstep — e.g. the PR #710 sub-graph-slug threading fix stays in +// one place. `busy` is keyed on the row's `graphUri` (PR #710 Fix D — +// a root + sub-graph pair can share a `name`), with `'__all__'` reserved +// for the list-level promote-all action. +export function useAssertionPromote( + contextGraphId: string, + layer: 'wm' | 'swm', + onComplete: () => void, + onAfter?: () => void, +) { const [busy, setBusy] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); - const handlePromote = useCallback(async (assertion: AssertionInfo) => { + const promoteOne = useCallback(async (assertion: AssertionInfo) => { // PR #710 Fix D — busy / React keys must use `graphUri`, not // `name`. A root + sub-graph pair can share a name and would // otherwise both highlight as busy on a single click. `graphUri` @@ -2990,17 +3053,17 @@ export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey }: await publishSharedMemory(contextGraphId, roots); setResult('Published to Verifiable Memory'); } - refresh(); onComplete(); + onAfter?.(); } catch (err: any) { const typed = describePromoteError(assertion.name, err); setError(typed ? typed.message : (err?.message ?? 'Action failed')); } finally { setBusy(null); } - }, [contextGraphId, layer, refresh, onComplete]); + }, [contextGraphId, layer, onComplete, onAfter]); - const handlePromoteAll = useCallback(async () => { + const promoteAll = useCallback(async (assertions: AssertionInfo[] | null | undefined) => { if (!assertions?.length) return; setBusy('__all__'); setResult(null); @@ -3033,15 +3096,43 @@ export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey }: await publishSharedMemory(contextGraphId, roots); setResult('Published all to Verifiable Memory'); } - refresh(); onComplete(); + onAfter?.(); } catch (err: any) { const typed = describePromoteError(currentAssertion ?? 'selected assertion', err); setError(typed ? typed.message : (err?.message ?? 'Action failed')); } finally { setBusy(null); } - }, [assertions, contextGraphId, layer, refresh, onComplete]); + }, [contextGraphId, layer, onComplete, onAfter]); + + return { busy, result, error, promoteOne, promoteAll }; +} + +export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey, onSelectAssertion }: { + contextGraphId: string; + layer: 'wm' | 'swm'; + onComplete: () => void; + scrollKey?: string; + /** S4 — open the assertion detail view for a clicked row. Optional so + * call sites that don't wire detail navigation (none today) degrade + * to a non-clickable list. + * Codex round-11 (11-1) — forwards THIS list's `layer` as the source + * layer so the detail view can seed its badge + graph tone immediately + * (no `?? 'wm'` invention during hydrate). The list is the authoritative + * source of the layer: an assertion only ever opens from a WM/SWM list. */ + onSelectAssertion?: (assertion: AssertionInfo, sourceLayer: 'wm' | 'swm') => void; +}) { + const { data: assertions, loading, refresh } = useFetch( + () => listAssertions(contextGraphId, layer), + [contextGraphId, layer], + 0 + ); + const { busy, result, error, promoteOne, promoteAll } = + useAssertionPromote(contextGraphId, layer, onComplete, refresh); + + const handlePromote = promoteOne; + const handlePromoteAll = useCallback(() => promoteAll(assertions), [promoteAll, assertions]); const scrollRootStyle = { flex: 1, overflow: 'auto' } as const; @@ -3094,7 +3185,29 @@ export function AssertionsList({ contextGraphId, layer, onComplete, scrollKey }: {result &&
✓ {result}
} {error &&
✕ {error}
} {assertions.map(a => ( -
+
onSelectAssertion(a, layer) : undefined} + onKeyDown={onSelectAssertion + ? ev => { + // Codex round-1 — only the ROW itself activates on + // Enter/Space. `onKeyDown` bubbles, so without this guard + // pressing Enter/Space on the nested promote/publish + // button would ALSO open the detail (double action). + if (ev.target !== ev.currentTarget) return; + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + onSelectAssertion(a, layer); + } + } + : undefined} + >
{a.name}
@@ -3131,6 +3244,7 @@ export function LayerDetailView({ memory, onNodeClick, onSelectEntity, + onSelectAssertion, contextGraphId, activeTab, onTabChange, @@ -3140,6 +3254,10 @@ export function LayerDetailView({ memory: ReturnType; onNodeClick: (node: any) => void; onSelectEntity: (uri: string) => void; + /** S4 — open an assertion's detail view from the Assertions subtab. + * Codex round-11 (11-1) — carries the source layer (the list's layer) + * so the detail view seeds its badge/tone without inventing 'wm'. */ + onSelectAssertion?: (assertion: AssertionInfo, sourceLayer: 'wm' | 'swm') => void; contextGraphId: string; activeTab: LayerContentTab; onTabChange: (tab: LayerContentTab) => void; @@ -3179,6 +3297,7 @@ export function LayerDetailView({ activeTab={activeTab} onTabChange={onTabChange} onSelectEntity={onSelectEntity} + onSelectAssertion={onSelectAssertion} onNodeClick={onNodeClick} swmAttribution={swmAttribution} /> @@ -3531,12 +3650,11 @@ export function VerifyOnDkgButton({ ); } -export function KADetailView({ entity, allEntities, allTriples, onNavigate, onClose, contextGraphId, onRefresh, onOpenAgent }: { +export function KADetailView({ entity, allEntities, allTriples, onNavigate, contextGraphId, onRefresh, onOpenAgent }: { entity: MemoryEntity; allEntities: Map; allTriples: Triple[]; onNavigate: (uri: string) => void; - onClose: () => void; contextGraphId: string; onRefresh: () => void; onOpenAgent?: (uri: string) => void; @@ -3660,7 +3778,8 @@ export function KADetailView({ entity, allEntities, allTriples, onNavigate, onCl return (
- + {/* S5 — no back button here; the persistent breadcrumb in + ProjectHeaderStrip is the sole back-affordance. */}
{detailNoun}
@@ -3957,6 +4076,468 @@ export function TrailEvent({ ); } +// ─── S4 Assertion Detail View ─────────────────────────────── +// A clickable assertion's detail page — the twin of `KADetailView`: +// Entities / Triples / Graph tabs on the left, an ASSERTION INFO + +// LIFECYCLE metadata rail on the right. The assertion's content is +// scoped to its own data graph (resolved from `_meta` via +// `useAssertionState`), so the panes show exactly this assertion, not +// the whole layer. + +/** Lifecycle trail for the assertion detail rail. Reuses the + * `.v10-ka-timeline` / `.v10-ka-event*` markup family so it reads as + * one visual family with the entity-detail Provenance Trail (S13). The + * stage matching the current `state` carries the `is-current` halo. + * While the state is hydrating (`stage` array all-neutral, no current) + * or failed, the caller passes a `hint` rendered quietly at the base. */ +function AssertionLifecycleTrail({ + state, + hint, +}: { + state: AssertionState | null | undefined; + hint?: string | null; +}) { + const stages = buildAssertionTrail(state); + return ( +
+ {stages.map(stage => ( +
+
+
+ {stage.title} +
+
+ ))} + {hint && ( +
{hint}
+ )} +
+ ); +} + +export function AssertionDetailView({ + assertion, + sourceLayer, + contextGraphId, + onNavigate, + onComplete, + onOpenAgent, +}: { + assertion: AssertionInfo; + /** Codex round-11 (11-1) — the layer the assertion's list row came from + * ('wm' | 'swm'). Seeds the badge + graph tone immediately so the header + * shows the KNOWN-true layer during the state hydrate, instead of the old + * `?? 'wm'` invention that mis-tinted SWM assertions and contradicted the + * "state unavailable" error. `fetchAssertionState.layer` refines it once + * resolved (e.g. created→promoted). */ + sourceLayer: 'wm' | 'swm'; + contextGraphId: string; + /** Open an entity from this assertion in the entity-detail view. + * Codex round-5 — forwards the assertion's resolved `layer` so the + * follow-on entity detail stays scoped to the assertion's layer + * (a WM-assertion entity that ALSO exists in SWM/VM would otherwise + * open the global/canonical detail). Feeds M2's existing + * `handleNavigate(uri, _, layerContext)` channel; no contract reshape. */ + onNavigate: (uri: string, layer?: 'wm' | 'swm' | 'vm') => void; + /** Refresh the underlying memory after a successful promote. */ + onComplete: () => void; + onOpenAgent?: (uri: string) => void; +}) { + // No back button by design (S4 + S5 lock): the breadcrumb in the + // persistent ProjectHeaderStrip is the sole back-affordance; the + // origin restore lives in ProjectView's `handleDetailClose`. + const [pane, setPane] = useState('content'); + const theme = useLayoutStore(s => s.theme); + const profile = useProjectProfileContext(); + const agents = useAgentsContext(); + + // Codex round-1 — a promote FROM this detail view flips the assertion's + // state in `_meta` but leaves `assertion.graphUri` unchanged, so neither + // the state fetch nor the triples fetch would re-run (their deps are + // URI-keyed). Bump this nonce on promote success to force both to + // refetch, so the trail / badge / Promote-CTA reflect the new state + // without a remount. + const [refreshNonce, setRefreshNonce] = useState(0); + + // Lifecycle state (α verdict lock-b — lazy, on mount). Resolves the + // assertion's data-graph URI + author DID alongside the state. + const { data: stateInfo, loading: stateLoading, error: stateError } = + useAssertionState(contextGraphId, assertion.graphUri, refreshNonce); + // Codex round-11 (11-1) — seed the layer from the KNOWN-true `sourceLayer` + // (the list row's layer) during the hydrate, and refine to the resolved + // `_meta` layer once it arrives (e.g. a `created` WM assertion the user + // just promoted resolves to `swm`). The old `?? 'wm'` invention is DELETED: + // it mis-tinted SWM assertions as Working during load and contradicted the + // "state unavailable" error. `stateInfo?.layer` wins when present; else the + // real source layer — never an invented default. + const layer = stateInfo?.layer ?? sourceLayer; + const layerConfig = LAYER_CONFIG[layer]; + const trustLevel: TrustLevel = LAYER_CONFIG[layer].trustLevel; + + // Promote CTA — reuses the SAME shared promote hook the list rows use + // (no duplication of the daemon call / busy bookkeeping). Only the WM + // single-row path is reachable here; the visibility gate below pins it + // to `created && wm`. + const { busy, error: promoteError, promoteOne } = + useAssertionPromote( + contextGraphId, + layer === 'swm' ? 'swm' : 'wm', + onComplete, + // onAfter fires on promote SUCCESS only — bump the nonce so this + // detail view re-fetches its own state + triples (the state flipped + // created → promoted in `_meta`). + () => setRefreshNonce(n => n + 1), + ); + const showPromote = canPromoteAssertion(stateInfo?.state, layer); + + // The assertion's own triples (scoped to its data graph). Fetched once + // the `_meta` lookup yields the data-graph URI. Tagged with the + // assertion's trust level so `buildMemoryEntities` classifies them. + const assertionGraph = stateInfo?.assertionGraph; + const [triples, setTriples] = useState([]); + const [triplesLoading, setTriplesLoading] = useState(false); + // Codex round-6 — a triple-FETCH failure must be DISTINCT from a + // genuinely-empty assertion (loading / error / empty are three states, + // not two — mirrors the lifecycle state's hydrating/error edge). + // Previously the `.catch` collapsed to `[]`, so a backend/query error + // showed the same empty-state copy as valid-but-empty data. + const [triplesError, setTriplesError] = useState(false); + useEffect(() => { + if (!assertionGraph) { + // No data graph to read (promoted assertion — its triples moved to + // /_shared_memory — or a legacy record without dkg:assertionGraph). + // AssertionDetailView is NOT keyed in ProjectView, so this instance + // is reused across assertion switches: a prior in-flight fetch was + // cancelled but its `.finally` is gated by `cancelled`, so we MUST + // reset `triplesLoading` here or the Entities pane sticks on + // "Loading assertion entities…" forever and the empty-state never + // renders. + setTriples([]); + setTriplesLoading(false); + setTriplesError(false); + return; + } + let cancelled = false; + setTriplesLoading(true); + setTriplesError(false); + fetchAssertionTriples(contextGraphId, assertionGraph) + .then(rows => { if (!cancelled) { setTriples(rows); setTriplesError(false); } }) + .catch(() => { if (!cancelled) { setTriples([]); setTriplesError(true); } }) + .finally(() => { if (!cancelled) setTriplesLoading(false); }); + return () => { cancelled = true; }; + // `refreshNonce` re-runs the triples fetch after a promote (the data + // graph URI string is unchanged on promote, so it alone wouldn't). + }, [contextGraphId, assertionGraph, refreshNonce]); + + const layeredTriples = useMemo( + () => triples.map(t => ({ ...t, layer: trustLevel })), + [triples, trustLevel], + ); + const entities = useMemo(() => buildMemoryEntities(layeredTriples), [layeredTriples]); + // Root entities of the assertion: real first-class entities (drop the + // synthesised stubs for pure-object URIs the builder adds). + const rootEntities = useMemo( + () => [...entities.values()].filter(isFirstClassEntity), + [entities], + ); + const entityCount = rootEntities.length; + const tripleCount = triples.length; + + // Codex round-11 (11-2) — header counts follow the hydrating contract: + // show KNOWN-true values immediately, `…` for the genuinely-unknown, NEVER + // a 0-until-resolved that reads as a false "this assertion is empty" claim. + // Counts are TRUSTWORTHY only once the triples fetch has settled cleanly + // (state resolved, not loading, not errored). `triplesError` keeps the + // placeholder — an operational failure must not surface as `0`. + const countsResolved = !stateLoading && !triplesLoading && !triplesError; + // entityCount is NOT carried on the row (genuinely unknown until the + // fetch) → placeholder during load/error; the real (possibly-zero) count + // only once resolved. + const entityCountLabel = countsResolved ? `${entityCount}` : '…'; + // tripleCount MAY be seeded from the row (`AssertionInfo.tripleCount`): + // prefer the resolved fetch count; else the row hint if present; else `…`. + // Never `0` before the fetch settles. + const tripleCountLabel = countsResolved + ? `${tripleCount}` + : assertion.tripleCount != null + ? `${assertion.tripleCount}` + : '…'; + + const author = stateInfo?.createdBy ? agents?.get(stateInfo.createdBy) ?? null : null; + const authorUri = stateInfo?.createdBy ?? null; + const subgraphLine = assertionSubgraphLine(assertion.subGraph); + + // Right-rail badge: `{glyph} {trustLabel} · {state}` once resolved; + // while hydrating the `· state` suffix is omitted (no flash). + const badgeState = stateLoading ? null : stateInfo?.state ?? null; + // Trail hint for the fetch-error / unavailable case (state couldn't + // load) — quiet danger text at the trail base; never on the happy path. + const trailHint = !stateLoading && (stateError || !stateInfo) + ? "Lifecycle state couldn't load" + : null; + + const graphOptions = useMemo(() => { + const focalColor = TRUST_COLORS[trustLevel]; + return { + labelMode: 'humanized' as const, + renderer: '2d' as const, + labels: memoryGraphLabels({ minZoomForLabels: 0.2 }), + style: { + namespaceColors: neutraliseBuiltinNamespaces(focalColor), + defaultNodeColor: focalColor, + defaultEdgeColor: '#475569', + edgeWidth: 1.0, + fontSize: 11, + }, + hexagon: { baseSize: 7, minSize: 4, maxSize: 10, scaleWithDegree: true }, + focus: { maxNodes: 500, hops: 999 }, + }; + }, [trustLevel]); + const graphViewConfig = useMemo(() => ({ + name: `assertion-${assertion.graphUri}-${theme}`, + palette: theme, + }), [assertion.graphUri, theme]); + + return ( +
+
+
+
+ ▤ {assertion.name} +
+
+ assertion · {entityCountLabel} entities · {tripleCountLabel} triples +
+ {subgraphLine &&
{subgraphLine}
} +
+
+ {/* Codex round-2 — a `discarded` assertion is LAYERLESS in the + core model: render a neutral muted badge (just `discarded`, + no layer glyph / no layer name / never VM-green). This is the + render-side guard for the unreachable-today `discarded → vm` + api.ts fallback (discarded isn't in any listAssertions, so + the detail never opens for it; this hardens our own code + against a future wiring). */} + {badgeState === 'discarded' ? ( + discarded + ) : ( + + {layerConfig.icon} {layerConfig.trustLabel}{badgeState ? ` · ${badgeState}` : ''} + + )} + {showPromote && ( + + )} +
+
+ +
+ {/* Left pane: Entities / Triples / Graph */} +
+
+ + + +
+ + {/* Codex round-9 (9-1) — the triples loading / error treatment is + SHARED across all three tabs. Pre-fix only the Entities pane + gated on triplesLoading/triplesError; the Triples tab rendered a + "0 of 0" table and the Graph tab fell through to "No assertion + graph data" while the fetch was pending OR after it errored, + misreporting unknown/error as real empty content. Hoist the gate: + while loading (state still hydrating OR triples in-flight with + nothing yet to show) render the loading treatment in whichever + tab is active; on a fetch error render the error state; only once + resolved does each tab render its real content. Keyed on + `triples.length === 0` (not just the loading flag) so a + post-promote REFETCH that still holds the prior rows doesn't + blank existing content across the tabs. The Entities pane keeps + its own state-keyed EMPTY-state (8-1) inside the resolved branch + — empty is distinct from loading/error. */} + {(stateLoading || triplesLoading) && triples.length === 0 ? ( +
+
Loading assertion entities...
+
+ ) : triplesError ? ( + // A fetch ERROR is DISTINCT from an empty assertion: an + // operational failure must not masquerade as valid-but-empty + // data. Mirrors the lifecycle state's error edge (quiet, + // explicit "couldn't load") — now consistent across all 3 tabs. +
+ +
+ ) : ( + <> + {pane === 'content' && ( +
+ {rootEntities.length === 0 ? ( + (() => { + // ux §4.7.1 locked copy — keyed off the lifecycle state + // so the noun + forward path match the destination layer + // (created / promoted→SWM / published|finalized→VM / + // discarded). See `assertionEmptyStateCopy`. + const copy = assertionEmptyStateCopy(stateInfo?.state); + return ( + + ); + })() + ) : ( + rootEntities.map(e => { + const { icon, type } = entityMeta(e, profile); + return ( + + ); + }) + )} +
+ )} + + {pane === 'triples' && ( + // Plain s/p/o table, assertion-scoped — NO filter pills + // (an assertion has singular scope, so cross-layer / + // cross-subgraph pills would have nothing to filter). +
+ + + + + + {triples.slice(0, 200).map((t, i) => ( + + + + {/* Codex round-2: literal objects from + `fetchAssertionTriples`/`rawBindingValue` carry the + FULL N-Triples form (`"v"^^` / `"v"@lang`, + escaped) — required by the Graph tab's RdfGraph + + buildMemoryEntities. For DISPLAY, decode through the + shared `decodeRdfStringLiteral` (the same helper + `humanizeLabel` uses) so the datatype/lang suffix is + dropped and the body is unescaped — NOT a naive + outer-quote strip, which left `"42"^^` → + `42"^^`. IRIs render via `shortPred`. */} + + + ))} + +
SubjectPredicateObject
{shortPred(t.subject)}{shortPred(t.predicate)}{t.object.startsWith('"') ? decodeRdfStringLiteral(t.object).slice(0, 60) : shortPred(t.object)}
+
+ {Math.min(triples.length, 200)} of {triples.length} triples shown +
+
+ )} + + {pane === 'graph' && ( + // S4 ships with current viewport treatment. S7 (Track B, pending) + // will land the full-width / tall / expandable viewport upgrade + // for KADetailView's Graph tab AND AssertionDetailView's Graph + // tab simultaneously — same component family. + ( + layeredTriples.length > 0 ? ( + Loading graph...}> + n?.id && onNavigate(n.id, layer)} + initialFit + /> + + ) : ( +
No assertion graph data
+ ) + )} + /> + )} + + )} +
+ + {/* Right pane: ASSERTION INFO + LIFECYCLE trail */} +
+
+
Assertion Info
+
+ State + + {stateLoading + ? 'loading…' + : stateInfo + // Discarded is layerless — no `(Layer)` suffix. + ? (stateInfo.state === 'discarded' + ? 'discarded' + : `${stateInfo.state} (${layerConfig.trustLabel})`) + : 'state unavailable'} + +
+ {(author || authorUri) && ( +
+ Created by + + + +
+ )} + {assertion.subGraph && ( +
+ Subgraph + {assertion.subGraph} +
+ )} + {assertionGraph && ( +
+ Graph + {assertionGraph} +
+ )} +
+ +
+
Lifecycle
+ +
+ + {promoteError && ( +
✕ {promoteError}
+ )} +
+
+
+ ); +} + // ─── Subgraph Explorer header (page identity + permanent intro) ──── // Shared between the All / Subgraphs-overview state and every chip diff --git a/packages/node-ui/src/ui/views/project/helpers.ts b/packages/node-ui/src/ui/views/project/helpers.ts index eb27013d9..1a54c7f09 100644 --- a/packages/node-ui/src/ui/views/project/helpers.ts +++ b/packages/node-ui/src/ui/views/project/helpers.ts @@ -1,4 +1,5 @@ import { useMemo } from 'react'; +import type { AssertionState } from '@origintrail-official/dkg-core'; import { useMemoryEntities, canonicalEntityUri, @@ -893,3 +894,351 @@ export function formatRelativeTime(ts: number): string { // Older than a month — show the date so it's still useful at a glance. return new Date(ts).toISOString().slice(0, 10); } + +// ─── S4 Assertion detail view ──────────────────────────────── +// An assertion's lifecycle is a forward-only chain +// created (WM) → promoted (SWM) → published (VM) → finalized +// with `discarded` as a terminal off-ramp reachable only from +// `created` (mirrors `AssertionState` in `@origintrail-official/dkg-core`). +// The detail view's right-rail trail reuses the entity-detail +// Provenance Trail markup family (`.v10-ka-timeline` / `.v10-ka-event*`), +// so the assertion stage must map onto the SAME tone vocabulary the +// trail dots already paint: `created` (WM slate), `shared` (SWM amber), +// `verified` (VM green), plus a `discarded` muted tone. + +/** Lifecycle layer of an assertion, derived from its `dkg:state`. */ +export type AssertionDetailLayer = 'wm' | 'swm' | 'vm'; + +/** + * Map an assertion lifecycle `state` to the trail-dot tone class used + * by `.v10-ka-event-dot.{created,shared,verified,discarded}`. The tone + * is keyed on the TRUST LAYER the state corresponds to (not the + * state-machine name) so the trail's colour reads consistently with the + * rest of the trust-coding system: `created` → WM slate, `promoted` → + * SWM amber, `published`/`finalized` → VM green, `discarded` → muted. + * Unknown states fall back to the WM `created` tone (the safest neutral). + */ +export function assertionStageTone( + state: AssertionState | null | undefined, +): 'created' | 'shared' | 'verified' | 'discarded' { + switch (state) { + case 'created': return 'created'; + case 'promoted': return 'shared'; + case 'published': + case 'finalized': return 'verified'; + case 'discarded': return 'discarded'; + default: return 'created'; + } +} + +/** + * Promote CTA visibility predicate for the assertion detail header. + * A per-assertion forward action exists ONLY for a `created` WM + * assertion (`promote` is assertion-scoped — §2.3); every later state + * has no further per-assertion forward action (SWM→VM publish is + * entity-scoped). Returns false while the state is still hydrating + * (`state == null`) so the CTA never flashes in then hides. + */ +export function canPromoteAssertion( + state: AssertionState | null | undefined, + layer: AssertionDetailLayer | null | undefined, +): boolean { + return state === 'created' && layer === 'wm'; +} + +/** + * Header line-3 content for the assertion detail view. The mockup folded + * the sub-graph into the line-2 stat row, but a root-scoped assertion + * has no sub-graph — splitting it onto a conditional third line avoids a + * trailing `· subgraph: undefined` cliff. Returns `null` (render nothing) + * for root assertions; the `subgraph: ` string only when truthy. + */ +export function assertionSubgraphLine( + subGraph: string | null | undefined, +): string | null { + const slug = subGraph?.trim(); + return slug ? `subgraph: ${slug}` : null; +} + +/** One stage of the assertion lifecycle trail. */ +export interface AssertionTrailStage { + /** State this stage represents. */ + state: AssertionState; + /** Human title rendered as the event title. */ + title: string; + /** Trail-dot tone class (`created` / `shared` / `verified` / `discarded`). */ + tone: 'created' | 'shared' | 'verified' | 'discarded'; + /** True for the stage matching the assertion's CURRENT state ("you are here"). */ + isCurrent: boolean; +} + +/** + * Build the ordered lifecycle-trail stages for an assertion detail view. + * + * - A live (non-discarded) assertion always renders the full forward + * chain `created ▸ promoted ▸ published ▸ finalized` so the user sees + * the whole pipeline and where the assertion currently sits; the stage + * matching `state` carries `isCurrent` (the `is-current` halo). + * - A `discarded` assertion renders a SINGLE muted `discarded` event + * (not `created ▸ discarded`) — the data model carries no distinct + * `discardedAt`, so a single terminal event is the natural treatment + * and needs no connector-mask CSS. + * - While the state is still hydrating (`state == null`) the full chain + * renders: each stage at its STATIC stage-tone (created=slate / + * promoted=amber / published+finalized=green — a fixed pipeline legend, + * NOT a state signal) with NO `is-current` marker. The absent halo is + * the load-bearing "no false you-are-here during hydrate" guarantee + * (ux §4.7.1); the static tones are intentionally kept (graying them + * would couple the legend to fetch state for a sub-second flash and + * pop colour on resolve). + */ +export function buildAssertionTrail( + state: AssertionState | null | undefined, +): AssertionTrailStage[] { + if (state === 'discarded') { + return [{ state: 'discarded', title: 'Discarded', tone: 'discarded', isCurrent: true }]; + } + const chain: Array<{ state: AssertionState; title: string }> = [ + { state: 'created', title: 'Created in Working Memory' }, + { state: 'promoted', title: 'Promoted to Shared Working Memory' }, + { state: 'published', title: 'Published to Verifiable Memory' }, + { state: 'finalized', title: 'Finalized' }, + ]; + return chain.map(stage => ({ + state: stage.state, + title: stage.title, + tone: assertionStageTone(stage.state), + isCurrent: state != null && stage.state === state, + })); +} + +/** + * Empty-state copy for the assertion detail Entities pane when the + * assertion has zero entities, keyed off `dkg:state` (ux-lead's locked + * §4.7.1 copy — Codex round-3 finding 3). A single generic template was + * rejected because the noun changes at the VM boundary (entities → + * Knowledge Assets, §4.8) and because the forward path differs per state: + * - created → genuinely empty draft. + * - promoted → its entities moved to Shared Working Memory. + * - published / finalized → its entities are now Knowledge Assets in + * Verifiable Memory (post-publish the data graph is empty). + * - discarded → terminal; no forward path (forward-safety — discarded + * isn't list-reachable today, see the badge guard). + * Plain text, no links (S4 lock). Title is the constant "No entities in + * this assertion." for the live states; discarded gets a terminal title. + */ +export function assertionEmptyStateCopy( + state: AssertionState | null | undefined, +): { title: string; description: string } { + switch (state) { + case 'promoted': + return { + title: 'No entities in this assertion.', + description: 'This assertion was promoted — its entities now live in Shared Working Memory. Open the Shared Working Memory tab to view them.', + }; + case 'published': + case 'finalized': + return { + title: 'No entities in this assertion.', + description: 'This assertion was published — its entities are now Knowledge Assets in Verifiable Memory. Open the Verifiable Memory tab to view them.', + }; + case 'discarded': + return { + title: 'This assertion was discarded.', + description: 'This assertion was discarded.', + }; + case 'created': + default: + return { + title: 'No entities in this assertion.', + description: 'This assertion has no extracted entities.', + }; + } +} + +/** + * The primary (first non-`meta`) sub-graph slug an entity has triples + * in, or null when it lives only in the root bucket / meta. Mirrors the + * `SubGraphBadge` rule (lowest-rank binding wins; most entities live in + * exactly one sub-graph). Used by M2 option (b) to decide whether a + * cross-subgraph entity jump should switch `activeSubGraph`. + */ +export function primarySubGraphOf(entity: MemoryEntity | undefined | null): string | null { + if (!entity) return null; + for (const s of entity.subGraphs) { + if (s !== 'meta') return s; + } + return null; +} + +// ─── S5 Breadcrumb navigation ─────────────────────────────── +// `Context Graph › {Layer | Subgraph} › {Entity | Assertion}`. Lives +// inline in the persistent ProjectHeaderStrip. Hop content rules +// (§4.7.1): the middle hop is EITHER the layer full-name OR the subgraph +// displayName, never both (layer scope inside a subgraph is signalled by +// the in-page active-layer chip, not the breadcrumb). The trailing hop +// is the current location — a non-interactive span, three intentional +// styling differences from clickable hops ("you are here" without a +// label). + +/** Where a clicked breadcrumb hop navigates. */ +export type BreadcrumbTarget = + | 'overview' // the Context Graph overview (first hop) + | 'origin' // restore the M2 origin snapshot (close the open detail) + | 'current'; // the trailing hop — not interactive + +export interface BreadcrumbHop { + /** Stable React key. */ + key: string; + /** Truncatable display label. */ + label: string; + /** Full text for the unconditional `title=` tooltip. */ + title: string; + /** Navigation target; `'current'` hops render as a non-interactive span. */ + target: BreadcrumbTarget; +} + +const LAYER_FULL_NAME: Record = { + wm: 'Working Memory', + swm: 'Shared Working Memory', + vm: 'Verifiable Memory', +}; + +type MemoryLayerKey = 'wm' | 'swm' | 'vm'; + +/** + * Build the breadcrumb hops for the current ProjectView location. + * + * - `Context Graph` is always the first hop, clickable to overview + * (except when it is itself the current page — then it's the trailing + * `current` hop with no further hops). + * - The middle hop is the subgraph displayName when a subgraph page is + * active, otherwise the active layer's full name. Only one, never both. + * - The trailing hop (`current`) is the open detail's name (entity or + * assertion) when a detail is open; otherwise the middle hop itself is + * the current location. + * + * Navigation semantics are attached by the renderer via `target`: + * non-trailing hops are clickable; clicking the first hop goes to + * overview, clicking the middle hop (when a detail is open) restores the + * M2 origin (closes the detail back to where it was opened). + */ +export function buildBreadcrumbHops(input: { + /** The CG display name (first hop label). */ + contextGraphName: string; + activeLayer: LayerView; + /** Active subgraph slug, or null when not on a subgraph page. */ + activeSubGraph: string | null; + /** Active subgraph display name (falls back to slug). */ + subGraphDisplayName?: string | null; + /** Open detail's name, or null when no detail is open. */ + detailLabel?: string | null; + /** + * Codex round-8 (8-2) — the M2 ORIGIN's layer/subgraph. When a detail + * is open the middle hop's `target` is `'origin'` (clicking closes the + * detail back to where it was opened), so its LABEL must name the + * origin, not the current location. After an M2(b) cross-subgraph + * follow the current subgraph diverges from the origin, and labelling + * the middle hop with the CURRENT subgraph misnames the destination the + * click returns you to. Supplied only when a detail is open; when + * absent (or no detail), the middle hop falls back to current-scope + * labelling (then current == location, which is correct). If the origin + * had no middle (opened from overview — `originLayer === 'overview'` and + * `originSubGraph === null`), NO middle is synthesised (the 2-hop rule + * still holds). + */ + originLayer?: LayerView | null; + originSubGraph?: string | null; + originSubGraphDisplayName?: string | null; +}): BreadcrumbHop[] { + const { + contextGraphName, + activeLayer, + activeSubGraph, + subGraphDisplayName, + detailLabel, + originLayer, + originSubGraph, + originSubGraphDisplayName, + } = input; + const hops: BreadcrumbHop[] = []; + + // First hop is the CURRENT page only when we are on the overview with no + // subgraph and no open detail. + const onOverview = !activeSubGraph && activeLayer === 'overview' && !detailLabel; + if (onOverview) { + hops.push({ + key: 'cg', + label: contextGraphName, + title: contextGraphName, + target: 'current', + }); + return hops; + } + + // Middle hop — subgraph displayName OR layer full-name (never both). + const detailOpen = !!detailLabel; + // 8-2 — when a detail is open the middle hop labels the ORIGIN (where + // clicking returns you). The origin layer/subgraph is supplied by the + // caller from the M2 snapshot. When it isn't (e.g. no detail open, or a + // caller that doesn't thread it), fall back to the current location. + const haveOrigin = detailOpen && originLayer !== undefined; + const middleLayer = haveOrigin ? (originLayer ?? 'overview') : activeLayer; + const middleSubGraph = haveOrigin ? (originSubGraph ?? null) : activeSubGraph; + const middleSubGraphName = haveOrigin ? originSubGraphDisplayName : subGraphDisplayName; + let middle: { label: string; title: string } | null = null; + if (middleSubGraph) { + const name = middleSubGraphName?.trim() || middleSubGraph; + middle = { label: name, title: name }; + } else if (middleLayer === 'wm' || middleLayer === 'swm' || middleLayer === 'vm') { + const name = LAYER_FULL_NAME[middleLayer]; + middle = { label: name, title: name }; + } else if (middleLayer === 'graph-overview') { + middle = { label: 'Subgraphs', title: 'Subgraphs' }; + } else if (middleLayer === 'query') { + middle = { label: 'Query Catalogue', title: 'Query Catalogue' }; + } + + // First hop — Context Graph. + // Codex round-10 (10-2) — when a detail was opened from the OVERVIEW the + // breadcrumb is only 2 hops `[Context Graph › Detail]` (no middle), so the + // first hop is the SOLE back-affordance. Targeting 'overview' there would + // be a fresh top-of-overview nav that drops the captured scroll/tab; + // re-target it to 'origin' (→ onRestoreOrigin / handleDetailClose, which + // restores the overview origin correctly). The carve-out: in the 3-hop + // case (detail from a layer/subgraph) the first hop stays 'overview' — a + // genuine "go UP to the CG root", a DIFFERENT destination than the middle + // hop's 'origin' restore. The "no middle" check is the same condition that + // decides whether a middle hop exists, so one shared check drives both. + const firstHopRestoresOrigin = detailOpen && !middle; + hops.push({ + key: 'cg', + label: contextGraphName, + title: contextGraphName, + target: firstHopRestoresOrigin ? 'origin' : 'overview', + }); + + if (middle) { + hops.push({ + key: 'middle', + label: middle.label, + title: middle.title, + // When a detail is open the middle hop is clickable (restores the + // origin). When no detail is open the middle hop IS the current + // location. + target: detailOpen ? 'origin' : 'current', + }); + } + + // Trailing hop — the open detail's name (current location). + if (detailOpen) { + hops.push({ + key: 'detail', + label: detailLabel!, + title: detailLabel!, + target: 'current', + }); + } + + return hops; +} diff --git a/packages/node-ui/test/assertion-detail-helpers.test.ts b/packages/node-ui/test/assertion-detail-helpers.test.ts new file mode 100644 index 000000000..a5182be44 --- /dev/null +++ b/packages/node-ui/test/assertion-detail-helpers.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it } from 'vitest'; +import { + assertionStageTone, + canPromoteAssertion, + assertionSubgraphLine, + buildAssertionTrail, + assertionEmptyStateCopy, + buildBreadcrumbHops, + primarySubGraphOf, +} from '../src/ui/views/project/helpers.js'; +import type { MemoryEntity } from '../src/ui/hooks/useMemoryEntities.js'; + +function entity(subGraphs: string[]): MemoryEntity { + return { + uri: 'urn:e', label: 'E', types: [], trustLevel: 'working', + layers: new Set(['working']), subGraphs: new Set(subGraphs), + properties: new Map(), connections: [], + }; +} + +// S4 — pure helpers behind the assertion detail view. These pin the +// lifecycle-trail tone mapping (T01), the Promote CTA visibility +// predicate (T02), the header subgraph-line conditional (T03), and the +// trail-stage builder + `is-current` marker. + +describe('assertionStageTone — lifecycle state → trail-dot tone (T01)', () => { + it('maps each state onto the trust-layer tone vocabulary', () => { + expect(assertionStageTone('created')).toBe('created'); // WM slate + expect(assertionStageTone('promoted')).toBe('shared'); // SWM amber + expect(assertionStageTone('published')).toBe('verified'); // VM green + expect(assertionStageTone('finalized')).toBe('verified'); // VM-internal + expect(assertionStageTone('discarded')).toBe('discarded'); + }); + + it('falls back to the neutral `created` tone for null/undefined/unknown', () => { + expect(assertionStageTone(null)).toBe('created'); + expect(assertionStageTone(undefined)).toBe('created'); + expect(assertionStageTone('bogus' as any)).toBe('created'); + }); +}); + +describe('canPromoteAssertion — Promote CTA visibility predicate (T02)', () => { + // Truth table: the CTA shows ONLY for a `created` WM assertion. Every + // later state and every non-WM layer hides it (no further + // per-assertion forward action). Hydrating (null state) hides it too + // so it never flashes in then disappears. + it('is true ONLY for created + wm', () => { + expect(canPromoteAssertion('created', 'wm')).toBe(true); + }); + + it('is false for created in a non-WM layer', () => { + expect(canPromoteAssertion('created', 'swm')).toBe(false); + expect(canPromoteAssertion('created', 'vm')).toBe(false); + }); + + it('is false for every non-created state, even in wm', () => { + expect(canPromoteAssertion('promoted', 'wm')).toBe(false); + expect(canPromoteAssertion('published', 'wm')).toBe(false); + expect(canPromoteAssertion('finalized', 'wm')).toBe(false); + expect(canPromoteAssertion('discarded', 'wm')).toBe(false); + }); + + it('is false while the state/layer is still hydrating (null/undefined)', () => { + expect(canPromoteAssertion(null, 'wm')).toBe(false); + expect(canPromoteAssertion('created', null)).toBe(false); + expect(canPromoteAssertion(undefined, undefined)).toBe(false); + }); +}); + +describe('assertionSubgraphLine — header line-3 conditional (T03)', () => { + it('returns the `subgraph: ` line only when a sub-graph is present', () => { + expect(assertionSubgraphLine('research')).toBe('subgraph: research'); + }); + + it('returns null for a root-scoped assertion (no trailing-undefined cliff)', () => { + expect(assertionSubgraphLine(undefined)).toBeNull(); + expect(assertionSubgraphLine(null)).toBeNull(); + expect(assertionSubgraphLine('')).toBeNull(); + expect(assertionSubgraphLine(' ')).toBeNull(); + }); +}); + +describe('buildAssertionTrail — lifecycle trail stages + is-current marker', () => { + it('renders the full forward chain with the current stage marked', () => { + const stages = buildAssertionTrail('created'); + expect(stages.map(s => s.state)).toEqual(['created', 'promoted', 'published', 'finalized']); + expect(stages.filter(s => s.isCurrent).map(s => s.state)).toEqual(['created']); + }); + + it('marks `promoted` as current on a promoted assertion', () => { + const stages = buildAssertionTrail('promoted'); + expect(stages.filter(s => s.isCurrent).map(s => s.state)).toEqual(['promoted']); + // Tones follow the trust layers, not the state names. + expect(stages.find(s => s.state === 'promoted')!.tone).toBe('shared'); + expect(stages.find(s => s.state === 'published')!.tone).toBe('verified'); + }); + + it('renders a SINGLE muted discarded event (not created ▸ discarded)', () => { + const stages = buildAssertionTrail('discarded'); + expect(stages).toHaveLength(1); + expect(stages[0]).toMatchObject({ state: 'discarded', tone: 'discarded', isCurrent: true }); + }); + + it('renders the full chain all-neutral (no current marker) while hydrating', () => { + const stages = buildAssertionTrail(null); + expect(stages.map(s => s.state)).toEqual(['created', 'promoted', 'published', 'finalized']); + expect(stages.some(s => s.isCurrent)).toBe(false); + }); +}); + +describe('assertionEmptyStateCopy — ux §4.7.1 state-keyed empty copy (Codex round-3 #3)', () => { + it('created → "no extracted entities" (the generalization must NOT loosen this)', () => { + const c = assertionEmptyStateCopy('created'); + expect(c.title).toBe('No entities in this assertion.'); + expect(c.description).toBe('This assertion has no extracted entities.'); + }); + + it('promoted → SWM forward-path line (unchanged)', () => { + const c = assertionEmptyStateCopy('promoted'); + expect(c.title).toBe('No entities in this assertion.'); + expect(c.description).toContain('now live in Shared Working Memory'); + expect(c.description).toContain('Open the Shared Working Memory tab'); + expect(c.description).not.toContain('no extracted entities'); + }); + + it('published AND finalized → VM / Knowledge-Assets line, NOT "no extracted entities"', () => { + for (const s of ['published', 'finalized'] as const) { + const c = assertionEmptyStateCopy(s); + expect(c.title).toBe('No entities in this assertion.'); + expect(c.description).toBe('This assertion was published — its entities are now Knowledge Assets in Verifiable Memory. Open the Verifiable Memory tab to view them.'); + expect(c.description).not.toContain('no extracted entities'); + expect(c.description).not.toContain('Shared Working Memory'); + } + }); + + it('discarded → terminal copy, no "open X tab" forward path', () => { + const c = assertionEmptyStateCopy('discarded'); + expect(c.title).toBe('This assertion was discarded.'); + expect(c.description).toBe('This assertion was discarded.'); + expect(c.description).not.toContain('Open the'); + }); + + it('hydrating (null) falls back to the created copy', () => { + expect(assertionEmptyStateCopy(null).description).toBe('This assertion has no extracted entities.'); + }); +}); + +describe('buildBreadcrumbHops — S5 breadcrumb hop construction (T04)', () => { + const CG = 'Hello World'; + + it('overview: a single non-interactive Context Graph hop', () => { + const hops = buildBreadcrumbHops({ contextGraphName: CG, activeLayer: 'overview', activeSubGraph: null }); + expect(hops).toHaveLength(1); + expect(hops[0]).toMatchObject({ label: CG, target: 'current' }); + }); + + it('on a layer page: Context Graph (link) › Layer full-name (current)', () => { + const hops = buildBreadcrumbHops({ contextGraphName: CG, activeLayer: 'wm', activeSubGraph: null }); + expect(hops.map(h => h.label)).toEqual([CG, 'Working Memory']); + expect(hops[0].target).toBe('overview'); // clickable to overview + expect(hops[1].target).toBe('current'); // current location + }); + + it('uses the full layer name for SWM / VM', () => { + expect(buildBreadcrumbHops({ contextGraphName: CG, activeLayer: 'swm', activeSubGraph: null })[1].label) + .toBe('Shared Working Memory'); + expect(buildBreadcrumbHops({ contextGraphName: CG, activeLayer: 'vm', activeSubGraph: null })[1].label) + .toBe('Verifiable Memory'); + }); + + it('on a subgraph page: the middle hop is the subgraph displayName, NOT the layer (never both)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, activeLayer: 'wm', + activeSubGraph: 'demo', subGraphDisplayName: 'Demo Subgraph', + }); + expect(hops.map(h => h.label)).toEqual([CG, 'Demo Subgraph']); + // The layer name must NOT appear when a subgraph is the middle hop. + expect(hops.some(h => h.label === 'Working Memory')).toBe(false); + }); + + it('with an open detail: Context Graph (link) › middle (link → origin) › detail name (current)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, activeLayer: 'wm', activeSubGraph: null, + detailLabel: 'Battery cell 003', + }); + expect(hops.map(h => h.label)).toEqual([CG, 'Working Memory', 'Battery cell 003']); + expect(hops[0].target).toBe('overview'); + expect(hops[1].target).toBe('origin'); // middle hop closes the detail + expect(hops[2].target).toBe('current'); // trailing = you are here + }); + + it('every hop carries a title for the unconditional tooltip', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, activeLayer: 'wm', activeSubGraph: 'demo', + subGraphDisplayName: 'Demo', detailLabel: 'X', + }); + expect(hops.every(h => typeof h.title === 'string' && h.title.length > 0)).toBe(true); + }); +}); + +describe('buildBreadcrumbHops — cross-subgraph update (T05)', () => { + // When NO origin snapshot is threaded, the middle hop falls back to the + // current subgraph (pre-8-2 behaviour — and still correct for callers + // that don't supply the origin). Codex round-8 (8-2) layers the + // origin-aware labelling ON TOP of this fallback (see the next describe). + it('reflects the active subgraph on the middle hop (no origin threaded → current fallback)', () => { + const before = buildBreadcrumbHops({ + contextGraphName: 'CG', activeLayer: 'wm', + activeSubGraph: 'demo', subGraphDisplayName: 'Demo', + detailLabel: 'Entity A', + }); + expect(before[1].label).toBe('Demo'); + + const after = buildBreadcrumbHops({ + contextGraphName: 'CG', activeLayer: 'wm', + activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + }); + expect(after[1].label).toBe('Other'); + expect(after[2].label).toBe('Entity B'); + }); +}); + +describe('buildBreadcrumbHops — origin-derived middle hop (Codex round-8 / 8-2)', () => { + // When a detail is open the middle hop's target is `'origin'` (clicking + // closes the detail back to where it was opened). After an M2(b) + // cross-subgraph follow the CURRENT subgraph diverges from the origin, so + // the middle hop's LABEL must name the ORIGIN — where the click returns + // you — not the followed-into subgraph. + it('labels the middle hop with the ORIGIN subgraph, not the current one (M2(b) divergence)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: 'CG', activeLayer: 'wm', + // Current page is subgraph "other" (followed into via M2(b)) … + activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + // … but the detail was OPENED from subgraph "demo". + originLayer: 'wm', + originSubGraph: 'demo', originSubGraphDisplayName: 'Demo', + }); + // Middle hop names the origin (Demo) — clicking it returns there. + expect(hops[1].label).toBe('Demo'); + expect(hops[1].target).toBe('origin'); + // ONLY the middle hop changes — trailing stays the entity name. + expect(hops[2].label).toBe('Entity B'); + expect(hops[2].target).toBe('current'); + // The followed-into subgraph name must NOT appear anywhere. + expect(hops.some(h => h.label === 'Other')).toBe(false); + }); + + it('labels the middle hop with the ORIGIN layer when the origin was a layer page', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: 'CG', + // Current location followed into a subgraph … + activeLayer: 'wm', activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + // … but the detail was opened from the SWM layer list (no subgraph). + originLayer: 'swm', originSubGraph: null, originSubGraphDisplayName: null, + }); + expect(hops[1].label).toBe('Shared Working Memory'); + expect(hops[1].target).toBe('origin'); + }); + + it('synthesises NO middle hop when the origin was the overview (2-hop rule holds)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: 'CG', + activeLayer: 'wm', activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + // Origin was the overview — no middle to return to. + originLayer: 'overview', originSubGraph: null, originSubGraphDisplayName: null, + }); + expect(hops.map(h => h.label)).toEqual(['CG', 'Entity B']); + expect(hops[1].target).toBe('current'); + }); + + it('falls back to current-scope labelling when no origin is threaded (back-compat)', () => { + // Detail open but origin props omitted (undefined) → current label. + const hops = buildBreadcrumbHops({ + contextGraphName: 'CG', activeLayer: 'wm', + activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + }); + expect(hops[1].label).toBe('Other'); + }); +}); + +describe('buildBreadcrumbHops — first-hop restores origin for an overview-opened detail (Codex round-10 / 10-2)', () => { + const CG = 'Hello World'; + + // Detail opened from the OVERVIEW → 2-hop `[Context Graph › Detail]`, no + // middle. The first hop is the SOLE back-affordance, so it must restore + // the M2 origin (target 'origin' → onRestoreOrigin), NOT do a fresh + // top-of-overview nav that drops the captured scroll/tab. + it('detail-from-overview (2-hop): the first hop target is "origin", not "overview"', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, + activeLayer: 'wm', activeSubGraph: 'other', subGraphDisplayName: 'Other', + detailLabel: 'Entity B', + // Origin was the overview — no middle hop. + originLayer: 'overview', originSubGraph: null, originSubGraphDisplayName: null, + }); + expect(hops.map(h => h.label)).toEqual([CG, 'Entity B']); // 2 hops, no middle + expect(hops[0].target).toBe('origin'); // first hop restores origin + expect(hops[1].target).toBe('current'); // trailing = you are here + }); + + // Detail opened from a LAYER (3-hop) → the first hop stays 'overview' (a + // genuine go-UP-to-CG-root, distinct from the middle hop's 'origin' + // restore). The carve-out must hold. + it('detail-from-layer (3-hop): the first hop stays "overview" (carve-out)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, + activeLayer: 'wm', activeSubGraph: null, + detailLabel: 'Entity B', + originLayer: 'swm', originSubGraph: null, originSubGraphDisplayName: null, + }); + expect(hops.map(h => h.label)).toEqual([CG, 'Shared Working Memory', 'Entity B']); // 3 hops + expect(hops[0].target).toBe('overview'); // go up to CG root — unchanged + expect(hops[1].target).toBe('origin'); // middle restores origin + expect(hops[2].target).toBe('current'); + }); + + // No detail open: the first hop is 'overview' (unchanged baseline). + it('no detail open: the first hop is "overview" (unchanged)', () => { + const hops = buildBreadcrumbHops({ + contextGraphName: CG, activeLayer: 'wm', activeSubGraph: null, + }); + expect(hops[0].target).toBe('overview'); + expect(hops[1].target).toBe('current'); + }); +}); + +describe('primarySubGraphOf — M2(b) cross-subgraph follow decision (T14)', () => { + it('returns the first non-meta subgraph slug', () => { + expect(primarySubGraphOf(entity(['demo']))).toBe('demo'); + expect(primarySubGraphOf(entity(['meta', 'research']))).toBe('research'); + }); + + it('returns null for a root-only / meta-only entity', () => { + expect(primarySubGraphOf(entity([]))).toBeNull(); + expect(primarySubGraphOf(entity(['meta']))).toBeNull(); + }); + + it('returns null for a missing entity', () => { + expect(primarySubGraphOf(undefined)).toBeNull(); + expect(primarySubGraphOf(null)).toBeNull(); + }); +}); diff --git a/packages/node-ui/test/assertion-detail-view.dom.test.ts b/packages/node-ui/test/assertion-detail-view.dom.test.ts new file mode 100644 index 000000000..c22320796 --- /dev/null +++ b/packages/node-ui/test/assertion-detail-view.dom.test.ts @@ -0,0 +1,633 @@ +// @vitest-environment happy-dom + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProjectProfileContext, type ProjectProfile } from '../src/ui/hooks/useProjectProfile.js'; +import { AgentsContext, type AgentsData } from '../src/ui/hooks/useAgents.js'; +import type { AssertionInfo, AssertionStateInfo, AssertionTriple } from '../src/ui/api.js'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// S4 — DOM tests for AssertionDetailView (T08 / T09 / T10 / T17). We +// override only the two assertion data reads on the real api.js module +// (keeping every other export the component tree pulls in) plus stub the +// lazy RdfGraph so the Graph tab doesn't load the force-graph bundle. + +const stateMock = vi.hoisted(() => ({ + fetchAssertionState: vi.fn<(cg: string, uri: string) => Promise>(), + fetchAssertionTriples: vi.fn<(cg: string, g: string) => Promise>(), + promoteAssertion: vi.fn<() => Promise<{ promotedCount: number }>>(), +})); + +vi.mock('../src/ui/api.js', async () => { + const actual = await vi.importActual('../src/ui/api.js'); + return { + ...actual, + fetchAssertionState: stateMock.fetchAssertionState, + fetchAssertionTriples: stateMock.fetchAssertionTriples, + promoteAssertion: stateMock.promoteAssertion, + }; +}); + +vi.mock('@origintrail-official/dkg-graph-viz/react', () => ({ + RdfGraph: () => React.createElement('div', { 'data-testid': 'rdf-graph' }, 'graph'), +})); + +const { AssertionDetailView } = await import('../src/ui/views/project/components.js'); + +const profile: ProjectProfile = { + contextGraphId: 'cg-test', + displayName: 'Context Graph Test', + primaryColor: '#64748b', + accentColor: '#22c55e', + subGraphs: [], + typeBindings: [], + views: [], + filterChips: [], + queryCatalogs: [], + savedQueries: [], + loading: false, + forSubGraph: () => undefined, + forType: typeIri => ({ typeIri, label: typeIri.split(/[/#]/).pop() ?? typeIri, color: '#64748b' }), + view: () => undefined, + chipsFor: () => [], + savedQueryCatalogsFor: () => [], + savedQueriesFor: () => [], +}; + +const agents: AgentsData = { + agents: new Map(), + list: [], + loading: false, + get: () => undefined, + openAgent: vi.fn(), +}; + +const NAME = 'http://schema.org/name'; +const TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; + +const sampleTriples: AssertionTriple[] = [ + { subject: 'urn:e:battery', predicate: TYPE, object: 'http://schema.org/Thing' }, + { subject: 'urn:e:battery', predicate: NAME, object: '"Battery cell 003"' }, +]; + +function query(selector: string): HTMLElement { + const el = document.querySelector(selector); + if (!el) throw new Error(`Missing element ${selector}`); + return el; +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await new Promise(r => setTimeout(r, 0)); + }); +} + +function render(root: Root, assertion: AssertionInfo, sourceLayer: 'wm' | 'swm' = 'wm') { + return act(async () => { + root.render( + React.createElement(ProjectProfileContext.Provider, { value: profile }, + React.createElement(AgentsContext.Provider, { value: agents }, + React.createElement(AssertionDetailView, { + assertion, + sourceLayer, + contextGraphId: 'cg-test', + onNavigate: vi.fn(), + onComplete: vi.fn(), + }))), + ); + }); +} + +async function mount(assertion: AssertionInfo, sourceLayer: 'wm' | 'swm' = 'wm'): Promise { + document.body.innerHTML = '
'; + const root = createRoot(query('#root')); + await render(root, assertion, sourceLayer); + await flush(); + return root; +} + +// Post-#864 `AssertionInfo.graphUri` is the DATA-GRAPH (partition) URI. +const wmAssertion: AssertionInfo = { name: 'epcis-demo', graphUri: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', subGraph: 'demo' }; + +describe('AssertionDetailView', () => { + let root: Root | undefined; + + beforeEach(() => { + stateMock.fetchAssertionState.mockReset(); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.promoteAssertion.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue(sampleTriples); + stateMock.promoteAssertion.mockResolvedValue({ promotedCount: 3 }); + }); + + afterEach(async () => { + if (root) { + await act(async () => { root!.unmount(); }); + root = undefined; + } + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + it('renders the 3-line header for a sub-graph-scoped WM created assertion (T09 / T17)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + createdBy: 'did:dkg:agent:0xabc', + }); + root = await mount(wmAssertion); + + // Line 1: ▤ + mono name. + expect(query('.v10-ka-name').textContent).toContain('epcis-demo'); + // Line 2: assertion · N entities · M triples. + const uals = document.querySelectorAll('.v10-ka-ual'); + expect(uals[0].textContent).toContain('assertion ·'); + expect(uals[0].textContent).toContain('2 triples'); + // Line 3: subgraph (only because this assertion is scoped). + expect([...uals].some(u => u.textContent?.includes('subgraph: demo'))).toBe(true); + // Right-rail badge shows the layer glyph + label + state. + const badge = query('.v10-trust-badge'); + expect(badge.textContent).toContain('Working'); + expect(badge.textContent).toContain('created'); + }); + + it('omits the line-3 subgraph row for a root-scoped assertion (T09 conditional)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/assertion/0xabc/root-doc', + }); + root = await mount({ name: 'root-doc', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/root-doc' }); + const uals = [...document.querySelectorAll('.v10-ka-ual')]; + expect(uals.some(u => u.textContent?.includes('subgraph:'))).toBe(false); + }); + + it('shows the Promote CTA only for created + wm (T08)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + expect(query('.v10-ka-header-actions').textContent).toContain('Promote to SWM'); + }); + + it('hides the Promote CTA for a promoted (SWM) assertion (T08)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'promoted', layer: 'swm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + // Badge reflects the promoted state. + expect(query('.v10-trust-badge').textContent).toContain('promoted'); + }); + + // Codex round-2 finding 1 (hardening) — a discarded assertion is + // LAYERLESS: the badge is a neutral muted `discarded`, NO layer glyph / + // name and NEVER VM-green (the api.ts discarded→vm fallback is + // unreachable today, but this render guard makes it harmless). State + // row also drops the `(Layer)` suffix. + it('renders a discarded assertion with a neutral layerless badge (no vm-green)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + // memoryLayer absent (discard deletes it) → the api fallback would + // pick vm; the render guard must override to neutral. + state: 'discarded', layer: 'vm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + const badge = query('.v10-ka-header-actions .v10-trust-badge, .v10-ka-header-actions .v10-trust-badge-discarded') as HTMLElement; + expect(badge.classList.contains('v10-trust-badge-discarded')).toBe(true); + expect(badge.classList.contains('vm')).toBe(false); // never VM-green + expect(badge.textContent).toBe('discarded'); + expect(badge.textContent).not.toContain('Verifiable'); + expect(badge.textContent).not.toContain('◉'); // no layer glyph + // No Promote CTA on a discarded assertion. + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + // State row is layerless ("discarded", no "(Layer)"). + expect(document.body.textContent).toContain('discarded'); + expect(document.body.textContent).not.toContain('discarded ('); + }); + + // Codex round-6 finding 2 — an entity label that is a lang/typed RDF + // literal must render DECODED in the Entities pane (`Hola`), not as the + // raw `Hola"@es` (the round-2 consumer audit missed buildMemoryEntities' + // label path; round-6 decodes at the shared label chokepoint). + it('Entities pane decodes a lang-tagged entity label (Hola, not Hola"@es)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue([ + { subject: 'urn:e:greet', predicate: TYPE, object: 'http://schema.org/Thing' }, + { subject: 'urn:e:greet', predicate: 'http://schema.org/name', object: '"Hola"@es' }, + ]); + root = await mount(wmAssertion); + const body = document.body.textContent ?? ''; + expect(body).toContain('Hola'); + expect(body).not.toContain('Hola"@es'); + expect(body).not.toContain('"@es'); + }); + + // Codex round-6 finding 3 — a triple-FETCH error must render a DISTINCT + // "couldn't load" state, NOT the empty-state copy (an operational + // failure must not look like valid-but-empty data). + it('triple-fetch error shows the error state, not the empty-state copy', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockRejectedValue(new Error('HTTP 500')); + root = await mount(wmAssertion); + const body = document.body.textContent ?? ''; + // Error state, distinct from the genuinely-empty copy. + expect(body).toContain("Couldn't load this assertion's contents."); + expect(body).not.toContain('This assertion has no extracted entities.'); + expect(body).not.toContain('No entities in this assertion.'); + }); + + it('Triples tab renders a plain s/p/o table with NO filter pills (T10)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + // Switch to the Triples tab. + const tabs = [...document.querySelectorAll('.v10-content-tab')]; + const triplesTab = tabs.find(t => t.textContent === 'Triples')!; + await act(async () => { triplesTab.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.querySelector('.v10-ka-triples-table')).not.toBeNull(); + // The entity-detail filter-pill chrome must NOT appear here. + expect(document.querySelector('.v10-triples-filter-pills')).toBeNull(); + expect(document.querySelector('.v10-ka-pill')).toBeNull(); + expect(query('.v10-ka-triples-table').textContent).toContain('Battery cell 003'); + }); + + // Codex round-9 (9-1) — the triples loading treatment is SHARED across + // all three tabs. Pre-fix only the Entities pane gated on triplesLoading; + // the Triples tab rendered a "0 of 0" table and the Graph tab fell through + // to "No assertion graph data" while the fetch was still pending. + it('triples loading: the Triples and Graph tabs show the loading treatment, not 0-of-0 / no-data', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + // Triples fetch never settles → triplesLoading stays true, triples empty. + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockReturnValue(new Promise(() => {})); + root = await mount(wmAssertion); + + // Entities tab (default) shows loading. + expect(document.body.textContent).toContain('Loading assertion entities'); + + // Triples tab — must NOT render the empty "0 of 0" table. + const tabs = () => [...document.querySelectorAll('.v10-content-tab')]; + await act(async () => { tabs().find(t => t.textContent === 'Triples')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.querySelector('.v10-ka-triples-table')).toBeNull(); + expect(document.body.textContent).not.toContain('0 of 0 triples shown'); + expect(document.body.textContent).toContain('Loading assertion entities'); + + // Graph tab — must NOT render "No assertion graph data". + await act(async () => { tabs().find(t => t.textContent === 'Graph')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.body.textContent).not.toContain('No assertion graph data'); + expect(document.body.textContent).toContain('Loading assertion entities'); + }); + + it('triples error: ALL three tabs show the error state, not empty content', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockRejectedValue(new Error('HTTP 500')); + root = await mount(wmAssertion); + + const ERR = "Couldn't load this assertion's contents."; + const tabs = () => [...document.querySelectorAll('.v10-content-tab')]; + + // Entities tab. + expect(document.body.textContent).toContain(ERR); + + // Triples tab — error, NOT the empty table. + await act(async () => { tabs().find(t => t.textContent === 'Triples')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.body.textContent).toContain(ERR); + expect(document.querySelector('.v10-ka-triples-table')).toBeNull(); + expect(document.body.textContent).not.toContain('0 of 0 triples shown'); + + // Graph tab — error, NOT "No assertion graph data". + await act(async () => { tabs().find(t => t.textContent === 'Graph')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + expect(document.body.textContent).toContain(ERR); + expect(document.body.textContent).not.toContain('No assertion graph data'); + }); + + // Codex round-2 finding 3 — the Triples table must DISPLAY literals + // decoded (datatype/lang suffix dropped, body unescaped), not the raw + // N-Triples form. The producer (rawBindingValue) keeps the full form for + // the Graph tab + buildMemoryEntities; the table renders through + // decodeRdfStringLiteral. Pin typed / lang / escaped cases. + it('Triples tab decodes typed / lang / escaped literals for display (no mangled suffix)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue([ + { subject: 'urn:e:1', predicate: 'http://schema.org/quantity', object: '"42"^^' }, + { subject: 'urn:e:1', predicate: 'http://schema.org/greeting', object: '"bonjour"@fr' }, + { subject: 'urn:e:1', predicate: 'http://schema.org/quote', object: '"say \\"hi\\""' }, + ]); + root = await mount(wmAssertion); + const tabs = [...document.querySelectorAll('.v10-content-tab')]; + await act(async () => { tabs.find(t => t.textContent === 'Triples')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + const table = query('.v10-ka-triples-table').textContent ?? ''; + // Decoded values shown; raw suffixes/escapes NOT shown. + expect(table).toContain('42'); + expect(table).not.toContain('^^<'); + expect(table).toContain('bonjour'); + expect(table).not.toContain('@fr'); + expect(table).toContain('say "hi"'); // unescaped + expect(table).not.toContain('\\"'); // no raw backslash-escape + }); + + it('hydrating: badge omits the · state suffix and the Promote CTA stays hidden', async () => { + // Never-resolving state fetch keeps the view in the hydrating phase. + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + root = await mount(wmAssertion); + const badge = query('.v10-trust-badge'); + expect(badge.textContent).toContain('Working'); + expect(badge.textContent).not.toContain('·'); + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + }); + + // Codex round-11 (11-1) — during the state hydrate the badge + tone must + // reflect the KNOWN-true source layer (the list the detail opened from), + // NOT the old `?? 'wm'` invention. An SWM assertion opened while its state + // is still loading shows "◈ Shared", never "◇ Working". + it('hydrating: badge reflects the SWM source layer, not an invented wm (11-1)', async () => { + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + root = await mount(wmAssertion, 'swm'); // opened from the SWM list + const badge = query('.v10-trust-badge'); + expect(badge.textContent).toContain('Shared'); // SWM trust label + expect(badge.textContent).not.toContain('Working'); // no wm-flash + expect(badge.textContent).toContain('◈'); // SWM glyph + expect(badge.textContent).not.toContain('◇'); // not the WM glyph + }); + + // 11-1 — on a state-fetch error the badge must still show the source layer + // (the value is real + known), NOT 'wm', so it doesn't contradict the + // "state unavailable" message in the right rail. + it('state-error: badge shows the SWM source layer alongside "state unavailable" (no wm contradiction)', async () => { + stateMock.fetchAssertionState.mockResolvedValue(null); + root = await mount(wmAssertion, 'swm'); + const badge = query('.v10-trust-badge'); + expect(badge.textContent).toContain('Shared'); + expect(badge.textContent).not.toContain('Working'); + expect(document.body.textContent).toContain('state unavailable'); + }); + + // Codex round-11 (11-2) — header counts during load must NOT read as + // `0 entities · 0 triples` (a false "empty" claim). entityCount is unknown + // until the fetch → `…`; tripleCount seeds from the row when present, else + // `…` — never 0 before the fetch settles. + it('hydrating: header counts show placeholders, never 0 entities / 0 triples (11-2)', async () => { + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + // Row carries NO tripleCount → both counts are placeholders. + root = await mount({ name: 'x', graphUri: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/x', subGraph: 'demo' }); + const line = query('.v10-ka-ual').textContent ?? ''; + expect(line).not.toContain('0 entities'); + expect(line).not.toContain('0 triples'); + expect(line).toContain('… entities'); + expect(line).toContain('… triples'); + }); + + // 11-2 — when the row carries a tripleCount it seeds immediately (no + // 0-flash, no placeholder for that field); entities stay `…` until the + // fetch resolves. + it('hydrating: a row-seeded tripleCount shows immediately; entities stay a placeholder (11-2)', async () => { + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + root = await mount({ name: 'x', graphUri: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/x', subGraph: 'demo', tripleCount: 142 }); + const line = query('.v10-ka-ual').textContent ?? ''; + expect(line).toContain('142 triples'); // row-seeded, no 0-flash + expect(line).toContain('… entities'); // still unknown + expect(line).not.toContain('0 entities'); + expect(line).not.toContain('0 triples'); + }); + + // 11-2 — once resolved, the real counts replace the placeholders (incl. a + // genuine zero, which is now a TRUE statement, not a hydrate artefact). + it('resolved: header shows the real fetched counts (placeholders replaced)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + // sampleTriples = 2 triples, 1 root entity (urn:e:battery). + root = await mount(wmAssertion); + const line = query('.v10-ka-ual').textContent ?? ''; + expect(line).toContain('2 triples'); + expect(line).not.toContain('… triples'); + expect(line).not.toContain('… entities'); + // Real entity count is present (not a placeholder). + expect(line).toMatch(/\d+ entities/); + }); + + // 11-2 — a triples-FETCH error must keep the count placeholders, NEVER + // surface `0 entities · 0 triples` (an operational failure must not read as + // valid-but-empty). + it('triples error: header counts stay placeholders, never 0 (11-2)', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockRejectedValue(new Error('HTTP 500')); + root = await mount(wmAssertion); // wmAssertion carries no tripleCount + const line = query('.v10-ka-ual').textContent ?? ''; + expect(line).not.toContain('0 entities'); + expect(line).not.toContain('0 triples'); + expect(line).toContain('… entities'); + expect(line).toContain('… triples'); + }); + + // Codex round-8 (8-1) — the empty-state must NOT FLASH during hydration. + // On first mount `stateInfo` is null, so `assertionGraph` is undefined + // and the triples effect sets `triplesLoading=false` immediately — + // leaving the empty-state branch reachable BEFORE the lifecycle lookup + // resolves (with `assertionEmptyStateCopy(undefined)`). The hydrating + // treatment must win while `stateLoading` is true. + it('hydrating (state fetch unresolved): Entities pane shows the loading treatment, NOT the empty-state flash', async () => { + // State never resolves → stateLoading stays true → assertionGraph + // undefined → triples effect early-returns with triplesLoading=false. + stateMock.fetchAssertionState.mockReturnValue(new Promise(() => {})); + root = await mount(wmAssertion); + const body = document.body.textContent ?? ''; + // Hydrating treatment is shown … + expect(body).toContain('Loading assertion entities'); + // … and NONE of the empty-state copies flash through. + expect(body).not.toContain('This assertion has no extracted entities.'); + expect(body).not.toContain('No entities in this assertion'); + expect(body).not.toContain('now live in Shared Working Memory'); + expect(body).not.toContain('Knowledge Assets in Verifiable Memory'); + }); + + it('state-fetch error: panel shows "state unavailable" + quiet trail hint, CTA hidden', async () => { + stateMock.fetchAssertionState.mockResolvedValue(null); + root = await mount(wmAssertion); + expect(document.body.textContent).toContain('state unavailable'); + expect(document.querySelector('.v10-ka-trail-hint')?.textContent) + .toContain("Lifecycle state couldn't load"); + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + }); + + // ux-lead §4.7.1 sibling edge — a created-WM assertion with a PRESENT + // but EMPTY data graph (assertionGraph truthy, zero triples) must show + // the "no extracted entities" copy, NOT the promoted line. The branch + // keys purely on state==='promoted', so created → the generic empty + // copy regardless of why it's empty. (This case does NOT hit the + // triplesLoading bug — assertionGraph is truthy, the fetch resolves, + // `.finally` unsticks loading — the risk is purely copy selection.) + it('created-WM with a present-but-empty data graph shows "no extracted entities", not the promoted line', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue([]); // present graph, zero triples + root = await mount(wmAssertion); + + expect(document.body.textContent).not.toContain('Loading assertion entities'); + expect(document.body.textContent).toContain('No entities in this assertion'); + expect(document.body.textContent).toContain('This assertion has no extracted entities.'); + // The promoted forward-path line must NOT appear for a created assertion. + expect(document.body.textContent).not.toContain('now live in Shared Working Memory'); + // A created-WM assertion still offers the Promote CTA. + expect(query('.v10-ka-header-actions').textContent).toContain('Promote to SWM'); + }); + + // Local-review 🟡 — AssertionDetailView is NOT keyed in ProjectView, so + // switching `selectedAssertion` REUSES the instance. Navigating from + // assertion A (triples mid-load) to assertion B that resolves WITHOUT a + // dkg:assertionGraph (a promoted assertion, or a legacy record) must + // reset triplesLoading in the effect's early-return — otherwise the + // Entities pane sticks on "Loading assertion entities…" forever and the + // promoted empty-state never renders. This pins the fix. + it('A (triples mid-load) → B with no assertionGraph: triplesLoading resets, empty-state renders', async () => { + // A — state resolves WITH a data graph; its triples fetch never + // settles (stuck mid-load). + stateMock.fetchAssertionState.mockResolvedValueOnce({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockReturnValueOnce(new Promise(() => {})); + root = await mount(wmAssertion); + // A is mid-load → the Entities pane shows the loading text. + expect(document.body.textContent).toContain('Loading assertion entities'); + + // B — a PROMOTED assertion: state resolves with NO assertionGraph + // (its triples moved to /_shared_memory). Re-render the SAME instance + // (no unmount) with a new assertion — the real ProjectView trigger. + stateMock.fetchAssertionState.mockResolvedValueOnce({ state: 'promoted', layer: 'swm' }); + await render(root, { name: 'promoted-b', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/promoted-b' }); + await flush(); + + // triplesLoading must have reset → no stuck loading text, and the + // promoted empty-state renders with ux-lead's locked forward-path + // copy ("entities" not "contents"; points to the SWM tab). + expect(document.body.textContent).not.toContain('Loading assertion entities'); + expect(document.body.textContent).toContain('No entities in this assertion'); + expect(document.body.textContent).toContain( + 'its entities now live in Shared Working Memory. Open the Shared Working Memory tab to view them.', + ); + }); + + // Codex round-3 finding 3 — a published/finalized assertion (empty data + // graph: entities moved to VM) must show the VM / Knowledge-Assets + // empty-state line, NOT "no extracted entities" and NOT the SWM line. + it('published assertion empty-state shows the VM / Knowledge-Assets line (not "no extracted entities")', async () => { + stateMock.fetchAssertionState.mockResolvedValue({ + state: 'published', layer: 'vm', + // post-publish the data graph is empty + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + stateMock.fetchAssertionTriples.mockReset(); + stateMock.fetchAssertionTriples.mockResolvedValue([]); + root = await mount(wmAssertion); + const body = document.body.textContent ?? ''; + expect(body).toContain('No entities in this assertion'); + expect(body).toContain('its entities are now Knowledge Assets in Verifiable Memory. Open the Verifiable Memory tab to view them.'); + expect(body).not.toContain('no extracted entities'); + // Scope the SWM-line check to the empty-state element — the lifecycle + // trail in the right rail always renders the static stage title + // "Promoted to Shared Working Memory" (the pipeline legend), so + // checking the whole body would false-positive. + const emptyStateText = document.querySelector('.v10-empty-state')?.textContent ?? body; + expect(emptyStateText).not.toContain('now live in Shared Working Memory'); + }); + + // Codex round-1 finding 1 — on assertion switch the hook must CLEAR + // `data` so the previous assertion's state isn't briefly visible while + // the new fetch is in flight. A → B where B's state never resolves: the + // badge/State row must NOT show A's resolved state. + it('switching to a slow-resolving assertion does not leave the prior state visible (stale-data clear)', async () => { + // A — resolves to created/wm with a recognisable subgraph badge state. + stateMock.fetchAssertionState.mockResolvedValueOnce({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + expect(query('.v10-trust-badge').textContent).toContain('created'); + + // B — state never resolves (stuck hydrating). After the switch the + // badge must drop the `· created` suffix (hydrating), NOT keep A's. + stateMock.fetchAssertionState.mockReturnValueOnce(new Promise(() => {})); + await render(root, { name: 'b', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/b' }); + await flush(); + const badge = query('.v10-trust-badge'); + expect(badge.textContent).not.toContain('created'); // A's state is gone + expect(badge.textContent).not.toContain('·'); // hydrating: no state suffix + }); + + // Codex round-1 finding 2 — promoting FROM the detail view flips the + // state in `_meta` but leaves graphUri unchanged. The view must REFETCH + // its state + triples so the badge/CTA reflect the new state without a + // remount. + it('promoting from the detail refetches state + triples (post-promote staleness)', async () => { + // Mount: created/wm → Promote CTA visible. + stateMock.fetchAssertionState.mockResolvedValueOnce({ + state: 'created', layer: 'wm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + root = await mount(wmAssertion); + expect(query('.v10-trust-badge').textContent).toContain('created'); + expect(query('.v10-ka-header-actions').textContent).toContain('Promote to SWM'); + expect(stateMock.fetchAssertionState).toHaveBeenCalledTimes(1); + + // After promote success the nonce bumps → state refetches and now + // resolves to promoted/swm. + stateMock.fetchAssertionState.mockResolvedValueOnce({ + state: 'promoted', layer: 'swm', + assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo', + }); + const cta = [...document.querySelectorAll('.v10-ka-header-actions button')] + .find(b => b.textContent?.includes('Promote to SWM'))!; + await act(async () => { cta.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await flush(); + + // promoteAssertion fired; state refetched (≥2 calls); badge now promoted; + // CTA gone (promoted is not created+wm). + expect(stateMock.promoteAssertion).toHaveBeenCalledTimes(1); + expect(stateMock.fetchAssertionState.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(query('.v10-trust-badge').textContent).toContain('promoted'); + expect(query('.v10-ka-header-actions').textContent).not.toContain('Promote to SWM'); + }); +}); diff --git a/packages/node-ui/test/breadcrumb.dom.test.ts b/packages/node-ui/test/breadcrumb.dom.test.ts new file mode 100644 index 000000000..3bbd25aec --- /dev/null +++ b/packages/node-ui/test/breadcrumb.dom.test.ts @@ -0,0 +1,276 @@ +// @vitest-environment happy-dom + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ProjectHeaderStrip } from '../src/ui/views/project/components.js'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// S5 — DOM tests for the breadcrumb in ProjectHeaderStrip (T11 / T12) +// plus the T13 grep guard that the old back-affordance is gone. + +const profile = { + displayName: 'Hello World', + primaryColor: '#64748b', +} as any; + +const cg = { id: 'cg-test', name: 'Hello World', description: 'A demo graph.' }; + +function query(selector: string): HTMLElement { + const el = document.querySelector(selector); + if (!el) throw new Error(`Missing element ${selector}`); + return el; +} +function all(selector: string): HTMLElement[] { + return [...document.querySelectorAll(selector)]; +} + +describe('ProjectHeaderStrip breadcrumb', () => { + let root: Root; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = createRoot(query('#root')); + }); + afterEach(async () => { + await act(async () => { root.unmount(); }); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + async function render(props: Partial>) { + await act(async () => { + root.render(React.createElement(ProjectHeaderStrip, { + cg, profile, + activeLayer: 'overview', + activeSubGraph: null, + detailLabel: null, + onOverview: vi.fn(), + onRestoreOrigin: vi.fn(), + ...props, + } as any)); + }); + } + + it('renders Context Graph › Layer with the layer as the trailing current hop (T12)', async () => { + await render({ activeLayer: 'wm' }); + const hops = all('.v10-breadcrumb-hop'); + expect(hops.map(h => h.textContent)).toEqual(['Hello World', 'Working Memory']); + // First hop is a clickable link button; trailing hop is a span. + expect(hops[0].tagName).toBe('BUTTON'); + expect(hops[0].classList.contains('link')).toBe(true); + expect(hops[1].tagName).toBe('SPAN'); + expect(hops[1].classList.contains('current')).toBe(true); + expect(hops[1].getAttribute('aria-current')).toBe('page'); + }); + + it('non-trailing hops are clickable and fire the right handler (T12)', async () => { + const onOverview = vi.fn(); + const onRestoreOrigin = vi.fn(); + await render({ activeLayer: 'wm', detailLabel: 'Battery cell 003', onOverview, onRestoreOrigin }); + const hops = all('.v10-breadcrumb-hop'); + expect(hops.map(h => h.textContent)).toEqual(['Hello World', 'Working Memory', 'Battery cell 003']); + + // First hop → overview. + await act(async () => { hops[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + expect(onOverview).toHaveBeenCalledTimes(1); + + // Middle hop (link → origin restore). + expect(hops[1].tagName).toBe('BUTTON'); + await act(async () => { hops[1].dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + expect(onRestoreOrigin).toHaveBeenCalledTimes(1); + + // Trailing hop is a non-interactive span (no button). + expect(hops[2].tagName).toBe('SPAN'); + }); + + it('every hop carries an unconditional title tooltip (T11 overflow affordance)', async () => { + const longName = 'x'.repeat(200); + await render({ + activeLayer: 'wm', + activeSubGraph: { slug: 'demo', displayName: longName, color: '#38bdf8' } as any, + detailLabel: longName, + }); + const hops = all('.v10-breadcrumb-hop'); + expect(hops.every(h => (h.getAttribute('title') ?? '').length > 0)).toBe(true); + // The middle (subgraph) + trailing hops carry the truncation CSS + // (max-width + ellipsis); the title preserves the full text. + const middle = hops[1]; + expect(middle.getAttribute('title')).toBe(longName); + }); + + it('the subgraph displayName is the middle hop, never the layer name (T12 hop rule)', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: { slug: 'demo', displayName: 'Demo Subgraph', color: '#38bdf8' } as any, + }); + const labels = all('.v10-breadcrumb-hop').map(h => h.textContent); + expect(labels).toEqual(['Hello World', 'Demo Subgraph']); + expect(labels).not.toContain('Working Memory'); + }); + + it('reuses the existing .v10-project-strip-sep separator between hops', async () => { + await render({ activeLayer: 'wm', detailLabel: 'E' }); + // 3 hops → 2 separators. + expect(all('.v10-project-strip-sep')).toHaveLength(2); + }); +}); + +describe('ProjectHeaderStrip strip chrome from origin (Codex round-9 / 9-2)', () => { + let root: Root; + + // A profile that resolves subgraph bindings by slug — so 9-2 can read the + // ORIGIN subgraph's color + description from `forSubGraph(originSubGraph)`. + const bindings: Record = { + demo: { slug: 'demo', displayName: 'Demo', color: '#aa0000', description: 'Demo description.' }, + other: { slug: 'other', displayName: 'Other', color: '#00bb00', description: 'Other description.' }, + }; + const chromeProfile = { + displayName: 'Hello World', + primaryColor: '#64748b', + forSubGraph: (slug: string) => bindings[slug], + } as any; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = createRoot(query('#root')); + }); + afterEach(async () => { + await act(async () => { root.unmount(); }); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + async function render(props: Partial>) { + await act(async () => { + root.render(React.createElement(ProjectHeaderStrip, { + cg, profile: chromeProfile, + activeLayer: 'wm', + activeSubGraph: null, + detailLabel: null, + onOverview: vi.fn(), + onRestoreOrigin: vi.fn(), + ...props, + } as any)); + }); + } + + // After an M2(b) cross-subgraph follow (current = 'other', origin = + // 'demo'), the strip tint + description + breadcrumb middle hop must all + // name the ORIGIN ('demo'), not the followed-into 'other'. + it('cross-subgraph follow: tint + description + breadcrumb all come from the ORIGIN', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: bindings.other, // current page = followed-into 'other' + detailLabel: 'Entity B', + originLayer: 'wm', + originSubGraph: 'demo', // origin = where the detail opened + originSubGraphDisplayName: 'Demo', + }); + // Tint = origin color, NOT the followed-into one. + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#aa0000'); + // Description = origin description. + expect(query('.v10-project-strip-desc').textContent).toBe('Demo description.'); + // Breadcrumb middle hop also names the origin (8-2 + 9-2 share the gate). + const hops = all('.v10-breadcrumb-hop'); + expect(hops[1].textContent).toBe('Demo'); + // The followed-into subgraph's chrome must NOT leak through. + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).not.toBe('#00bb00'); + expect(query('.v10-project-strip-desc').textContent).not.toBe('Other description.'); + }); + + // No cross-follow: origin == current → no-op (chrome stays on current). + it('no cross-follow (origin == current): chrome is a no-op (stays on current)', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: bindings.demo, + detailLabel: 'Entity A', + originLayer: 'wm', + originSubGraph: 'demo', + originSubGraphDisplayName: 'Demo', + }); + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#aa0000'); + expect(query('.v10-project-strip-desc').textContent).toBe('Demo description.'); + }); + + // Origin had NO subgraph (opened from overview / a non-subgraph layer): + // chrome degrades to profile.primaryColor + cg.description. + it('overview-origin: chrome falls back to primaryColor + cg.description', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: bindings.other, // followed into 'other' + detailLabel: 'Entity B', + originLayer: 'overview', + originSubGraph: null, // origin had no subgraph + originSubGraphDisplayName: null, + }); + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#64748b'); // primaryColor + expect(query('.v10-project-strip-desc').textContent).toBe('A demo graph.'); // cg.description + // The followed-into 'other' chrome must NOT be used. + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).not.toBe('#00bb00'); + }); + + // No detail open: chrome derives from the CURRENT activeSubGraph (the + // pre-9-2 behaviour — unchanged when there's no detail / no origin). + it('no detail open: chrome derives from the current activeSubGraph', async () => { + await render({ + activeLayer: 'wm', + activeSubGraph: bindings.other, + detailLabel: null, + }); + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#00bb00'); + expect(query('.v10-project-strip-desc').textContent).toBe('Other description.'); + }); + + // Codex round-10 (10-1) — an UNPROFILED origin subgraph (forSubGraph + // returns undefined) must NOT throw: the chrome degrades to + // profile.primaryColor + cg.description and the breadcrumb still renders. + // (ProjectView resolves the origin label with the same optional-chain + + // slug fallback; here we exercise the ProjectHeaderStrip chrome path.) + it('unprofiled origin subgraph: no throw, breadcrumb renders, chrome falls back', async () => { + await expect(render({ + activeLayer: 'wm', + activeSubGraph: bindings.demo, + detailLabel: 'Entity X', + originLayer: 'wm', + originSubGraph: 'ghost', // no binding → forSubGraph returns undefined + originSubGraphDisplayName: 'ghost', // caller's slug-fallback name + })).resolves.not.toThrow(); + // Breadcrumb rendered (didn't crash) — middle hop shows the slug. + const hops = all('.v10-breadcrumb-hop'); + expect(hops.map(h => h.textContent)).toEqual(['Hello World', 'ghost', 'Entity X']); + // Chrome degraded to profile/CG defaults (no color/description binding). + expect(query('.v10-project-strip').style.getPropertyValue('--sg-color')).toBe('#64748b'); + expect(query('.v10-project-strip-desc').textContent).toBe('A demo graph.'); + }); + + // Codex round-10 (10-2) — a detail opened from the OVERVIEW renders a + // 2-hop breadcrumb whose FIRST hop is the sole back-affordance. It must be + // a clickable button wired to onRestoreOrigin (origin restore), NOT + // onOverview (fresh nav that drops scroll/tab). + it('overview-opened detail: first hop is a button that restores the origin (not overview nav)', async () => { + const onOverview = vi.fn(); + const onRestoreOrigin = vi.fn(); + await render({ + activeLayer: 'wm', + activeSubGraph: null, + detailLabel: 'Entity B', + originLayer: 'overview', + originSubGraph: null, + originSubGraphDisplayName: null, + onOverview, + onRestoreOrigin, + }); + const hops = all('.v10-breadcrumb-hop'); + // 2 hops, no middle. + expect(hops.map(h => h.textContent)).toEqual(['Hello World', 'Entity B']); + // First hop is a clickable BUTTON (not a current span). + expect(hops[0].tagName).toBe('BUTTON'); + await act(async () => { hops[0].dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + // Restores the origin; does NOT fire a fresh overview nav. + expect(onRestoreOrigin).toHaveBeenCalledTimes(1); + expect(onOverview).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/node-ui/test/context-graph-empty-stat-components.test.ts b/packages/node-ui/test/context-graph-empty-stat-components.test.ts index b6e63d992..7a2f68881 100644 --- a/packages/node-ui/test/context-graph-empty-stat-components.test.ts +++ b/packages/node-ui/test/context-graph-empty-stat-components.test.ts @@ -586,4 +586,45 @@ describe('Context Graph shared empty/stat patterns', () => { await unmount(); }); + + // Codex round-1 finding 4 — the row's onKeyDown bubbles, so Enter/Space + // on the nested promote button must NOT also open the detail. The guard + // (`ev.target !== ev.currentTarget → return`) restricts row activation + // to the row itself. + it('Enter on the promote button does not open the detail; Enter on the row does (keyboard guard)', async () => { + apiMocks.listAssertions.mockResolvedValueOnce([ + { name: 'kbd-doc', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/kbd-doc', tripleCount: 4 }, + ]); + const onSelectAssertion = vi.fn(); + const { container, unmount } = await render( + React.createElement(AssertionsList, { + contextGraphId: 'cg-test', + layer: 'wm', + onComplete: vi.fn(), + onSelectAssertion, + }), + ); + await waitForText(container, 'kbd-doc'); + + const row = container.querySelector('.v10-item-row')!; + const promoteBtn = row.querySelector('button')!; + + // Enter on the nested promote button: bubbles to the row's onKeyDown, + // but the guard (target !== currentTarget) must suppress navigation. + await act(async () => { + promoteBtn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + expect(onSelectAssertion).not.toHaveBeenCalled(); + + // Enter on the ROW itself opens the detail. + await act(async () => { + row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + expect(onSelectAssertion).toHaveBeenCalledTimes(1); + // Codex round-11 (11-1) — the list forwards its own `layer` as the + // source layer (2nd arg) so the detail view seeds its badge/tone. + expect(onSelectAssertion).toHaveBeenCalledWith(expect.objectContaining({ name: 'kbd-doc' }), 'wm'); + + await unmount(); + }); }); diff --git a/packages/node-ui/test/ka-detail-label.test.ts b/packages/node-ui/test/ka-detail-label.test.ts index 163cf7b27..4b7862f98 100644 --- a/packages/node-ui/test/ka-detail-label.test.ts +++ b/packages/node-ui/test/ka-detail-label.test.ts @@ -89,8 +89,7 @@ describe('KADetailView navigation label', () => { vi.clearAllMocks(); }); - it('renders Back to Context Graph and calls onClose', async () => { - const onClose = vi.fn(); + it('S5 — no back button (the breadcrumb is the sole back-affordance)', async () => { await act(async () => { root.render( React.createElement(ProjectProfileContext.Provider, { value: profile }, @@ -100,22 +99,16 @@ describe('KADetailView navigation label', () => { allEntities: new Map([[entity.uri, entity]]), allTriples: [], onNavigate: vi.fn(), - onClose, contextGraphId: 'cg-test', onRefresh: vi.fn(), }))), ); }); - const back = query('.v10-ka-back'); - expect(back.textContent).toContain('Back to Context Graph'); - expect(back.textContent).not.toContain('Back to Project'); - - await act(async () => { - back.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(onClose).toHaveBeenCalledTimes(1); + // The old in-detail "← Back to Context Graph" button is gone — S5 + // moved the back-affordance to the persistent breadcrumb. + expect(document.querySelector('.v10-ka-back')).toBeNull(); + expect(document.body.textContent).not.toContain('Back to Context Graph'); }); it('uses layer-aware nouns in the detail header', async () => { @@ -129,7 +122,6 @@ describe('KADetailView navigation label', () => { allEntities: new Map([[testEntity.uri, testEntity]]), allTriples: [], onNavigate: vi.fn(), - onClose: vi.fn(), contextGraphId: 'cg-test', onRefresh: vi.fn(), }))), diff --git a/packages/node-ui/test/project-view-navigation.test.ts b/packages/node-ui/test/project-view-navigation.test.ts index 4cd46d47d..e05074a0e 100644 --- a/packages/node-ui/test/project-view-navigation.test.ts +++ b/packages/node-ui/test/project-view-navigation.test.ts @@ -39,6 +39,20 @@ function createEntities() { properties: new Map(), connections: [], }], + // Codex round-8 (8-3) — a MULTI-HOMED entity: lives in BOTH 'demo' + // and 'other'. When followed from a page already in one of its + // subgraphs, handleNavigate must NOT jump to the arbitrary + // `primarySubGraphOf` sibling — it should stay on the current one. + ['urn:entity:multihomed', { + uri: 'urn:entity:multihomed', + label: 'Multi-homed entity', + types: [], + trustLevel: 'working', + layers: new Set(['working']), + subGraphs: new Set(['demo', 'other']), + properties: new Map(), + connections: [], + }], ['urn:entity:overlap', { uri: 'urn:entity:overlap', label: 'Shared overlap', @@ -280,18 +294,52 @@ vi.mock('../src/ui/components/SubGraphBar.js', () => ({ })); vi.mock('../src/ui/views/project/components.js', () => ({ - ProjectHeaderStrip: ({ activeSubGraph }: { activeSubGraph: any }) => - React.createElement('div', { 'data-testid': 'active-subgraph' }, activeSubGraph?.slug ?? 'none'), + // S5 — the breadcrumb (in ProjectHeaderStrip) is now the sole back + // affordance: `detail-back` is wired to `onRestoreOrigin` (the + // breadcrumb middle-hop click = handleDetailClose) and + // `breadcrumb-overview` to the first-hop click. The detail views + // themselves no longer render a back button. + ProjectHeaderStrip: ({ activeLayer, activeSubGraph, detailLabel, onOverview, onRestoreOrigin }: { + activeLayer: string; + activeSubGraph: any; + detailLabel?: string | null; + onOverview: () => void; + onRestoreOrigin: () => void; + }) => + React.createElement('div', { 'data-testid': 'project-strip', 'data-layer': activeLayer, 'data-detail-label': detailLabel ?? '' }, + // Keep `active-subgraph` text == slug only (existing M2 tests + // assert its exact textContent); the breadcrumb buttons live in a + // sibling node so they don't pollute that assertion. + React.createElement('span', { 'data-testid': 'active-subgraph' }, activeSubGraph?.slug ?? 'none'), + React.createElement('button', { 'data-testid': 'detail-back', onClick: onRestoreOrigin }, 'breadcrumb restore'), + React.createElement('button', { 'data-testid': 'breadcrumb-overview', onClick: onOverview }, 'Context Graph')), LayerSwitcher: ({ active, onSwitch }: { active: string; onSwitch: (layer: string) => void }) => React.createElement('div', { 'data-testid': 'active-layer', 'data-layer': active }, React.createElement('button', { 'data-testid': 'switch-wm', onClick: () => onSwitch('wm') }, 'WM'), React.createElement('button', { 'data-testid': 'switch-swm', onClick: () => onSwitch('swm') }, 'SWM'), React.createElement('button', { 'data-testid': 'switch-subgraphs', onClick: () => onSwitch('graph-overview') }, 'Subgraphs')), - KADetailView: ({ entity, onNavigate, onClose }: { entity: any; onNavigate: (uri: string) => void; onClose: () => void }) => + // S5 — KADetailView no longer renders a back button (the breadcrumb + // is the sole back-affordance); the mock matches that shape. + KADetailView: ({ entity, onNavigate }: { entity: any; onNavigate: (uri: string) => void }) => React.createElement('section', { 'data-testid': 'entity-detail', 'data-entity': entity.uri, 'data-trust': entity.trustLevel, 'data-connections': String(entity.connections.length), 'data-subgraphs': [...entity.subGraphs].sort().join(',') }, React.createElement('div', {}, entity.label), React.createElement('button', { 'data-testid': 'open-related-entity', onClick: () => onNavigate('urn:entity:other') }, 'Open related'), - React.createElement('button', { 'data-testid': 'detail-back', onClick: onClose }, 'Back to Context Graph')), + // Codex round-8 (8-3) — follow a link to the MULTI-HOMED entity + // (subGraphs: {demo, other}). The prefer-current rule should keep + // activeSubGraph on whichever of those the page is already in. + React.createElement('button', { 'data-testid': 'open-multihomed-entity', onClick: () => onNavigate('urn:entity:multihomed') }, 'Open multi-homed')), + // S4 — assertion detail overlay. The mock surfaces the assertion + // identity + an "open entity from assertion" button so the + // mutually-exclusive overlay + close-to-origin behaviour can be + // asserted without the real fetch/render path. + AssertionDetailView: ({ assertion, onNavigate }: { assertion: any; onNavigate: (uri: string, layer?: 'wm' | 'swm' | 'vm') => void }) => + React.createElement('section', { 'data-testid': 'assertion-detail', 'data-assertion': assertion.graphUri, 'data-name': assertion.name, 'data-subgraph': assertion.subGraph ?? '' }, + React.createElement('div', {}, assertion.name), + React.createElement('button', { 'data-testid': 'assertion-open-entity', onClick: () => onNavigate('urn:entity:demo') }, 'Open entity from assertion'), + // Codex round-5 — open an entity that exists in MULTIPLE layers, + // forwarding the assertion's layer (wm) so the follow-on detail is + // WM-scoped, not the canonical (shared) version. + React.createElement('button', { 'data-testid': 'assertion-open-overlap-wm', onClick: () => onNavigate('urn:entity:overlap', 'wm') }, 'Open overlap entity (WM-scoped)')), SubGraphDetailView: ({ slug, activeTab = 'items', onTabChange, onSelectEntity, initialLayer, initialEnabledLayers, onEnabledLayersChange }: { slug: string; activeTab?: string; @@ -377,7 +425,11 @@ vi.mock('../src/ui/views/project/components.js', () => ({ onClick: () => onEnabledLayersChange?.(new Set(['working', 'shared', 'verified'])), }, 'all three'), React.createElement('div', { 'data-testid': 'subgraph-scroll', 'data-cg-scroll-key': `subgraph:${slug}:${activeTab}` }, - React.createElement('button', { 'data-testid': 'open-subgraph-entity', onClick: () => onSelectEntity('urn:entity:demo') }, 'Open demo entity'))); + React.createElement('button', { 'data-testid': 'open-subgraph-entity', onClick: () => onSelectEntity('urn:entity:demo') }, 'Open demo entity'), + // Codex round-8 (8-3) — open the multi-homed entity (subGraphs: + // {demo, other}) from whichever subgraph page we're on, exercising + // handleNavigate's prefer-current branch. + React.createElement('button', { 'data-testid': 'open-subgraph-multihomed', onClick: () => onSelectEntity('urn:entity:multihomed') }, 'Open multi-homed entity'))); }, ProjectOverviewCard: ({ onOpenPrimer, participants, participantsStatus, subGraphCount, subGraphFetchFailed }: { onOpenPrimer: () => void; @@ -408,18 +460,25 @@ vi.mock('../src/ui/views/project/components.js', () => ({ SubGraphExplorerHeader: () => React.createElement('div', { 'data-testid': 'subgraph-explorer-header' }, 'Subgraph Explorer'), ContextGraphQueryView: () => null, - LayerDetailView: ({ layer, activeTab, onTabChange, onSelectEntity, onNodeClick }: { + LayerDetailView: ({ layer, activeTab, onTabChange, onSelectEntity, onSelectAssertion, onNodeClick }: { layer: string; activeTab: string; onTabChange: (tab: string) => void; onSelectEntity: (uri: string) => void; + // Codex round-11 (11-1) — mirrors the real signature: the list forwards + // its own layer as the source layer. + onSelectAssertion?: (assertion: any, sourceLayer: 'wm' | 'swm') => void; onNodeClick: (node: any) => void; }) => React.createElement('section', { 'data-testid': 'layer-detail', 'data-layer': layer, 'data-tab': activeTab }, React.createElement('button', { 'data-testid': 'layer-tab-graph', onClick: () => onTabChange('graph') }, 'Graph'), + React.createElement('button', { 'data-testid': 'layer-tab-assertions', onClick: () => onTabChange('assertions') }, 'Assertions'), React.createElement('div', { 'data-testid': 'layer-scroll', 'data-cg-scroll-key': `layer:${layer}:${activeTab}` }, React.createElement('button', { 'data-testid': 'open-layer-entity', onClick: () => onSelectEntity('urn:entity:working') }, 'Open layer entity'), React.createElement('button', { 'data-testid': 'open-layer-overlap-entity', onClick: () => onSelectEntity('urn:entity:overlap') }, 'Open overlap entity'), + // S4 — open an assertion detail from the Assertions subtab. Forwards + // the mock's `layer` as the source layer (real lists do the same). + React.createElement('button', { 'data-testid': 'open-layer-assertion', onClick: () => onSelectAssertion?.({ name: 'demo-assertion', graphUri: 'did:dkg:context-graph:cg-test/assertion/0xabc/demo-assertion', subGraph: undefined }, layer === 'swm' ? 'swm' : 'wm') }, 'Open assertion'), React.createElement('button', { 'data-testid': 'open-layer-graph-node', onClick: () => onNodeClick({ id: 'urn:entity:overlap', trustLayer: layer }) }, 'Open graph node'))), })); @@ -654,28 +713,142 @@ describe('ProjectView entity detail navigation', () => { expect(query('overview-card').dataset.participantsStatus).toBe('loading'); }); - it('keeps the originating subgraph stable while following cross-subgraph entity links', async () => { + // M2 option (b) — ENABLED alongside S5. Following a link to an entity + // in a DIFFERENT sub-graph switches activeSubGraph (the breadcrumb + // makes the move visible); closing still returns to the ORIGINATING + // sub-graph page (the M2 origin captured at first open). (T14 / T15) + it('follows a cross-subgraph entity link → activeSubGraph switches; close returns to origin', async () => { await click('switch-subgraphs'); await click('select-subgraph-demo'); await click('subgraph-tab-graph'); expect(query('active-subgraph').textContent).toBe('demo'); expect(query('subgraph-detail').dataset.tab).toBe('graph'); + // Open an entity that lives in the CURRENT subgraph — no switch. await click('open-subgraph-entity'); expect(query('entity-detail').dataset.entity).toBe('urn:entity:demo'); expect(query('active-subgraph').textContent).toBe('demo'); + // Follow a link to urn:entity:other (subGraphs: {'other'}) — M2(b) + // switches activeSubGraph to 'other'; the breadcrumb reflects it via + // the active-subgraph mirror. await click('open-related-entity'); expect(query('entity-detail').dataset.entity).toBe('urn:entity:other'); - expect(query('active-subgraph').textContent).toBe('demo'); + expect(query('active-subgraph').textContent).toBe('other'); + // The breadcrumb's trailing hop tracks the followed entity. + expect(query('project-strip').dataset.detailLabel).toBe('Other entity'); + // Close — origin restore returns to the ORIGINATING subgraph (demo), + // not the followed-into one (other). (T15 — origin model intact.) await click('detail-back'); await flush(); - expect(query('subgraph-detail').dataset.slug).toBe('demo'); expect(query('subgraph-detail').dataset.tab).toBe('graph'); }); + // Codex round-8 (8-3) — prefer-current for a MULTI-HOMED entity. M2(b)'s + // switch target is `primarySubGraphOf` (first non-meta), which is + // arbitrary when the entity lives in several subgraphs. When the + // followed entity is ALREADY in the current subgraph, do NOT switch — + // staying put is least-surprising and avoids an arbitrary sibling jump. + // + // The discriminating setup: be on subgraph 'other' (the NON-primary of + // the multi-homed set {demo, other} — primarySubGraphOf returns 'demo', + // the insertion-first slug). Pre-8-3 the follow would switch to 'demo' + // (primary ≠ current); post-8-3 it stays on 'other' because the entity + // is already there. (If we tested from 'demo' the bug would hide, since + // primary == current → no switch even without the fix.) + it('following a multi-homed entity that is in the CURRENT (non-primary) subgraph does NOT switch (8-3 prefer-current)', async () => { + await click('switch-subgraphs'); + await click('select-subgraph-other'); + await flush(); + expect(query('active-subgraph').textContent).toBe('other'); + + await click('open-subgraph-multihomed'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:multihomed'); + // Stayed on 'other' — NOT switched to the arbitrary primary ('demo'). + expect(query('active-subgraph').textContent).toBe('other'); + }); + + // 8-3 symmetric guard — when the followed entity is NOT in the current + // subgraph at all, M2(b) MUST still switch (primarySubGraphOf is the + // must-move fallback that un-strands the entity). Already covered by the + // T14/T15 cross-subgraph test (urn:entity:other in {'other'} only); + // this pins the intent explicitly alongside the prefer-current case. + it('following an entity that is NOT in the current subgraph still switches (8-3 must-move fallback)', async () => { + await click('switch-subgraphs'); + await click('select-subgraph-demo'); + await flush(); + expect(query('active-subgraph').textContent).toBe('demo'); + + // urn:entity:other lives ONLY in {'other'} — not in 'demo'. The follow + // must switch to 'other' (the entity isn't on the current page). + await click('open-subgraph-entity'); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:demo'); + await click('open-related-entity'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:other'); + expect(query('active-subgraph').textContent).toBe('other'); + }); + + // Codex round-10 (10-1) — opening a detail from an UNPROFILED subgraph + // (profile.forSubGraph returns undefined for its slug) must NOT crash. The + // breadcrumb-origin name resolution in ProjectView calls + // `forSubGraph(originSubGraph)`; pre-fix it dereferenced `.displayName` on + // the undefined result, throwing during render. The fix optional-chains + // and degrades to the slug. Here we make 'demo' unprofiled, enter it, and + // open an entity (→ 'demo' becomes the detail origin) — the render must + // succeed and the detail must appear. + it('opening a detail from an unprofiled origin subgraph does not crash (10-1 null-guard)', async () => { + const originalForSubGraph = profile.forSubGraph; + // 'demo' has no profile binding now → forSubGraph('demo') === undefined. + // (The active-subgraph slug stays 'demo' internally; only the resolved + // binding is undefined, so the strip chrome degrades.) + profile.forSubGraph = (slug: string) => + slug === 'demo' ? undefined : originalForSubGraph(slug); + try { + await click('switch-subgraphs'); + await click('select-subgraph-demo'); + await flush(); + // The subgraph DETAIL view is on 'demo' (driven by the internal slug, + // not the missing binding) — proves we entered it without crashing. + expect(query('subgraph-detail').dataset.slug).toBe('demo'); + + // Open an entity in 'demo' → 'demo' becomes the detail origin. Pre-fix + // the render throws resolving the origin subgraph name + // (`forSubGraph('demo').displayName` on undefined). + await click('open-subgraph-entity'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:demo'); + // Breadcrumb still renders (didn't crash); origin name degrades to the + // slug. The strip is present. + expect(query('project-strip')).toBeTruthy(); + } finally { + profile.forSubGraph = originalForSubGraph; + } + }); + + // T15 — the M2 ORIGINAL behavior (no cross-subgraph involved) is + // untouched: open from a layer list → close → same layer / subtab / + // scroll, and opening from a plain layer does NOT spuriously jump into + // a subgraph just because the entity belongs to one. + it('opening an entity from a plain layer list does NOT switch into a subgraph (M2(b) guard)', async () => { + await click('switch-wm'); + // urn:entity:overlap has subGraphs {'demo'} but we open it from the + // WM layer list (no active subgraph) — must stay layer-scoped. + await click('open-layer-overlap-entity'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:overlap'); + // No subgraph page was entered. + expect(query('active-subgraph').textContent).toBe('none'); + + await click('detail-back'); + await flush(); + expect(query('layer-detail').dataset.layer).toBe('wm'); + expect(document.querySelector('[data-testid="subgraph-detail"]')).toBeNull(); + }); + it('clears stale detail origin when the selected entity disappears', async () => { await click('switch-swm'); await click('open-layer-overlap-entity'); @@ -1288,4 +1461,122 @@ describe('ProjectView entity detail navigation', () => { expect(query('subgraph-bar').dataset.enabledScope).toBe('shared,working'); }); + // ─── S4 — assertion detail navigation ────────────────────── + + it('clicking an assertion row opens the AssertionDetailView (T06 / T20)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + + await click('open-layer-assertion'); + await flush(); + + const detail = query('assertion-detail'); + expect(detail.dataset.name).toBe('demo-assertion'); + expect(detail.dataset.assertion).toBe('did:dkg:context-graph:cg-test/assertion/0xabc/demo-assertion'); + // The layer page is replaced by the overlay. + expect(document.querySelector('[data-testid="layer-detail"]')).toBeNull(); + // data-view on the page main reflects the assertion route. + expect(query('active-layer').dataset.layer).toBe('wm'); + }); + + it('closing the assertion detail via the breadcrumb restores layer + Assertions subtab (T07 / T16)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + await click('open-layer-assertion'); + await flush(); + expect(query('assertion-detail').dataset.name).toBe('demo-assertion'); + // The breadcrumb trailing hop reflects the open assertion. + expect(query('project-strip').dataset.detailLabel).toBe('demo-assertion'); + + // S5 — no back button on the detail; the breadcrumb middle-hop + // (mock `detail-back` → onRestoreOrigin → handleDetailClose) closes + // it back to the originating layer + Assertions subtab (T16: the + // SAME handleDetailClose serves the assertion overlay). + await click('detail-back'); + await flush(); + expect(document.querySelector('[data-testid="assertion-detail"]')).toBeNull(); + expect(query('layer-detail').dataset.layer).toBe('wm'); + expect(query('layer-detail').dataset.tab).toBe('assertions'); + }); + + // Codex round-7 (7-1) — the scroll-restore effect must fire for the + // assertion-detail close, not just the entity close. Assertions now + // participate in the M2 origin snapshot (`handleDetailClose` queues + // `origin.scroll` for either overlay), but pre-fix the effect's guard + // only watched `selectedUri`; closing an assertion back to the SAME + // layer it opened from changed none of the listed deps, so the queued + // scroll was never consumed. Mirrors the entity scroll-restore test + // (T07 / T16 covered layer + subtab; this closes the scroll gap). + it('restores scroll position when the assertion detail closes (T07 / T16 scroll parity)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + + const scroller = query('layer-scroll'); + scroller.scrollTop = 73; + + await click('open-layer-assertion'); + await flush(); + expect(query('assertion-detail').dataset.name).toBe('demo-assertion'); + + await click('detail-back'); + await flush(); + + expect(query('layer-detail').dataset.layer).toBe('wm'); + expect(query('layer-detail').dataset.tab).toBe('assertions'); + expect(query('layer-scroll').scrollTop).toBe(73); + }); + + it('opening an entity from the assertion detail replaces it (mutually exclusive overlays)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + await click('open-layer-assertion'); + await flush(); + expect(query('assertion-detail').dataset.name).toBe('demo-assertion'); + + await click('assertion-open-entity'); + await flush(); + // Entity detail takes over; the assertion overlay is gone. + expect(query('entity-detail').dataset.entity).toBe('urn:entity:demo'); + expect(document.querySelector('[data-testid="assertion-detail"]')).toBeNull(); + }); + + // Codex round-5 finding 2 — navigating from a WM-assertion entity that + // ALSO exists in SWM must open the WM-SCOPED detail (the assertion's + // layer), not the canonical/global version. urn:entity:overlap lives in + // {working, shared} with canonical trustLevel 'shared'; opening it with + // layer='wm' forwarded must resolve the WM slice (trust 'working'). + it('navigating from a WM-assertion entity (also in SWM) opens the WM-scoped detail, not canonical', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + await click('open-layer-assertion'); + await flush(); + expect(query('assertion-detail').dataset.name).toBe('demo-assertion'); + + await click('assertion-open-overlap-wm'); + await flush(); + expect(query('entity-detail').dataset.entity).toBe('urn:entity:overlap'); + // WM-scoped (working), NOT the canonical 'shared' — proves the + // assertion's layer flowed through handleNavigate's layerContext. + expect(query('entity-detail').dataset.trust).toBe('working'); + expect(query('entity-detail').textContent).toContain('Working overlap'); + }); + + it('switching layers from the assertion detail exits the overlay (no stranded detail)', async () => { + await click('switch-wm'); + await click('layer-tab-assertions'); + await flush(); + await click('open-layer-assertion'); + await flush(); + + await click('switch-swm'); + await flush(); + expect(document.querySelector('[data-testid="assertion-detail"]')).toBeNull(); + expect(query('layer-detail').dataset.layer).toBe('swm'); + }); + }); diff --git a/packages/node-ui/test/s5-back-affordance-removed.test.ts b/packages/node-ui/test/s5-back-affordance-removed.test.ts new file mode 100644 index 000000000..10947b128 --- /dev/null +++ b/packages/node-ui/test/s5-back-affordance-removed.test.ts @@ -0,0 +1,48 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// S5 (T13) — the in-detail "Back to Context Graph" affordance is gone; +// the persistent breadcrumb is the sole back-affordance. This is the +// grep guard from the handoff, run as a node-env source scan. +// +// Mirrors `grep -r "v10-ka-back\|Back to Context Graph" packages/node-ui/src` +// with one carve-out the handoff didn't anticipate: AgentProfileView's +// "← Back" button is an UNRELATED shell-level back (a sibling of the +// explicitly-protected PanelCenter "Back to Project") that legitimately +// reuses the `.v10-ka-back` class. So: +// - "Back to Context Graph" — must be ZERO across all of src. +// - `className="v10-ka-back"` — must be absent from the context-graph +// detail views (components.tsx); the class may still exist in +// AgentProfileView (out of scope) + styles.css (the class def). + +const here = fileURLToPath(new URL('.', import.meta.url)); +const SRC = resolve(here, '../src/ui'); + +function walk(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = resolve(dir, name); + if (statSync(full).isDirectory()) out.push(...walk(full)); + else if (/\.(ts|tsx)$/.test(name)) out.push(full); + } + return out; +} + +describe('S5 back-affordance removal (T13)', () => { + it('no "Back to Context Graph" label remains anywhere in node-ui/src', () => { + const offenders = walk(SRC).filter(f => readFileSync(f, 'utf8').includes('Back to Context Graph')); + expect(offenders).toEqual([]); + }); + + it('the context-graph detail components render no .v10-ka-back button', () => { + const components = readFileSync(resolve(SRC, 'views/project/components.tsx'), 'utf8'); + expect(components).not.toMatch(/className="v10-ka-back"/); + }); + + it('the breadcrumb separator (v10-project-strip-sep) is still defined (reused, not dropped)', () => { + const css = readFileSync(resolve(SRC, 'styles.css'), 'utf8'); + expect(css).toMatch(/\.v10-project-strip-sep\s*\{/); + }); +}); diff --git a/packages/node-ui/test/use-assertion-state.test.ts b/packages/node-ui/test/use-assertion-state.test.ts new file mode 100644 index 000000000..6d10e9e85 --- /dev/null +++ b/packages/node-ui/test/use-assertion-state.test.ts @@ -0,0 +1,350 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchAssertionState, fetchAssertionTriples, listAssertions } from '../src/ui/api.js'; + +// S4 — pins the `_meta`-scoped assertion-state read + the assertion +// data-graph triple read (T19 + the data-shape contract). Mocks the +// transport boundary (`globalThis.fetch`, used by api.ts's `post`) so +// the parser code-path runs verbatim — same pattern as +// `list-assertions.test.ts`. + +function jsonResponse(body: unknown) { + return { ok: true, status: 200, json: async () => body } as any; +} + +describe('fetchAssertionState — reads dkg:state + memoryLayer from _meta (T19)', () => { + let originalFetch: typeof globalThis.fetch | undefined; + let fetchMock: ReturnType; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as any; + }); + afterEach(() => { + if (originalFetch) globalThis.fetch = originalFetch; + }); + + function setBindings(bindings: unknown[]) { + fetchMock.mockResolvedValueOnce(jsonResponse({ result: { bindings } })); + } + + // Post-#864, `AssertionInfo.graphUri` is the DATA-GRAPH (partition) URI, + // NOT the lifecycle URN. `fetchAssertionState` must take that partition + // URI and reach `dkg:state` via the INVERSE `dkg:assertionGraph` link + // (`?lifecycle dkg:assertionGraph ` → `?lifecycle dkg:state`). + const PARTITION = 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'; + + it('queries /_meta keyed on the DATA-GRAPH URI via the inverse dkg:assertionGraph link (#864)', async () => { + setBindings([{ state: { value: 'created' }, layer: { value: 'WM' } }]); + await fetchAssertionState('cg-A', PARTITION); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sparql).toContain('did:dkg:context-graph:cg-A/_meta'); + // The subject is bound via the inverse link, NOT used directly as the + // lifecycle subject (feeding the partition URI to `dkg:state` directly + // would never match → "state unavailable" silent regression). + expect(body.sparql).toContain('assertionGraph'); + expect(body.sparql).toContain(`<${PARTITION}>`); + expect(body.sparql).toContain('?lifecycle'); + expect(body.sparql).toContain('state'); + expect(body.sparql).toContain('memoryLayer'); + // The partition URI must NOT be wrapped as the direct dkg:state subject. + expect(body.sparql).not.toContain(`<${PARTITION}> `); + }); + + it('returns the created/wm shape; assertionGraph echoes the input partition URI', async () => { + setBindings([{ + state: { value: 'created' }, + layer: { value: 'WM' }, + createdBy: { value: 'did:dkg:agent:0xabc' }, + }]); + const out = await fetchAssertionState('cg-A', PARTITION); + expect(out).toEqual({ + state: 'created', + layer: 'wm', + assertionGraph: PARTITION, // echoed input — the data graph to read triples from + createdBy: 'did:dkg:agent:0xabc', + }); + }); + + it('maps SWM / VM memoryLayer literals to swm / vm', async () => { + setBindings([{ state: { value: 'promoted' }, layer: { value: 'SWM' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('swm'); + setBindings([{ state: { value: 'published' }, layer: { value: 'VM' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('vm'); + }); + + it('derives the layer from the state when the memoryLayer literal is absent', async () => { + setBindings([{ state: { value: 'created' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('wm'); + setBindings([{ state: { value: 'promoted' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('swm'); + setBindings([{ state: { value: 'finalized' } }]); + expect((await fetchAssertionState('cg-A', PARTITION))!.layer).toBe('vm'); + }); + + it('returns null when no lifecycle entity links to this data graph', async () => { + setBindings([]); + expect(await fetchAssertionState('cg-A', PARTITION)).toBeNull(); + }); + + it('returns null when the binding has no state literal', async () => { + setBindings([{ layer: { value: 'WM' } }]); + expect(await fetchAssertionState('cg-A', PARTITION)).toBeNull(); + }); +}); + +describe('fetchAssertionTriples — reads the assertion data graph', () => { + let originalFetch: typeof globalThis.fetch | undefined; + let fetchMock: ReturnType; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as any; + }); + afterEach(() => { + if (originalFetch) globalThis.fetch = originalFetch; + }); + + function setBindings(bindings: unknown[]) { + fetchMock.mockResolvedValueOnce(jsonResponse({ result: { bindings } })); + } + + it('queries the exact assertion data-graph URI', async () => { + setBindings([]); + await fetchAssertionTriples('cg-A', 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sparql).toContain('GRAPH '); + }); + + it('maps s/p/o bindings, preserving literal quoting on objects', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'http://schema.org/name' }, o: { type: 'literal', value: 'Battery cell' } }, + { s: { value: 'urn:e:1' }, p: { value: 'http://example/ref' }, o: { value: 'urn:e:2' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out).toHaveLength(2); + // Literal object is re-wrapped in quotes so downstream renderers + // classify it as a literal (not an IRI). + expect(out[0].object).toBe('"Battery cell"'); + // IRI object stays bare. + expect(out[1].object).toBe('urn:e:2'); + }); + + it('skips incomplete bindings', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { value: 'o' } }, + { s: { value: 'urn:e:2' }, p: { value: 'p' } }, // no ?o → skipped + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out).toHaveLength(1); + }); + + // Codex round-1 — rawBindingValue must (a) PRESERVE datatype/lang + // (the doc comment claimed it but the code dropped them) and (b) ESCAPE + // embedded `"`/`\` so a quote-containing literal stays well-formed and + // still classifies as a literal (leading `"`). + it('preserves datatype on a typed literal', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { type: 'typed-literal', value: '42', datatype: 'http://www.w3.org/2001/XMLSchema#integer' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out[0].object).toBe('"42"^^'); + }); + + it('preserves the language tag on a lang-tagged literal', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { type: 'literal', value: 'bonjour', 'xml:lang': 'fr' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out[0].object).toBe('"bonjour"@fr'); + }); + + it('escapes embedded quotes and backslashes in a literal', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { type: 'literal', value: 'say "hi"\\done' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + // Backslash → \\, quote → \" ; still starts with `"` (literal marker). + expect(out[0].object).toBe('"say \\"hi\\"\\\\done"'); + expect(out[0].object.startsWith('"')).toBe(true); + }); + + // Codex round-6 — control chars (raw newline/tab/CR + other C0) in a + // multiline extracted literal must be N-Triples-escaped, else the value + // is invalid N-Triples and breaks the graph/triple parsers. + it('escapes control chars (newline / tab / CR / other C0) in a literal', async () => { + // Build the control chars + backslash at RUNTIME (fromCharCode) so the + // test SOURCE holds no raw control bytes / ambiguous escapes. + const NL = String.fromCharCode(10), TAB = String.fromCharCode(9), CR = String.fromCharCode(13), BELL = String.fromCharCode(7); + const BS = String.fromCharCode(92); // backslash + const raw = 'line1' + NL + 'line2' + TAB + 'col' + CR + 'end' + BELL + 'bell'; + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'p' }, o: { type: 'literal', value: raw } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + // Expected: two-char escapes BS+n / BS+t / BS+r and the BS+u0007 form. + const expected = '"' + 'line1' + BS + 'n' + 'line2' + BS + 't' + 'col' + BS + 'r' + 'end' + BS + 'u0007' + 'bell' + '"'; + expect(out[0].object).toBe(expected); + // No RAW control bytes survive in the output. + // eslint-disable-next-line no-control-regex + expect(out[0].object).not.toMatch(new RegExp('[\\u0000-\\u001F]')); + expect(out[0].object.startsWith('"')).toBe(true); + }); + + // Codex round-5 — a SPARQL-JSON blank node must come back as `_:` + // (the form `useMemoryEntities.isUri` recognises as a resource), NOT the + // bare identifier (which is neither a leading-`"` literal nor an + // IRI-with-scheme → misclassified, bnode RDF structure lost). + it('renders a blank-node object as _: (resource, not literal/bare)', async () => { + setBindings([ + { s: { value: 'urn:e:1' }, p: { value: 'http://example/part' }, o: { type: 'bnode', value: 'b0' } }, + ]); + const out = await fetchAssertionTriples('cg-A', 'urn:g'); + expect(out[0].object).toBe('_:b0'); + // Sanity: it does NOT look like a literal (no leading quote). + expect(out[0].object.startsWith('"')).toBe(false); + }); +}); + +// Make-or-break (#864 rebase): the REAL list→detail data flow, NOT a +// state-fetch mock. `listAssertions(wm)` now yields the partition URI as +// `graphUri`; feeding THAT into `fetchAssertionState` must still resolve +// `dkg:state` (via the inverse dkg:assertionGraph link). A direct-subject +// query would silently return null → "state unavailable" for every WM +// assertion. This drives both fns against the real fetch transport with +// the post-#864 partition shape end-to-end. +describe('listAssertions(wm) partition graphUri → fetchAssertionState resolves state (#864 regression guard)', () => { + let originalFetch: typeof globalThis.fetch | undefined; + let fetchMock: ReturnType; + + beforeEach(() => { + originalFetch = globalThis.fetch; + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as any; + }); + afterEach(() => { + if (originalFetch) globalThis.fetch = originalFetch; + }); + + it('the graphUri from listAssertions(wm) feeds fetchAssertionState and hydrates created/wm', async () => { + const PARTITION = 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'; + + // Call 1 — listAssertions(wm): #864 partition enumeration. ?g is the + // DATA-GRAPH (partition) URI; ?cnt the triple count. + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ g: { value: PARTITION }, cnt: { value: '5' } }] }, + })); + const rows = await listAssertions('cg-A', 'wm'); + expect(rows).toHaveLength(1); + // Post-#864 the row's graphUri IS the partition URI (the regression risk). + expect(rows[0].graphUri).toBe(PARTITION); + expect(rows[0].tripleCount).toBe(5); + + // Call 2 — fetchAssertionState(graphUri): must resolve via the inverse + // dkg:assertionGraph link. The daemon returns the lifecycle entity's + // state for the bound `?lifecycle`. + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ state: { value: 'created' }, layer: { value: 'WM' } }] }, + })); + const stateInfo = await fetchAssertionState('cg-A', rows[0].graphUri); + + // NOT null → no "state unavailable" silent regression. + expect(stateInfo).not.toBeNull(); + expect(stateInfo!.state).toBe('created'); + expect(stateInfo!.layer).toBe('wm'); + // assertionGraph echoes the partition URI → the triples pane reads it. + expect(stateInfo!.assertionGraph).toBe(PARTITION); + + // The state query keyed on the partition URI via the inverse link. + const stateBody = JSON.parse(fetchMock.mock.calls[1][1].body); + expect(stateBody.sparql).toContain('assertionGraph'); + expect(stateBody.sparql).toContain(`<${PARTITION}>`); + }); + + // SWM rows carry a DIFFERENT graphUri shape: `listAssertions(swm)` sets + // graphUri = the LIFECYCLE URN (urn:dkg:assertion:…), not the data-graph + // URI. The SWM AssertionsList is click-through too (layer !== 'vm'), so + // fetchAssertionState must also resolve when fed the lifecycle URN + // directly. The UNION's first branch (input IS the lifecycle subject) + // handles that; the resolved dkg:assertionGraph gives the data graph to + // read triples from. + it('SWM lifecycle-URN graphUri also resolves state (UNION direct-subject branch)', async () => { + const LIFECYCLE = 'urn:dkg:assertion:cg-A:0xabc:notes'; + const DATA_GRAPH = 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'; + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ + state: { value: 'promoted' }, + layer: { value: 'SWM' }, + assertionGraph: { value: DATA_GRAPH }, // resolved off the lifecycle subject + }] }, + })); + const stateInfo = await fetchAssertionState('cg-A', LIFECYCLE); + expect(stateInfo).not.toBeNull(); + expect(stateInfo!.state).toBe('promoted'); + expect(stateInfo!.layer).toBe('swm'); + // For the lifecycle-URN input, assertionGraph comes from the resolved + // dkg:assertionGraph (so the triples pane reads the data graph, not the + // lifecycle URN). + expect(stateInfo!.assertionGraph).toBe(DATA_GRAPH); + // The query admits the lifecycle URN as the direct subject AND keys + // off dkg:assertionGraph (the UNION covers both shapes). + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.sparql).toContain(`<${LIFECYCLE}>`); + expect(body.sparql).toContain('UNION'); + expect(body.sparql).toContain('assertionGraph'); + }); + + // Codex round-3 finding 2 — an SWM input (lifecycle URN) whose + // dkg:assertionGraph did NOT resolve (legacy/partial _meta row) must + // NOT echo the URN as assertionGraph (that would make + // fetchAssertionTriples query `GRAPH ` — a bogus + // empty render). Return undefined → the panes show their empty-state. + it('SWM lifecycle-URN with UNRESOLVED dkg:assertionGraph → assertionGraph undefined (not the URN)', async () => { + const LIFECYCLE = 'urn:dkg:assertion:cg-A:0xabc:legacy-no-graph'; + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ state: { value: 'promoted' }, layer: { value: 'SWM' } }] }, // no assertionGraph + })); + const stateInfo = await fetchAssertionState('cg-A', LIFECYCLE); + expect(stateInfo).not.toBeNull(); + expect(stateInfo!.state).toBe('promoted'); + // Must NOT be the lifecycle URN; undefined so the triples pane is empty. + expect(stateInfo!.assertionGraph).toBeUndefined(); + }); + + // Counterpart — a WM data-graph-URI input with no resolved + // dkg:assertionGraph (it IS the data graph) keeps echoing itself. + it('WM data-graph-URI input with no resolved dkg:assertionGraph echoes the input (it IS the data graph)', async () => { + const PARTITION = 'did:dkg:context-graph:cg-A/assertion/0xabc/notes'; + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ state: { value: 'created' }, layer: { value: 'WM' } }] }, // no assertionGraph binding + })); + const stateInfo = await fetchAssertionState('cg-A', PARTITION); + expect(stateInfo!.assertionGraph).toBe(PARTITION); + }); + + // Codex round-4 — branch A must bind ?lifecycle UNCONDITIONALLY with the + // dkg:assertionGraph match OPTIONAL, so an SWM lifecycle-URN row whose + // _meta carries dkg:state but NOT dkg:assertionGraph (legacy/partial) + // still resolves its state. Pin the SPARQL shape so it can't regress to + // requiring the assertionGraph triple to bind the lifecycle subject. + it('SPARQL: branch A binds the input AS ?lifecycle unconditionally + OPTIONAL assertionGraph (round-4)', async () => { + const LIFECYCLE = 'urn:dkg:assertion:cg-A:0xabc:legacy-no-graph'; + fetchMock.mockResolvedValueOnce(jsonResponse({ + result: { bindings: [{ state: { value: 'promoted' }, layer: { value: 'SWM' } }] }, + })); + const stateInfo = await fetchAssertionState('cg-A', LIFECYCLE); + // State still resolves (the round-4 outcome) and assertionGraph stays + // undefined (round-3 guard composes). + expect(stateInfo!.state).toBe('promoted'); + expect(stateInfo!.assertionGraph).toBeUndefined(); + const sparql = JSON.parse(fetchMock.mock.calls[0][1].body).sparql as string; + // Unconditional BIND of the input as the lifecycle subject… + expect(sparql).toContain(`BIND(<${LIFECYCLE}> AS ?lifecycle)`); + // …with the assertionGraph match OPTIONAL (branch A no longer REQUIRES + // ` dkg:assertionGraph ?assertionGraph` to bind ?lifecycle). + expect(sparql).toMatch(/OPTIONAL\s*\{\s*\s*\s*\?assertionGraph\s*\}/); + }); +});