Files
semantica/explorer/tests/ontologyEditorModel.test.ts
T
a9f1b29292 fix(explorer): resolve ontology ownership on the backend for entity deep links (#1439)
* fix(explorer): resolve ontology ownership on the backend

Which registered ontology owns an entity was decided twice: the backend
applies nested-namespace boundaries, while the Ontology Editor did a bare
prefix match. The two had already drifted, so a deep link to an entity in an
unregistered nested namespace selected the parent ontology whose /graph
response excludes that entity, and the selection silently failed.

/api/ontology/entity now returns owning_ontology, resolved with the same rule
the graph endpoint filters by, and the editor prefers it. The frontend
namespace guess stays as the fallback for a missing verdict, documented as
non-authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(explorer): keep the backend's no-owner verdict authoritative

Review follow-up: loadOntologyEntityOwner collapsed the backend's
explicit owning_ontology: null into undefined, re-activating the
namespace prefix guess for exactly the unregistered-nested-namespace
case this PR exists to fix. The owner verdict is now three-state
(owner / authoritative none / unavailable) and resolveEditorOntology
in the model suppresses inference on an authoritative none; only an
unavailable verdict may fall back. Model tests pin all three states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(explorer): trust the backend to send owning_ontology

The Explorer bundle ships in the same wheel as the route that emits this
field, so the legacy-response branch could never run. Dropping it lets
the type say what the wire actually carries, leaving undefined to mean
only what it should: the request failed.

Note why the endpoint derives its ontology-URI set inline rather than
calling _known_ontology_uris, so the next reader does not consolidate a
graph scan back in.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(explorer): stop the registry default overriding a no-owner verdict

resolveEditorOntology returned string | undefined, so the caller wrote
`resolved || entries[0]?.uri` and an authoritative "nothing owns this
entity" fell straight through to an arbitrary registry entry. When that
entry happened to be the parent, the deep link opened the parent whose
graph excludes the entity — the bug the verdict exists to prevent. Only
the ordering of the registry in the earlier test hid it.

It now returns a union: unowned and unresolved both mean "no ontology to
open" but the editor treats them oppositely, so collapsing them with ||
is a type error rather than a silent regression. An unowned entity is
reported on the canvas instead of quietly opening the wrong ontology.

Also:

- /entity resolves ownership through _known_ontology_uris, the same
  helper /graph uses, instead of deriving it from a get_nodes scan capped
  at 999,999. Past that cap the set was silently truncated and the two
  endpoints could disagree about who owns a node.
- _resolve_owning_ontology does one pass over the candidates rather than
  one pass per candidate, each rescanning the whole set: 516us -> 9us at
  50 ontologies, 125ms -> 138us at 800, same answers throughout. A test
  pins it against _node_belongs_to_ontology so the hand-rolled version
  cannot drift from the membership rule it has to mirror.
- An explicit scheme_uri is honoured even when the registry does not list
  it, on both sides. Discarding it and guessing by namespace answered a
  question nobody asked; an unregistered owner now surfaces as an
  explicit error from /graph instead.
- A missing owning_ontology field reads as "no verdict", not as the
  authoritative "nothing owns this". That claim now suppresses selection
  outright, so it must not be inferred from an absent field.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sameer Kadam <sskadam6305@gmail.com>
Co-authored-by: Sameer Kadam <sameerkadam@Mac.lan>
2026-09-09 23:39:39 +05:30

115 lines
4.8 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
classifyNodeType,
compactNodeType,
inferOntologyUri,
isEditableEntityType,
resolveEditorOntology,
ONTOLOGY_MINIMAP_THEME,
} from "../src/workspaces/OntologyWorkspace/ontologyEditorModel";
const registry = [
{ uri: "https://example.test/foo", name: "Foo" },
{ uri: "https://example.test/foo/nested", name: "Nested" },
];
test("ontology inference requires a URI delimiter and prefers the closest namespace", () => {
assert.equal(inferOntologyUri(registry, "https://example.test/foobar/Class"), undefined);
assert.equal(
inferOntologyUri(registry, "https://example.test/foo/nested#Class"),
"https://example.test/foo/nested",
);
});
test("explicit scheme ownership wins when an entity uses another namespace", () => {
assert.equal(
inferOntologyUri(registry, "https://vocabulary.test/Class", "https://example.test/foo"),
"https://example.test/foo",
);
});
test("an explicit owner missing from the registry is used, not quietly replaced", () => {
// The entity sits under a registered namespace, so the guess has an answer
// ready; the backend naming a different, unregistered owner must still win,
// or the editor opens an ontology nobody said owned this entity.
assert.equal(
inferOntologyUri(
registry,
"https://example.test/foo#Class",
"https://unregistered.test/vocab",
),
"https://unregistered.test/vocab",
);
});
test("only draft-supported class and property nodes are editable", () => {
assert.equal(isEditableEntityType("class"), true);
assert.equal(isEditableEntityType("property"), true);
assert.equal(isEditableEntityType("ontology"), false);
assert.equal(isEditableEntityType("external"), false);
});
test("the ontology minimap has an explicit dark, high-contrast theme", () => {
assert.equal(ONTOLOGY_MINIMAP_THEME.bgColor, "#0b1625");
assert.equal(ONTOLOGY_MINIMAP_THEME.maskStrokeColor, "#5faeff");
assert.equal(ONTOLOGY_MINIMAP_THEME.nodeStrokeColor, "#9acbff");
assert.match(ONTOLOGY_MINIMAP_THEME.style.border, /#29435c/);
});
test("node types classify identically in compact and full IRI form", () => {
const cases: Array<[string, string, string]> = [
["owl:Ontology", "http://www.w3.org/2002/07/owl#Ontology", "ontology"],
["owl:Class", "http://www.w3.org/2002/07/owl#Class", "class"],
["rdfs:Class", "http://www.w3.org/2000/01/rdf-schema#Class", "class"],
["owl:ObjectProperty", "http://www.w3.org/2002/07/owl#ObjectProperty", "property"],
["owl:DatatypeProperty", "http://www.w3.org/2002/07/owl#DatatypeProperty", "property"],
["owl:AnnotationProperty", "http://www.w3.org/2002/07/owl#AnnotationProperty", "property"],
];
for (const [compact, fullIri, expected] of cases) {
assert.equal(classifyNodeType(compact), expected, compact);
assert.equal(classifyNodeType(fullIri), expected, fullIri);
}
assert.equal(classifyNodeType("owl:NamedIndividual"), "external");
assert.equal(classifyNodeType("http://www.w3.org/2004/02/skos/core#Concept"), "external");
});
test("compactNodeType leaves unknown namespaces untouched", () => {
assert.equal(compactNodeType("https://example.org/custom#Thing"), "https://example.org/custom#Thing");
assert.equal(compactNodeType("owl:Class"), "owl:Class");
});
test("an authoritative no-owner verdict suppresses the namespace guess", () => {
// Without suppression the prefix guess would pick the registered parent
// for an unregistered nested entity — the deep link must not do that.
const nested = "https://example.test/foo/unregistered#Term";
assert.deepEqual(resolveEditorOntology(registry, nested, null), {
status: "unowned",
entityUri: nested,
});
// An unavailable verdict may still fall back to inference
assert.deepEqual(
resolveEditorOntology(registry, "https://example.test/foo#Class", undefined),
{ status: "resolved", uri: "https://example.test/foo" },
);
// A named owner wins outright
assert.deepEqual(
resolveEditorOntology(registry, nested, "https://example.test/foo/nested"),
{ status: "resolved", uri: "https://example.test/foo/nested" },
);
});
test("unowned is distinguishable from unresolved, so neither collapses to a default", () => {
// Both mean "no ontology to open", and the editor treats them oppositely:
// unresolved may land on the registry default, unowned must not. A caller
// writing `resolve(...) || entries[0]` reintroduced exactly the parent
// selection this verdict exists to prevent, so the difference is typed.
const unowned = resolveEditorOntology(registry, "https://example.test/foo/x#T", null);
const unresolved = resolveEditorOntology(registry, "https://elsewhere.test/T", undefined);
assert.equal(unowned.status, "unowned");
assert.equal(unresolved.status, "unresolved");
assert.notEqual(unowned.status, unresolved.status);
});