From 5c06b912e273e072a731845b80742a19a5ef81fc Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Tue, 31 Mar 2026 14:21:30 +0100 Subject: [PATCH 1/9] feat(desktop): OWL property characteristics (ONT-82) Parse and display owl:TransitiveProperty, owl:SymmetricProperty, owl:ReflexiveProperty, owl:FunctionalProperty, owl:InverseFunctionalProperty. Graph edges show compact T/S/R/F/IF badge rows; EdgeDetail panel shows full labels with tooltip help text. Order-independent parsing handles characteristic rdf:type triples before or after owl:ObjectProperty declaration. Co-Authored-By: Paperclip --- .../sample-ontologies/owl-characteristics.ttl | 77 ++++++ .../components/detail/CharacteristicBadge.tsx | 46 ++++ .../src/components/detail/EdgeDetail.tsx | 12 + .../graph/edges/ObjectPropertyEdge.tsx | 50 ++++ apps/desktop/src/renderer/src/globals.css | 6 + apps/desktop/src/renderer/src/model/quads.ts | 28 ++- .../src/renderer/src/model/reactflow.ts | 5 +- apps/desktop/src/renderer/src/model/types.ts | 8 + .../src/renderer/src/store/ontology.ts | 1 + .../components/CharacteristicBadge.test.tsx | 78 ++++++ .../tests/components/EdgeDetail.test.tsx | 148 ++++++++++++ .../components/ObjectPropertyEdge.test.tsx | 222 ++++++++++++++++++ .../tests/model/owl-characteristics.test.ts | 170 ++++++++++++++ 13 files changed, 848 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/resources/sample-ontologies/owl-characteristics.ttl create mode 100644 apps/desktop/src/renderer/src/components/detail/CharacteristicBadge.tsx create mode 100644 apps/desktop/tests/components/CharacteristicBadge.test.tsx create mode 100644 apps/desktop/tests/components/EdgeDetail.test.tsx create mode 100644 apps/desktop/tests/components/ObjectPropertyEdge.test.tsx create mode 100644 apps/desktop/tests/model/owl-characteristics.test.ts diff --git a/apps/desktop/resources/sample-ontologies/owl-characteristics.ttl b/apps/desktop/resources/sample-ontologies/owl-characteristics.ttl new file mode 100644 index 0000000..3b40903 --- /dev/null +++ b/apps/desktop/resources/sample-ontologies/owl-characteristics.ttl @@ -0,0 +1,77 @@ +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix ex: . + +# Ontology declaration +ex:CharacteristicsOntology rdf:type owl:Ontology . + +# Classes +ex:Person rdf:type owl:Class ; + rdfs:label "Person" . + +ex:Animal rdf:type owl:Class ; + rdfs:label "Animal" . + +ex:Thing rdf:type owl:Class ; + rdfs:label "Thing" . + +# Transitive property: ancestor +ex:hasAncestor rdf:type owl:ObjectProperty ; + rdf:type owl:TransitiveProperty ; + rdfs:label "has ancestor" ; + rdfs:domain ex:Person ; + rdfs:range ex:Person . + +# Symmetric property: sibling +ex:hasSibling rdf:type owl:ObjectProperty ; + rdf:type owl:SymmetricProperty ; + rdfs:label "has sibling" ; + rdfs:domain ex:Person ; + rdfs:range ex:Person . + +# Reflexive property: knows +ex:knows rdf:type owl:ObjectProperty ; + rdf:type owl:ReflexiveProperty ; + rdfs:label "knows" ; + rdfs:domain ex:Person ; + rdfs:range ex:Person . + +# Functional property: hasBiologicalMother +ex:hasBiologicalMother rdf:type owl:ObjectProperty ; + rdf:type owl:FunctionalProperty ; + rdfs:label "has biological mother" ; + rdfs:domain ex:Person ; + rdfs:range ex:Person . + +# InverseFunctional property: hasSSN +ex:hasSSN rdf:type owl:ObjectProperty ; + rdf:type owl:InverseFunctionalProperty ; + rdfs:label "has SSN" ; + rdfs:domain ex:Person ; + rdfs:range ex:Thing . + +# Multiple characteristics: transitiveAndFunctional +ex:hasUniqueAncestor rdf:type owl:ObjectProperty ; + rdf:type owl:TransitiveProperty ; + rdf:type owl:FunctionalProperty ; + rdfs:label "has unique ancestor" ; + rdfs:domain ex:Person ; + rdfs:range ex:Person . + +# All five characteristics on a single property +ex:hasAll rdf:type owl:ObjectProperty ; + rdf:type owl:TransitiveProperty ; + rdf:type owl:SymmetricProperty ; + rdf:type owl:ReflexiveProperty ; + rdf:type owl:FunctionalProperty ; + rdf:type owl:InverseFunctionalProperty ; + rdfs:label "has all" ; + rdfs:domain ex:Person ; + rdfs:range ex:Person . + +# Plain property (no characteristics) — for negative testing +ex:worksFor rdf:type owl:ObjectProperty ; + rdfs:label "works for" ; + rdfs:domain ex:Person ; + rdfs:range ex:Thing . diff --git a/apps/desktop/src/renderer/src/components/detail/CharacteristicBadge.tsx b/apps/desktop/src/renderer/src/components/detail/CharacteristicBadge.tsx new file mode 100644 index 0000000..a162ab6 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/detail/CharacteristicBadge.tsx @@ -0,0 +1,46 @@ +import type { OWLCharacteristic } from '@renderer/model/types'; +import { Badge } from '@/components/ui/badge'; + +interface CharInfo { + label: string; + tooltip: string; +} + +const CHAR_INFO: Record = { + transitive: { + label: 'Transitive', + tooltip: 'If A→B and B→C, then A→C is entailed.', + }, + symmetric: { + label: 'Symmetric', + tooltip: 'If A→B, then B→A is entailed.', + }, + reflexive: { + label: 'Reflexive', + tooltip: 'Every individual is related to itself.', + }, + functional: { + label: 'Functional', + tooltip: 'Each subject has at most one value.', + }, + inverseFunctional: { + label: 'Inv. Functional', + tooltip: 'Each value has at most one subject.', + }, +}; + +interface Props { + characteristic: OWLCharacteristic; +} + +export function CharacteristicBadge({ characteristic }: Props): React.JSX.Element { + const { label, tooltip } = CHAR_INFO[characteristic]; + return ( + + + {label} + + {tooltip} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/detail/EdgeDetail.tsx b/apps/desktop/src/renderer/src/components/detail/EdgeDetail.tsx index 4c12a18..a77bb0a 100644 --- a/apps/desktop/src/renderer/src/components/detail/EdgeDetail.tsx +++ b/apps/desktop/src/renderer/src/components/detail/EdgeDetail.tsx @@ -3,6 +3,7 @@ import { useOntologyStore } from '@renderer/store/ontology'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { CharacteristicBadge } from './CharacteristicBadge'; interface Props { property: ObjectProperty; @@ -38,6 +39,17 @@ export function EdgeDetail({ property }: Props): React.JSX.Element { /> + {property.characteristics.length > 0 && ( +
+

Characteristics

+
+ {property.characteristics.map((c) => ( + + ))} +
+
+ )} + {property.domain.length > 0 && (
Domain
diff --git a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx index 67cf798..b0805df 100644 --- a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx +++ b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx @@ -1,4 +1,5 @@ import type { ObjPropEdgeData } from '@renderer/model/reactflow'; +import type { OWLCharacteristic } from '@renderer/model/types'; import { useUIStore } from '@renderer/store/ui'; import { BaseEdge, @@ -13,6 +14,14 @@ import { getFloatingEdgeParams } from './floating-edge-utils'; type ObjPropEdge = Edge; +const CHAR_ABBREV: Record = { + transitive: { abbr: 'T', title: 'Transitive' }, + symmetric: { abbr: 'S', title: 'Symmetric' }, + reflexive: { abbr: 'R', title: 'Reflexive' }, + functional: { abbr: 'F', title: 'Functional' }, + inverseFunctional: { abbr: 'IF', title: 'Inverse Functional' }, +}; + function autoRotation(sx: number, sy: number, tx: number, ty: number): number { let angle = Math.atan2(ty - sy, tx - sx) * (180 / Math.PI); if (angle > 90 || angle < -90) angle += 180; @@ -52,6 +61,7 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({ const color = 'var(--graph-edge-property)'; const markerId = `objprop-arrow-${id}`; const rotation = autoRotation(sx, sy, tx, ty); + const uniqueCharacteristics = [...new Set(data?.characteristics ?? [])]; return ( <> @@ -94,6 +104,46 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({
)} + {uniqueCharacteristics.length > 0 && ( + +
+ {uniqueCharacteristics.map((c) => { + const { abbr, title } = CHAR_ABBREV[c]; + return ( + + {abbr} + + ); + })} +
+
+ )} ); }); diff --git a/apps/desktop/src/renderer/src/globals.css b/apps/desktop/src/renderer/src/globals.css index 95ede5c..406bd6d 100644 --- a/apps/desktop/src/renderer/src/globals.css +++ b/apps/desktop/src/renderer/src/globals.css @@ -54,6 +54,9 @@ --graph-edge-disjoint: oklch(0.60 0.12 15); --graph-edge-subclass: oklch(0.50 0.02 270); --graph-edge-restriction: oklch(0.65 0.15 280); + --characteristic-badge-bg: oklch(0.65 0.12 280 / 0.15); + --characteristic-badge-border: oklch(0.65 0.12 280 / 0.50); + --characteristic-badge-text: oklch(0.45 0.12 280); } .dark { @@ -103,6 +106,9 @@ --graph-edge-disjoint: oklch(0.65 0.12 15); --graph-edge-subclass: oklch(0.65 0.015 270); --graph-edge-restriction: oklch(0.65 0.15 280); + --characteristic-badge-bg: oklch(0.65 0.12 280 / 0.15); + --characteristic-badge-border: oklch(0.65 0.12 280 / 0.50); + --characteristic-badge-text: oklch(0.75 0.12 280); } @theme inline { diff --git a/apps/desktop/src/renderer/src/model/quads.ts b/apps/desktop/src/renderer/src/model/quads.ts index b65751b..a0229d2 100644 --- a/apps/desktop/src/renderer/src/model/quads.ts +++ b/apps/desktop/src/renderer/src/model/quads.ts @@ -6,6 +6,7 @@ import type { ObjectProperty, Ontology, OntologyClass, + OWLCharacteristic, Restriction, RestrictionType, } from './types'; @@ -51,12 +52,20 @@ function getOrCreateClass(ontology: Ontology, uri: string): OntologyClass { function getOrCreateObjectProperty(ontology: Ontology, uri: string): ObjectProperty { let prop = ontology.objectProperties.get(uri); if (!prop) { - prop = { uri, domain: [], range: [] }; + prop = { uri, domain: [], range: [], characteristics: [] }; ontology.objectProperties.set(uri, prop); } return prop; } +const OWL_CHARACTERISTIC_MAP: Record = { + [`${OWL}TransitiveProperty`]: 'transitive', + [`${OWL}SymmetricProperty`]: 'symmetric', + [`${OWL}ReflexiveProperty`]: 'reflexive', + [`${OWL}FunctionalProperty`]: 'functional', + [`${OWL}InverseFunctionalProperty`]: 'inverseFunctional', +}; + function getOrCreateDatatypeProperty(ontology: Ontology, uri: string): DatatypeProperty { let prop = ontology.datatypeProperties.get(uri); if (!prop) { @@ -107,6 +116,9 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu // Track declared types to distinguish ObjectProperty from DatatypeProperty const declaredTypes = new Map>(); + // Collect OWL characteristic type tokens per URI (order-independent) + const pendingCharacteristics = new Map(); + // First pass: collect type declarations for (const quad of quads) { const s = quad.subject.value; @@ -117,6 +129,12 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu if (!declaredTypes.has(s)) declaredTypes.set(s, new Set()); declaredTypes.get(s)?.add(o); + const charToken = OWL_CHARACTERISTIC_MAP[o]; + if (charToken) { + if (!pendingCharacteristics.has(s)) pendingCharacteristics.set(s, []); + pendingCharacteristics.get(s)?.push(charToken); + } + if (o === `${OWL}Class`) { getOrCreateClass(ontology, s); } else if (o === `${OWL}ObjectProperty`) { @@ -153,6 +171,14 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu } } + // Apply collected characteristics to ObjectProperties (handles order-independence) + for (const [uri, tokens] of pendingCharacteristics) { + const prop = ontology.objectProperties.get(uri); + if (prop) { + prop.characteristics = tokens; + } + } + // Restriction pass: collect blank nodes typed as owl:Restriction const restrictionBlankNodes = new Set(); const restrictionProps = new Map< diff --git a/apps/desktop/src/renderer/src/model/reactflow.ts b/apps/desktop/src/renderer/src/model/reactflow.ts index ca40aa1..1ba96eb 100644 --- a/apps/desktop/src/renderer/src/model/reactflow.ts +++ b/apps/desktop/src/renderer/src/model/reactflow.ts @@ -1,6 +1,6 @@ import type { Edge, Node } from '@xyflow/react'; import type { ValidationError } from '../services/validation'; -import type { Ontology, Restriction } from './types'; +import type { Ontology, OWLCharacteristic, Restriction } from './types'; export interface ClassNodeData extends Record { label: string; @@ -34,6 +34,7 @@ export type OntographNode = ClassNode | IndividualNode | GroupNode; export interface ObjPropEdgeData extends Record { label: string; uri: string; + characteristics?: OWLCharacteristic[]; } export interface SubClassEdgeData extends Record { @@ -151,7 +152,7 @@ export function ontologyToReactFlowElements( source: domainUri, target: rangeUri, type: 'objectProperty', - data: { label, uri: prop.uri }, + data: { label, uri: prop.uri, characteristics: prop.characteristics }, }); } } diff --git a/apps/desktop/src/renderer/src/model/types.ts b/apps/desktop/src/renderer/src/model/types.ts index 7c13cb2..3debce4 100644 --- a/apps/desktop/src/renderer/src/model/types.ts +++ b/apps/desktop/src/renderer/src/model/types.ts @@ -45,6 +45,13 @@ export interface OntologyClass { restrictions?: Restriction[]; } +export type OWLCharacteristic = + | 'transitive' + | 'symmetric' + | 'reflexive' + | 'functional' + | 'inverseFunctional'; + export interface ObjectProperty { uri: string; label?: string; @@ -54,6 +61,7 @@ export interface ObjectProperty { minCardinality?: number; maxCardinality?: number; inverseOf?: string; + characteristics: OWLCharacteristic[]; } export interface DatatypeProperty { diff --git a/apps/desktop/src/renderer/src/store/ontology.ts b/apps/desktop/src/renderer/src/store/ontology.ts index 2cb17f6..e7ac64b 100644 --- a/apps/desktop/src/renderer/src/store/ontology.ts +++ b/apps/desktop/src/renderer/src/store/ontology.ts @@ -184,6 +184,7 @@ export const useOntologyStore = create((set, get) => ({ uri, domain: [], range: [], + characteristics: [], ...partial, }); track('object_property_added'); diff --git a/apps/desktop/tests/components/CharacteristicBadge.test.tsx b/apps/desktop/tests/components/CharacteristicBadge.test.tsx new file mode 100644 index 0000000..4cf2a22 --- /dev/null +++ b/apps/desktop/tests/components/CharacteristicBadge.test.tsx @@ -0,0 +1,78 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +const { CharacteristicBadge } = await import('@renderer/components/detail/CharacteristicBadge'); + +describe('CharacteristicBadge', () => { + afterEach(cleanup); + + describe('label rendering', () => { + it('renders "Transitive" for transitive', () => { + render(); + expect(screen.getByText('Transitive')).toBeInTheDocument(); + }); + + it('renders "Symmetric" for symmetric', () => { + render(); + expect(screen.getByText('Symmetric')).toBeInTheDocument(); + }); + + it('renders "Reflexive" for reflexive', () => { + render(); + expect(screen.getByText('Reflexive')).toBeInTheDocument(); + }); + + it('renders "Functional" for functional', () => { + render(); + expect(screen.getByText('Functional')).toBeInTheDocument(); + }); + + it('renders "Inv. Functional" for inverseFunctional', () => { + render(); + expect(screen.getByText('Inv. Functional')).toBeInTheDocument(); + }); + }); + + describe('tooltip help text', () => { + it('transitive badge has tooltip describing inference', () => { + render(); + // The tooltip trigger element should carry the help text as accessible title or aria-label + // shadcn Tooltip wraps — check that tooltip content is present in DOM + expect(screen.getByText(/A→B.*B→C.*A→C/i)).toBeInTheDocument(); + }); + + it('symmetric badge has tooltip describing inference', () => { + render(); + expect(screen.getByText(/A→B.*B→A/i)).toBeInTheDocument(); + }); + + it('reflexive badge has tooltip describing self-relation', () => { + render(); + expect(screen.getByText(/every individual.*itself/i)).toBeInTheDocument(); + }); + + it('functional badge has tooltip describing single-value constraint', () => { + render(); + expect(screen.getByText(/at most one value/i)).toBeInTheDocument(); + }); + + it('inverseFunctional badge has tooltip describing single-subject constraint', () => { + render(); + expect(screen.getByText(/at most one subject/i)).toBeInTheDocument(); + }); + }); + + describe('visual structure', () => { + it('renders a Badge element (secondary variant class)', () => { + const { container } = render(); + // shadcn Badge with variant="secondary" renders with secondary styling + const badge = container.querySelector('[class*="secondary"], [data-slot="badge"]'); + expect(badge).not.toBeNull(); + }); + + it('renders exactly one badge per characteristic', () => { + render(); + expect(screen.getAllByText('Functional')).toHaveLength(1); + }); + }); +}); diff --git a/apps/desktop/tests/components/EdgeDetail.test.tsx b/apps/desktop/tests/components/EdgeDetail.test.tsx new file mode 100644 index 0000000..027f176 --- /dev/null +++ b/apps/desktop/tests/components/EdgeDetail.test.tsx @@ -0,0 +1,148 @@ +import { useOntologyStore } from '@renderer/store/ontology'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const { EdgeDetail } = await import('@renderer/components/detail/EdgeDetail'); + +const BASE_PROP = { + uri: 'http://ex/rel', + domain: [], + range: [], + characteristics: [] as import('@renderer/model/types').OWLCharacteristic[], +}; + +function resetStore() { + useOntologyStore.getState().reset(); +} + +describe('EdgeDetail — Characteristics section', () => { + beforeEach(resetStore); + afterEach(cleanup); + + describe('no characteristics', () => { + it('does not render Characteristics section when array is empty', () => { + render(); + expect(screen.queryByText('Characteristics')).not.toBeInTheDocument(); + }); + }); + + describe('single characteristic', () => { + it('renders Characteristics section heading when characteristics present', () => { + render( + , + ); + expect(screen.getByText('Characteristics')).toBeInTheDocument(); + }); + + it('shows Transitive badge', () => { + render( + , + ); + expect(screen.getByText('Transitive')).toBeInTheDocument(); + }); + + it('shows Symmetric badge', () => { + render( + , + ); + expect(screen.getByText('Symmetric')).toBeInTheDocument(); + }); + + it('shows Reflexive badge', () => { + render( + , + ); + expect(screen.getByText('Reflexive')).toBeInTheDocument(); + }); + + it('shows Functional badge', () => { + render( + , + ); + expect(screen.getByText('Functional')).toBeInTheDocument(); + }); + + it('shows Inv. Functional badge', () => { + render( + , + ); + expect(screen.getByText('Inv. Functional')).toBeInTheDocument(); + }); + }); + + describe('multiple characteristics', () => { + it('renders all provided characteristic badges', () => { + render( + , + ); + expect(screen.getByText('Transitive')).toBeInTheDocument(); + expect(screen.getByText('Symmetric')).toBeInTheDocument(); + expect(screen.getByText('Reflexive')).toBeInTheDocument(); + expect(screen.getByText('Functional')).toBeInTheDocument(); + expect(screen.getByText('Inv. Functional')).toBeInTheDocument(); + }); + + it('renders two characteristics without duplicates', () => { + render( + , + ); + expect(screen.getAllByText('Transitive')).toHaveLength(1); + expect(screen.getAllByText('Functional')).toHaveLength(1); + }); + }); + + describe('section ordering', () => { + it('Characteristics section appears before Domain section', () => { + render( + , + ); + const charHeading = screen.getByText('Characteristics'); + const domainHeading = screen.getByText('Domain'); + // compareDocumentPosition: 4 = DOCUMENT_POSITION_FOLLOWING (domain comes after characteristics) + expect( + charHeading.compareDocumentPosition(domainHeading) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + }); +}); diff --git a/apps/desktop/tests/components/ObjectPropertyEdge.test.tsx b/apps/desktop/tests/components/ObjectPropertyEdge.test.tsx new file mode 100644 index 0000000..1e2c318 --- /dev/null +++ b/apps/desktop/tests/components/ObjectPropertyEdge.test.tsx @@ -0,0 +1,222 @@ +import { useUIStore } from '@renderer/store/ui'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock @xyflow/react — component uses BaseEdge, EdgeLabelRenderer, getBezierPath, useInternalNode +vi.mock('@xyflow/react', () => ({ + BaseEdge: ({ id }: { id: string }) => , + EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => <>{children}, + getBezierPath: () => ['M0 0', 50, 50], + useInternalNode: vi.fn(() => ({ + internals: { + positionAbsolute: { x: 0, y: 0 }, + handleBounds: { source: [], target: [] }, + }, + measured: { width: 100, height: 40 }, + })), + Position: { Left: 'left', Top: 'top', Right: 'right', Bottom: 'bottom' }, +})); + +const { ObjectPropertyEdge } = await import('@renderer/components/graph/edges/ObjectPropertyEdge'); + +const EDGE_BASE = { + id: 'test-edge', + source: 'node-a', + target: 'node-b', + selected: false, + type: 'objectProperty', + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 0, + sourcePosition: 'right' as const, + targetPosition: 'left' as const, +}; + +function resetStore() { + useUIStore.setState({ selectedNodeId: null, adjacentEdgeIds: [] }); +} + +describe('ObjectPropertyEdge — characteristic badge row', () => { + beforeEach(resetStore); + afterEach(cleanup); + + describe('no characteristics', () => { + it('renders without badge row when characteristics is empty', () => { + render( + , + ); + expect(screen.queryByText('T')).not.toBeInTheDocument(); + expect(screen.queryByText('S')).not.toBeInTheDocument(); + expect(screen.queryByText('R')).not.toBeInTheDocument(); + expect(screen.queryByText('F')).not.toBeInTheDocument(); + expect(screen.queryByText('IF')).not.toBeInTheDocument(); + }); + + it('renders without badge row when characteristics is undefined (backwards compat)', () => { + render( + , + ); + expect(screen.queryByText('T')).not.toBeInTheDocument(); + }); + }); + + describe('single characteristic abbreviations', () => { + it('renders T badge for transitive', () => { + render( + , + ); + expect(screen.getByText('T')).toBeInTheDocument(); + }); + + it('renders S badge for symmetric', () => { + render( + , + ); + expect(screen.getByText('S')).toBeInTheDocument(); + }); + + it('renders R badge for reflexive', () => { + render( + , + ); + expect(screen.getByText('R')).toBeInTheDocument(); + }); + + it('renders F badge for functional', () => { + render( + , + ); + expect(screen.getByText('F')).toBeInTheDocument(); + }); + + it('renders IF badge for inverseFunctional', () => { + render( + , + ); + expect(screen.getByText('IF')).toBeInTheDocument(); + }); + }); + + describe('multiple characteristics', () => { + it('renders all five abbreviation badges', () => { + render( + , + ); + expect(screen.getByText('T')).toBeInTheDocument(); + expect(screen.getByText('S')).toBeInTheDocument(); + expect(screen.getByText('R')).toBeInTheDocument(); + expect(screen.getByText('F')).toBeInTheDocument(); + expect(screen.getByText('IF')).toBeInTheDocument(); + }); + + it('renders exactly one badge per characteristic — no duplicates', () => { + render( + , + ); + // Should deduplicate or only render once per unique characteristic + expect(screen.getAllByText('T')).toHaveLength(1); + }); + }); + + describe('tooltip on badges', () => { + it('T badge has title "Transitive"', () => { + render( + , + ); + const badge = screen.getByText('T'); + expect(badge.closest('[title]')?.getAttribute('title')).toBe('Transitive'); + }); + + it('IF badge has title "Inverse Functional"', () => { + render( + , + ); + const badge = screen.getByText('IF'); + expect(badge.closest('[title]')?.getAttribute('title')).toBe('Inverse Functional'); + }); + }); + + describe('dimming behavior', () => { + it('badge row container has same opacity as edge when not dimmed', () => { + useUIStore.setState({ selectedNodeId: null, adjacentEdgeIds: [] }); + render( + , + ); + const tBadge = screen.getByText('T'); + // The badge row should not have opacity: 0.15 when not dimmed + const badgeRow = tBadge.closest('div'); + const style = badgeRow?.style?.opacity; + expect(style).not.toBe('0.15'); + }); + + it('badge row dims when a different node is selected', () => { + useUIStore.setState({ selectedNodeId: 'node-c', adjacentEdgeIds: [] }); + render( + , + ); + // The wrapping container for the whole edge group should carry opacity 0.15 + const tBadge = screen.getByText('T'); + // Walk up until we find an element with opacity style + let el: HTMLElement | null = tBadge; + let foundDimmed = false; + while (el) { + if (el.style?.opacity === '0.15') { + foundDimmed = true; + break; + } + el = el.parentElement; + } + expect(foundDimmed).toBe(true); + }); + }); +}); diff --git a/apps/desktop/tests/model/owl-characteristics.test.ts b/apps/desktop/tests/model/owl-characteristics.test.ts new file mode 100644 index 0000000..b2d8c7c --- /dev/null +++ b/apps/desktop/tests/model/owl-characteristics.test.ts @@ -0,0 +1,170 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseTurtle, parseTurtleWithWarnings } from '@renderer/model/parse'; +import type { ObjectProperty } from '@renderer/model/types'; +import { describe, expect, it } from 'vitest'; + +const EX = 'http://example.org/characteristics#'; + +const characteristicsTurtle = readFileSync( + resolve(__dirname, '../../resources/sample-ontologies/owl-characteristics.ttl'), + 'utf-8', +); + +describe('OWL property characteristics parsing', () => { + it('parses without errors', () => { + const { warnings } = parseTurtleWithWarnings(characteristicsTurtle); + const errors = warnings.filter((w) => w.severity === 'error'); + expect(errors).toEqual([]); + }); + + describe('single characteristics', () => { + it('detects owl:TransitiveProperty', () => { + const ontology = parseTurtle(characteristicsTurtle); + const prop = ontology.objectProperties.get(`${EX}hasAncestor`) as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toContain('transitive'); + expect(prop.characteristics).toHaveLength(1); + }); + + it('detects owl:SymmetricProperty', () => { + const ontology = parseTurtle(characteristicsTurtle); + const prop = ontology.objectProperties.get(`${EX}hasSibling`) as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toContain('symmetric'); + expect(prop.characteristics).toHaveLength(1); + }); + + it('detects owl:ReflexiveProperty', () => { + const ontology = parseTurtle(characteristicsTurtle); + const prop = ontology.objectProperties.get(`${EX}knows`) as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toContain('reflexive'); + expect(prop.characteristics).toHaveLength(1); + }); + + it('detects owl:FunctionalProperty', () => { + const ontology = parseTurtle(characteristicsTurtle); + const prop = ontology.objectProperties.get(`${EX}hasBiologicalMother`) as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toContain('functional'); + expect(prop.characteristics).toHaveLength(1); + }); + + it('detects owl:InverseFunctionalProperty', () => { + const ontology = parseTurtle(characteristicsTurtle); + const prop = ontology.objectProperties.get(`${EX}hasSSN`) as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toContain('inverseFunctional'); + expect(prop.characteristics).toHaveLength(1); + }); + }); + + describe('multiple characteristics', () => { + it('detects two characteristics on one property', () => { + const ontology = parseTurtle(characteristicsTurtle); + const prop = ontology.objectProperties.get(`${EX}hasUniqueAncestor`) as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toHaveLength(2); + expect(prop.characteristics).toContain('transitive'); + expect(prop.characteristics).toContain('functional'); + }); + + it('detects all five characteristics on one property', () => { + const ontology = parseTurtle(characteristicsTurtle); + const prop = ontology.objectProperties.get(`${EX}hasAll`) as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toHaveLength(5); + expect(prop.characteristics).toContain('transitive'); + expect(prop.characteristics).toContain('symmetric'); + expect(prop.characteristics).toContain('reflexive'); + expect(prop.characteristics).toContain('functional'); + expect(prop.characteristics).toContain('inverseFunctional'); + }); + }); + + describe('no characteristics', () => { + it('returns empty characteristics array for plain property', () => { + const ontology = parseTurtle(characteristicsTurtle); + const prop = ontology.objectProperties.get(`${EX}worksFor`) as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toEqual([]); + }); + }); + + describe('existing ontology compatibility', () => { + it('properties parsed from people.ttl have empty characteristics array', () => { + const peopleTurtle = readFileSync( + resolve(__dirname, '../../resources/sample-ontologies/people.ttl'), + 'utf-8', + ); + const ontology = parseTurtle(peopleTurtle); + for (const prop of ontology.objectProperties.values()) { + expect(prop.characteristics).toBeDefined(); + expect(Array.isArray(prop.characteristics)).toBe(true); + expect(prop.characteristics).toEqual([]); + } + }); + }); + + describe('inline TTL snippets', () => { + it('handles property declared as ObjectProperty and characteristic in separate triples', () => { + const ttl = ` + @prefix owl: . + @prefix rdf: . + @prefix ex: . + ex:p rdf:type owl:ObjectProperty . + ex:p rdf:type owl:SymmetricProperty . + `; + const ontology = parseTurtle(ttl); + const prop = ontology.objectProperties.get('http://ex.org/p') as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toContain('symmetric'); + }); + + it('handles characteristic declared before ObjectProperty type triple', () => { + const ttl = ` + @prefix owl: . + @prefix rdf: . + @prefix ex: . + ex:p rdf:type owl:TransitiveProperty . + ex:p rdf:type owl:ObjectProperty . + `; + const ontology = parseTurtle(ttl); + const prop = ontology.objectProperties.get('http://ex.org/p') as ObjectProperty; + expect(prop).toBeDefined(); + expect(prop.characteristics).toContain('transitive'); + }); + + it('does not add characteristic tokens to non-object-properties', () => { + const ttl = ` + @prefix owl: . + @prefix rdf: . + @prefix rdfs: . + @prefix xsd: . + @prefix ex: . + ex:dp rdf:type owl:DatatypeProperty ; + rdfs:range xsd:string . + `; + const ontology = parseTurtle(ttl); + // Should not appear as an object property + expect(ontology.objectProperties.has('http://ex.org/dp')).toBe(false); + }); + + it('does not emit unsupported warning for characteristic type triples', () => { + const ttl = ` + @prefix owl: . + @prefix rdf: . + @prefix ex: . + ex:p rdf:type owl:ObjectProperty ; + rdf:type owl:TransitiveProperty ; + rdf:type owl:SymmetricProperty . + `; + const { warnings } = parseTurtleWithWarnings(ttl); + const unsupportedForP = warnings.filter( + (w) => w.message.includes('ex.org/p') && w.message.toLowerCase().includes('unsupported'), + ); + expect(unsupportedForP).toEqual([]); + }); + }); +}); From 918a3f972fefe767fcec13f0ca5865b65b21aa31 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Tue, 31 Mar 2026 15:02:52 +0100 Subject: [PATCH 2/9] fix(desktop): perpendicular badge offset on near-vertical edges (ONT-82) Characteristic badges now offset perpendicular to the edge direction instead of always 18px downward in screen-Y, preventing overlap on near-vertical edges. Co-Authored-By: Paperclip --- .../renderer/src/components/graph/edges/ObjectPropertyEdge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx index b0805df..a4a6e95 100644 --- a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx +++ b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx @@ -109,7 +109,7 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({
Date: Tue, 31 Mar 2026 15:38:55 +0100 Subject: [PATCH 3/9] fix(desktop): stagger characteristic badges on parallel/self-loop edges (ONT-82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple edges share the same source→target pair (e.g. self-loops on Person), each edge's badge row now offsets by an additional 14px in the perpendicular direction based on its sorted index, preventing all badge rows from stacking at the same screen position. Co-Authored-By: Paperclip --- .../components/graph/edges/ObjectPropertyEdge.tsx | 15 ++++++++++++++- .../tests/components/ObjectPropertyEdge.test.tsx | 3 ++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx index a4a6e95..2b47716 100644 --- a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx +++ b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx @@ -7,6 +7,7 @@ import { EdgeLabelRenderer, type EdgeProps, getBezierPath, + useEdges, useInternalNode, } from '@xyflow/react'; import { memo } from 'react'; @@ -39,6 +40,7 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({ const targetNode = useInternalNode(target); const selectedNodeId = useUIStore((s) => s.selectedNodeId); const adjacentEdgeIds = useUIStore((s) => s.adjacentEdgeIds); + const allEdges = useEdges(); if (!sourceNode || !targetNode) return null; @@ -63,6 +65,17 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({ const rotation = autoRotation(sx, sy, tx, ty); const uniqueCharacteristics = [...new Set(data?.characteristics ?? [])]; + // Stagger badge rows when multiple edges share the same source→target pair, + // so they don't overlap. Sort by id for a stable, deterministic order. + const peerEdges = allEdges + .filter((e) => e.source === source && e.target === target && e.data?.characteristics?.length) + .sort((a, b) => a.id.localeCompare(b.id)); + const badgeStagger = peerEdges.findIndex((e) => e.id === id); + const staggerDist = badgeStagger * 14; + const rotRad = rotation * (Math.PI / 180); + const badgeX = labelX + (18 + staggerDist) * Math.sin(rotRad); + const badgeY = labelY + (18 + staggerDist) * Math.cos(rotRad); + return ( <> @@ -109,7 +122,7 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({
({ BaseEdge: ({ id }: { id: string }) => , EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => <>{children}, @@ -14,6 +14,7 @@ vi.mock('@xyflow/react', () => ({ }, measured: { width: 100, height: 40 }, })), + useEdges: vi.fn(() => []), Position: { Left: 'left', Top: 'top', Right: 'right', Bottom: 'bottom' }, })); From 4479a34196f5733af0da43e74423159f8779fe61 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Tue, 31 Mar 2026 15:57:00 +0100 Subject: [PATCH 4/9] fix(desktop): hide characteristic badges on self-loop edges by default (ONT-82) Self-loop badges are hidden unless the edge is selected; detail panel shows characteristics regardless. Eliminates visual clutter on nodes with multiple reflexive/symmetric properties. Co-Authored-By: Paperclip --- .../src/components/graph/edges/ObjectPropertyEdge.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx index 2b47716..0664346 100644 --- a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx +++ b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx @@ -57,9 +57,9 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({ targetY: ty, targetPosition, }); - const isAdjacent = adjacentEdgeIds.includes(id); const isDimmed = selectedNodeId !== null && !isAdjacent; + const isSelfLoop = source === target; const color = 'var(--graph-edge-property)'; const markerId = `objprop-arrow-${id}`; const rotation = autoRotation(sx, sy, tx, ty); @@ -125,7 +125,7 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({ transform: `translate(-50%, -50%) translate(${badgeX}px, ${badgeY}px) rotate(${rotation}deg)`, display: 'flex', gap: 2, - opacity: isDimmed ? 0.15 : 1, + opacity: isSelfLoop && !selected ? 0 : isDimmed ? 0.15 : 1, transition: 'opacity 0.15s ease', pointerEvents: 'none', }} From 290e38d1f8ad326d526701735abd1ca36d5d4d0f Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Tue, 31 Mar 2026 15:59:49 +0100 Subject: [PATCH 5/9] fix(desktop): cast allEdges to ObjPropEdge[] to fix TS2339 (ONT-82) useEdges() returns Edge<{}> so e.data.characteristics was unknown. Cast to ObjPropEdge[] where the data type is known. Co-Authored-By: Paperclip --- .../renderer/src/components/graph/edges/ObjectPropertyEdge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx index 0664346..231dfeb 100644 --- a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx +++ b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx @@ -67,7 +67,7 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({ // Stagger badge rows when multiple edges share the same source→target pair, // so they don't overlap. Sort by id for a stable, deterministic order. - const peerEdges = allEdges + const peerEdges = (allEdges as ObjPropEdge[]) .filter((e) => e.source === source && e.target === target && e.data?.characteristics?.length) .sort((a, b) => a.id.localeCompare(b.id)); const badgeStagger = peerEdges.findIndex((e) => e.id === id); From ecca87266ea53b6cc9642c358123c6a7e00bb528 Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Tue, 31 Mar 2026 16:18:43 +0100 Subject: [PATCH 6/9] fix(desktop): reveal self-loop characteristic badges when adjacent node selected (ONT-82) isSelfLoop && !selected && !isAdjacent ? 0 : ... so selecting the source/target node reveals the R badge without requiring a click on the tiny self-loop SVG path. Co-Authored-By: Paperclip --- .../renderer/src/components/graph/edges/ObjectPropertyEdge.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx index 231dfeb..c795c7b 100644 --- a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx +++ b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx @@ -125,7 +125,7 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({ transform: `translate(-50%, -50%) translate(${badgeX}px, ${badgeY}px) rotate(${rotation}deg)`, display: 'flex', gap: 2, - opacity: isSelfLoop && !selected ? 0 : isDimmed ? 0.15 : 1, + opacity: isSelfLoop && !selected && !isAdjacent ? 0 : isDimmed ? 0.15 : 1, transition: 'opacity 0.15s ease', pointerEvents: 'none', }} From 6e51642a4b4174ed6230b4ddf41802cd2810b22d Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Tue, 31 Mar 2026 16:37:58 +0100 Subject: [PATCH 7/9] fix(desktop): enable pointer events on characteristic badge spans (ONT-82) Parent container keeps pointer-events:none so clicks pass through to the edge; individual badge elements opt back in with pointer-events:auto so their title tooltip is reachable on hover. Co-Authored-By: Paperclip --- .../renderer/src/components/graph/edges/ObjectPropertyEdge.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx index c795c7b..5131987 100644 --- a/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx +++ b/apps/desktop/src/renderer/src/components/graph/edges/ObjectPropertyEdge.tsx @@ -138,6 +138,7 @@ export const ObjectPropertyEdge = memo(function ObjectPropertyEdge({ key={c} title={title} style={{ + pointerEvents: 'auto', fontSize: 5, fontFamily: 'monospace', height: 10, From cee84104abb4e5f88113559238f2b36779362faa Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Tue, 31 Mar 2026 17:47:41 +0100 Subject: [PATCH 8/9] test(desktop): add ONT-83 class-expression QA fixtures Adds parser and class-detail spec tests plus OWL expression fixture for equivalentClass/unionOf/intersectionOf/complementOf and malformed-list scenarios. Co-Authored-By: Paperclip --- .../owl-class-expressions.ttl | 41 +++++++++++++ .../ClassDetail.class-expressions.test.tsx | 52 +++++++++++++++++ .../tests/model/owl-class-expressions.test.ts | 57 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 apps/desktop/resources/sample-ontologies/owl-class-expressions.ttl create mode 100644 apps/desktop/tests/components/ClassDetail.class-expressions.test.tsx create mode 100644 apps/desktop/tests/model/owl-class-expressions.test.ts diff --git a/apps/desktop/resources/sample-ontologies/owl-class-expressions.ttl b/apps/desktop/resources/sample-ontologies/owl-class-expressions.ttl new file mode 100644 index 0000000..cfdbbed --- /dev/null +++ b/apps/desktop/resources/sample-ontologies/owl-class-expressions.ttl @@ -0,0 +1,41 @@ +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix ex: . + +ex:A a owl:Class ; + owl:equivalentClass ex:B . + +ex:EqAnd a owl:Class ; + owl:equivalentClass [ + owl:intersectionOf ( ex:B ex:C ) + ] . + +ex:SubOr a owl:Class ; + rdfs:subClassOf [ + owl:unionOf ( ex:B ex:C ex:D ) + ] . + +ex:SubNot a owl:Class ; + rdfs:subClassOf [ + owl:complementOf ex:B + ] . + +ex:Nested a owl:Class ; + owl:equivalentClass [ + owl:unionOf ( + [ owl:intersectionOf ( ex:B ex:C ) ] + [ owl:complementOf ex:D ] + ) + ] . + +ex:Broken a owl:Class ; + owl:equivalentClass _:brokenExpr . + +_:brokenExpr owl:unionOf _:loop . +_:loop rdf:first ex:B ; + rdf:rest _:loop . + +ex:B a owl:Class . +ex:C a owl:Class . +ex:D a owl:Class . diff --git a/apps/desktop/tests/components/ClassDetail.class-expressions.test.tsx b/apps/desktop/tests/components/ClassDetail.class-expressions.test.tsx new file mode 100644 index 0000000..f88e0f1 --- /dev/null +++ b/apps/desktop/tests/components/ClassDetail.class-expressions.test.tsx @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { DetailPanel } from '@renderer/components/detail/DetailPanel'; +import { useOntologyStore } from '@renderer/store/ontology'; +import { useUIStore } from '@renderer/store/ui'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +const EX = 'http://example.org/expr#'; + +const classExpressionTurtle = readFileSync( + resolve(__dirname, '../../resources/sample-ontologies/owl-class-expressions.ttl'), + 'utf-8', +); + +function resetStores() { + useUIStore.setState({ selectedNodeId: null, selectedEdgeId: null }); + useOntologyStore.getState().reset(); +} + +describe('ClassDetail — class expression rendering (ONT-83)', () => { + beforeEach(() => { + resetStores(); + useOntologyStore + .getState() + .loadFromTurtle(classExpressionTurtle, 'Sample: owl-class-expressions'); + }); + + afterEach(cleanup); + + it('shows the Class Expressions section with operator badges', () => { + useUIStore.setState({ selectedNodeId: `${EX}Nested` }); + + render(); + + expect(screen.getByText('Class Expressions')).toBeInTheDocument(); + expect(screen.getByText('Logical definitions for this class')).toBeInTheDocument(); + expect(screen.getByText('OR')).toBeInTheDocument(); + expect(screen.getByText('AND')).toBeInTheDocument(); + expect(screen.getByText('NOT')).toBeInTheDocument(); + }); + + it('focuses a target class when a class-expression class chip is clicked', () => { + useUIStore.setState({ selectedNodeId: `${EX}A` }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'B' })); + + expect(useUIStore.getState().selectedNodeId).toBe(`${EX}B`); + }); +}); diff --git a/apps/desktop/tests/model/owl-class-expressions.test.ts b/apps/desktop/tests/model/owl-class-expressions.test.ts new file mode 100644 index 0000000..94116f0 --- /dev/null +++ b/apps/desktop/tests/model/owl-class-expressions.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { parseTurtleWithWarnings } from '@renderer/model/parse'; +import type { OntologyClass } from '@renderer/model/types'; +import { describe, expect, it } from 'vitest'; + +const EX = 'http://example.org/expr#'; + +const classExpressionTurtle = readFileSync( + resolve(__dirname, '../../resources/sample-ontologies/owl-class-expressions.ttl'), + 'utf-8', +); + +function getClassExpressions(cls: OntologyClass): unknown[] { + return ((cls as unknown as { classExpressions?: unknown[] }).classExpressions ?? []) as unknown[]; +} + +describe('parseTurtle — OWL class expressions (ONT-83)', () => { + it('does not create anonymous class-expression blank nodes as standalone classes', () => { + const { ontology } = parseTurtleWithWarnings(classExpressionTurtle); + const blankNodeClasses = [...ontology.classes.keys()].filter( + (k) => k.startsWith('_:') || k.startsWith('n3-'), + ); + + expect(blankNodeClasses).toEqual([]); + }); + + it('extracts equivalentClass expressions for named and anonymous targets', () => { + const { ontology } = parseTurtleWithWarnings(classExpressionTurtle); + const named = ontology.classes.get(`${EX}A`) as OntologyClass; + const anonymous = ontology.classes.get(`${EX}EqAnd`) as OntologyClass; + + expect(getClassExpressions(named).length).toBeGreaterThan(0); + expect(getClassExpressions(anonymous).length).toBeGreaterThan(0); + }); + + it('extracts unionOf/intersectionOf/complementOf with nesting', () => { + const { ontology } = parseTurtleWithWarnings(classExpressionTurtle); + const subOr = ontology.classes.get(`${EX}SubOr`) as OntologyClass; + const subNot = ontology.classes.get(`${EX}SubNot`) as OntologyClass; + const nested = ontology.classes.get(`${EX}Nested`) as OntologyClass; + + expect(getClassExpressions(subOr).length).toBeGreaterThan(0); + expect(getClassExpressions(subNot).length).toBeGreaterThan(0); + expect(getClassExpressions(nested).length).toBeGreaterThan(0); + }); + + it('emits warning for malformed RDF list/cycle in class expressions without crashing', () => { + const { ontology, warnings } = parseTurtleWithWarnings(classExpressionTurtle); + const broken = ontology.classes.get(`${EX}Broken`) as OntologyClass; + + expect(broken).toBeDefined(); + expect(warnings.some((w) => /class expression|rdf list|cycle|malformed/i.test(w.message))).toBe( + true, + ); + }); +}); From 4f27ba6d881e83c6b4b69b4f22097f1af2c70cec Mon Sep 17 00:00:00 2001 From: Dave Hudson Date: Tue, 31 Mar 2026 17:54:04 +0100 Subject: [PATCH 9/9] feat(desktop): support OWL class-expression parsing and detail rendering (ONT-83) Co-Authored-By: Paperclip --- .../src/components/detail/ClassDetail.tsx | 182 +++++++++++++++++- apps/desktop/src/renderer/src/model/quads.ts | 158 ++++++++++++++- apps/desktop/src/renderer/src/model/types.ts | 13 ++ .../src/renderer/src/store/ontology.ts | 29 ++- 4 files changed, 370 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/detail/ClassDetail.tsx b/apps/desktop/src/renderer/src/components/detail/ClassDetail.tsx index c8d7099..31ed98c 100644 --- a/apps/desktop/src/renderer/src/components/detail/ClassDetail.tsx +++ b/apps/desktop/src/renderer/src/components/detail/ClassDetail.tsx @@ -1,6 +1,13 @@ -import type { DatatypeProperty, OntologyClass, Restriction } from '@renderer/model/types'; +import type { + ClassExpression, + ClassExpressionAssertion, + DatatypeProperty, + OntologyClass, + Restriction, +} from '@renderer/model/types'; import { useOntologyStore } from '@renderer/store/ontology'; import { useUIStore } from '@renderer/store/ui'; +import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -44,6 +51,11 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { const updateDatatypeProperty = useOntologyStore((s) => s.updateDatatypeProperty); const ontology = useOntologyStore((s) => s.ontology); const setFocusNode = useUIStore((s) => s.setFocusNode); + const setSelectedNode = useUIStore((s) => s.setSelectedNode); + const focusClass = (uri: string): void => { + setSelectedNode(uri); + setFocusNode(uri); + }; const dtProps = Array.from(ontology.datatypeProperties.values()).filter((p) => p.domain.includes(cls.uri), @@ -92,7 +104,7 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { type="button" key={uri} className="text-xs bg-secondary rounded px-2 py-1 cursor-pointer hover:bg-accent transition-colors flex items-center gap-1.5 group" - onClick={() => setFocusNode(uri)} + onClick={() => focusClass(uri)} > {ontology.classes.get(uri)?.label || localName(uri)} @@ -136,7 +148,7 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { type="button" key={r} className="text-primary underline underline-offset-2 decoration-primary/40 hover:decoration-primary cursor-pointer transition-colors" - onClick={() => setFocusNode(r)} + onClick={() => focusClass(r)} > {ontology.classes.get(r)?.label || localName(r)} @@ -151,7 +163,7 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { type="button" key={d} className="text-primary underline underline-offset-2 decoration-primary/40 hover:decoration-primary cursor-pointer transition-colors" - onClick={() => setFocusNode(d)} + onClick={() => focusClass(d)} > {ontology.classes.get(d)?.label || localName(d)} @@ -173,12 +185,42 @@ export function ClassDetail({ cls }: Props): React.JSX.Element { key={`${r.onProperty}-${r.type}-${r.value}`} restriction={r} ontology={ontology} - onFocus={setFocusNode} + onFocus={focusClass} /> ))}
)} + + {cls.classExpressions && cls.classExpressions.length > 0 && ( +
+
+
Class Expressions
+
Logical definitions for this class
+
+
+ {(() => { + const seen = new Map(); + return cls.classExpressions.map((assertion) => { + const base = `${assertion.source}:${classExpressionKey(assertion.expression)}`; + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return ( + + ); + }); + })()} +
+
+ EQUIV = equivalent class · OR = union · AND = intersection · NOT = complement +
+
+ )} ); } @@ -280,6 +322,136 @@ function formatRestrictionLabel(r: Restriction): { text: string; targetUri?: str } } +function ClassExpressionRow({ + assertion, + ontology, + onFocus, +}: { + assertion: ClassExpressionAssertion; + ontology: import('@renderer/model/types').Ontology; + onFocus: (uri: string) => void; +}): React.JSX.Element { + return ( +
+
+ {assertion.source === 'equivalentClass' ? ( +
+ + EQUIV + + +
+ ) : ( + + )} +
+
+ ); +} + +function ClassExpressionTree({ + expression, + ontology, + onFocus, +}: { + expression: ClassExpression; + ontology: import('@renderer/model/types').Ontology; + onFocus: (uri: string) => void; +}): React.JSX.Element { + if (expression.kind === 'named') { + const label = ontology.classes.get(expression.uri)?.label || localName(expression.uri); + return ( + + ); + } + + if (expression.kind === 'unknown') { + return ( +
+ + Warning + + {expression.reason} +
+ ); + } + + if (expression.kind === 'complement') { + return ( +
+ + NOT + +
+ +
+
+ ); + } + + const operator = expression.kind === 'union' ? 'OR' : 'AND'; + return ( +
+ + {operator} + +
+ {(() => { + const seen = new Map(); + return expression.operands.map((operand) => { + const base = classExpressionKey(operand); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + return ( + + ); + }); + })()} +
+
+ ); +} + +function classExpressionKey(expression: ClassExpression): string { + switch (expression.kind) { + case 'named': + return `named:${expression.uri}`; + case 'union': + return `union(${expression.operands.map(classExpressionKey).join('|')})`; + case 'intersection': + return `intersection(${expression.operands.map(classExpressionKey).join('|')})`; + case 'complement': + return `complement(${classExpressionKey(expression.operand)})`; + case 'unknown': + return `unknown:${expression.reason}`; + default: + return expression satisfies never; + } +} + function RestrictionPill({ restriction, ontology, diff --git a/apps/desktop/src/renderer/src/model/quads.ts b/apps/desktop/src/renderer/src/model/quads.ts index a0229d2..b8a3306 100644 --- a/apps/desktop/src/renderer/src/model/quads.ts +++ b/apps/desktop/src/renderer/src/model/quads.ts @@ -1,6 +1,8 @@ import type { Quad } from 'n3'; import type { AnnotationProperty, + ClassExpression, + ClassExpressionAssertion, DatatypeProperty, Individual, ObjectProperty, @@ -93,6 +95,11 @@ function getOrCreateAnnotationProperty(ontology: Ontology, uri: string): Annotat return prop; } +function localName(uri: string): string { + const idx = Math.max(uri.lastIndexOf('#'), uri.lastIndexOf('/')); + return idx >= 0 ? uri.substring(idx + 1) : uri; +} + export interface ParseWarning { message: string; severity: 'error' | 'warning'; @@ -118,6 +125,7 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu // Collect OWL characteristic type tokens per URI (order-independent) const pendingCharacteristics = new Map(); + const quadsBySubject = new Map(); // First pass: collect type declarations for (const quad of quads) { @@ -125,6 +133,10 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu const p = quad.predicate.value; const o = quad.object.value; + const bySubject = quadsBySubject.get(s); + if (bySubject) bySubject.push(quad); + else quadsBySubject.set(s, [quad]); + if (p === `${RDF}type`) { if (!declaredTypes.has(s)) declaredTypes.set(s, new Set()); declaredTypes.get(s)?.add(o); @@ -135,7 +147,7 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu pendingCharacteristics.get(s)?.push(charToken); } - if (o === `${OWL}Class`) { + if (o === `${OWL}Class` && quad.subject.termType === 'NamedNode') { getOrCreateClass(ontology, s); } else if (o === `${OWL}ObjectProperty`) { getOrCreateObjectProperty(ontology, s); @@ -223,6 +235,123 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu } } + const expressionCache = new Map(); + + function pushWarning(message: string): void { + warnings.push({ severity: 'warning', message }); + } + + function ensureClassExpression(cls: OntologyClass, assertion: ClassExpressionAssertion): void { + if (!cls.classExpressions) cls.classExpressions = []; + cls.classExpressions.push(assertion); + } + + function parseExpressionTerm( + term: Quad['object'], + trace: Set, + context: string, + ): ClassExpression | null { + if (term.termType === 'NamedNode') { + return { kind: 'named', uri: term.value }; + } + if (term.termType !== 'BlankNode') { + pushWarning(`Malformed class expression for ${context}: unsupported term "${term.value}"`); + return { kind: 'unknown', reason: `Unsupported term ${term.termType}` }; + } + return parseExpressionNode(term.value, trace, context); + } + + function parseRdfList( + head: string, + trace: Set, + context: string, + visited: Set = new Set(), + ): ClassExpression[] | null { + if (head === `${RDF}nil`) return []; + if (visited.has(head)) { + pushWarning(`Malformed RDF list cycle in class expression for ${context}`); + return null; + } + visited.add(head); + + const listQuads = quadsBySubject.get(head) ?? []; + const firstQuads = listQuads.filter((q) => q.predicate.value === `${RDF}first`); + const restQuads = listQuads.filter((q) => q.predicate.value === `${RDF}rest`); + if (firstQuads.length === 0 || restQuads.length === 0) { + pushWarning(`Malformed RDF list in class expression for ${context}`); + return null; + } + + const first = parseExpressionTerm(firstQuads[0].object, trace, context); + if (!first) return null; + const rest = restQuads[0].object; + if (rest.termType !== 'NamedNode' && rest.termType !== 'BlankNode') { + pushWarning(`Malformed RDF list in class expression for ${context}`); + return null; + } + const tail = parseRdfList(rest.value, trace, context, visited); + if (!tail) return null; + return [first, ...tail]; + } + + function parseExpressionNode( + nodeId: string, + trace: Set, + context: string, + ): ClassExpression | null { + if (trace.has(nodeId)) { + pushWarning(`Malformed class expression cycle for ${context}`); + return null; + } + const cached = expressionCache.get(nodeId); + if (cached !== undefined) return cached; + + trace.add(nodeId); + const nodeQuads = quadsBySubject.get(nodeId) ?? []; + + const union = nodeQuads.find((q) => q.predicate.value === `${OWL}unionOf`); + if (union) { + const operands = parseRdfList(union.object.value, trace, context); + const expr = operands + ? ({ kind: 'union', operands } as ClassExpression) + : ({ kind: 'unknown', reason: 'Malformed unionOf list' } as ClassExpression); + expressionCache.set(nodeId, expr); + trace.delete(nodeId); + return expr; + } + + const intersection = nodeQuads.find((q) => q.predicate.value === `${OWL}intersectionOf`); + if (intersection) { + const operands = parseRdfList(intersection.object.value, trace, context); + const expr = operands + ? ({ kind: 'intersection', operands } as ClassExpression) + : ({ kind: 'unknown', reason: 'Malformed intersectionOf list' } as ClassExpression); + expressionCache.set(nodeId, expr); + trace.delete(nodeId); + return expr; + } + + const complement = nodeQuads.find((q) => q.predicate.value === `${OWL}complementOf`); + if (complement) { + const operand = parseExpressionTerm(complement.object, trace, context); + const expr = operand + ? ({ kind: 'complement', operand } as ClassExpression) + : ({ kind: 'unknown', reason: 'Malformed complementOf target' } as ClassExpression); + expressionCache.set(nodeId, expr); + trace.delete(nodeId); + return expr; + } + + pushWarning(`Malformed class expression node for ${context}: ${localName(nodeId)}`); + const expr: ClassExpression = { + kind: 'unknown', + reason: `Unsupported expression node ${nodeId}`, + }; + expressionCache.set(nodeId, expr); + trace.delete(nodeId); + return expr; + } + // Second pass: process properties and relationships for (const quad of quads) { const s = quad.subject.value; @@ -333,24 +462,39 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu } continue; } + if (quad.object.termType === 'BlankNode') { + const expr = parseExpressionTerm(quad.object, new Set(), s); + if (expr) { + const cls = getOrCreateClass(ontology, s); + ensureClassExpression(cls, { source: 'subClassOf', expression: expr }); + } + continue; + } const cls = getOrCreateClass(ontology, s); - getOrCreateClass(ontology, o); + if (quad.object.termType === 'NamedNode') getOrCreateClass(ontology, o); if (!cls.subClassOf.includes(o)) { cls.subClassOf.push(o); } continue; } + if (p === `${OWL}equivalentClass`) { + const cls = getOrCreateClass(ontology, s); + const expr = parseExpressionTerm(quad.object, new Set(), s); + if (expr) ensureClassExpression(cls, { source: 'equivalentClass', expression: expr }); + continue; + } + if (p === `${OWL}disjointWith`) { const cls = getOrCreateClass(ontology, s); - if (!cls.disjointWith.includes(o)) { + if (quad.object.termType === 'NamedNode' && !cls.disjointWith.includes(o)) { cls.disjointWith.push(o); } continue; } if (p === `${RDFS}domain`) { - getOrCreateClass(ontology, o); + if (quad.object.termType === 'NamedNode') getOrCreateClass(ontology, o); const objProp = ontology.objectProperties.get(s); const dtProp = ontology.datatypeProperties.get(s); if (objProp) { @@ -365,8 +509,10 @@ export function walkQuads(quads: Quad[], prefixes?: Map): WalkQu const objProp = ontology.objectProperties.get(s); const dtProp = ontology.datatypeProperties.get(s); if (objProp) { - getOrCreateClass(ontology, o); - if (!objProp.range.includes(o)) objProp.range.push(o); + if (quad.object.termType === 'NamedNode') { + getOrCreateClass(ontology, o); + if (!objProp.range.includes(o)) objProp.range.push(o); + } } else if (dtProp) { dtProp.range = o; } else if (isDatatypeURI(o)) { diff --git a/apps/desktop/src/renderer/src/model/types.ts b/apps/desktop/src/renderer/src/model/types.ts index 3debce4..e2966fc 100644 --- a/apps/desktop/src/renderer/src/model/types.ts +++ b/apps/desktop/src/renderer/src/model/types.ts @@ -36,6 +36,18 @@ export interface Restriction { value: string; } +export type ClassExpression = + | { kind: 'named'; uri: string } + | { kind: 'union'; operands: ClassExpression[] } + | { kind: 'intersection'; operands: ClassExpression[] } + | { kind: 'complement'; operand: ClassExpression } + | { kind: 'unknown'; reason: string }; + +export interface ClassExpressionAssertion { + source: 'equivalentClass' | 'subClassOf'; + expression: ClassExpression; +} + export interface OntologyClass { uri: string; label?: string; @@ -43,6 +55,7 @@ export interface OntologyClass { subClassOf: string[]; disjointWith: string[]; restrictions?: Restriction[]; + classExpressions?: ClassExpressionAssertion[]; } export type OWLCharacteristic = diff --git a/apps/desktop/src/renderer/src/store/ontology.ts b/apps/desktop/src/renderer/src/store/ontology.ts index e7ac64b..4a18eae 100644 --- a/apps/desktop/src/renderer/src/store/ontology.ts +++ b/apps/desktop/src/renderer/src/store/ontology.ts @@ -5,6 +5,7 @@ import { serializeToRdfXml } from '../model/formats/rdfxml'; import { type ParseWarning, parseTurtleWithWarnings } from '../model/parse'; import { serializeToTurtle } from '../model/serialize'; import type { + ClassExpression, DatatypeProperty, Individual, ObjectProperty, @@ -287,7 +288,16 @@ function cloneOntology(ontology: Ontology): Ontology { classes: new Map( Array.from(ontology.classes.entries()).map(([k, v]) => [ k, - { ...v, subClassOf: [...v.subClassOf], disjointWith: [...v.disjointWith] }, + { + ...v, + subClassOf: [...v.subClassOf], + disjointWith: [...v.disjointWith], + restrictions: v.restrictions?.map((r) => ({ ...r })), + classExpressions: v.classExpressions?.map((a) => ({ + source: a.source, + expression: cloneClassExpression(a.expression), + })), + }, ]), ), objectProperties: new Map( @@ -328,3 +338,20 @@ function cloneOntology(ontology: Ontology): Ontology { : undefined, }; } + +function cloneClassExpression(expr: ClassExpression): ClassExpression { + switch (expr.kind) { + case 'named': + return { kind: 'named', uri: expr.uri }; + case 'union': + return { kind: 'union', operands: expr.operands.map(cloneClassExpression) }; + case 'intersection': + return { kind: 'intersection', operands: expr.operands.map(cloneClassExpression) }; + case 'complement': + return { kind: 'complement', operand: cloneClassExpression(expr.operand) }; + case 'unknown': + return { kind: 'unknown', reason: expr.reason }; + default: + return expr satisfies never; + } +}