feat(wiki): [[wikilinks]] with redlinks + cross-project resolver (Issue B)

Last P2 from Antoine's "daily-usable" sprint. Entities referenced via
[[Name]] in descriptions or mirror markdown now render as:

- live wikilink if the name matches an entity in the same project
- live cross-project link with "(in project X)" scope indicator if the
  only match is in another project
- red italic redlink pointing at /wiki/new?name=... otherwise

Clicking a redlink opens a pre-filled "create this entity" form that
POSTs to /v1/entities and redirects to the new entity's page.

- engineering/wiki.py: _wikilink_transform + _resolve_wikilink,
  applied in render_project (pre-markdown) and render_entity
  (description body). render_new_entity_form for the create page.
  CSS for .wikilink / .wikilink-cross / .redlink / .new-entity-form
- api/routes.py: GET /wiki/new?name&project
- tests/test_wikilinks.py: 12 tests including the spec regression
  (A references [[B]] -> redlink; create B -> link becomes live)
- DEV-LEDGER.md: session log + test_count 521 -> 533

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 09:15:14 -04:00
parent b94f9dff56
commit e147ab2abd
4 changed files with 298 additions and 2 deletions

132
tests/test_wikilinks.py Normal file
View File

@@ -0,0 +1,132 @@
"""Issue B — wikilinks with redlinks + cross-project resolution."""
import pytest
from fastapi.testclient import TestClient
from atocore.engineering.service import (
create_entity,
init_engineering_schema,
)
from atocore.engineering.wiki import (
_resolve_wikilink,
_wikilink_transform,
render_entity,
render_new_entity_form,
render_project,
)
from atocore.main import app
from atocore.models.database import init_db
@pytest.fixture
def env(tmp_data_dir, tmp_path, monkeypatch):
registry_path = tmp_path / "test-registry.json"
registry_path.write_text('{"projects": []}', encoding="utf-8")
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
from atocore import config
config.settings = config.Settings()
init_db()
init_engineering_schema()
yield tmp_data_dir
def test_resolve_wikilink_same_project_is_live(env):
tower = create_entity(entity_type="component", name="Tower", project="p05")
href, cls, _ = _resolve_wikilink("Tower", current_project="p05")
assert href == f"/wiki/entities/{tower.id}"
assert cls == "wikilink"
def test_resolve_wikilink_missing_is_redlink(env):
href, cls, suffix = _resolve_wikilink("DoesNotExist", current_project="p05")
assert "/wiki/new" in href
assert "name=DoesNotExist" in href
assert cls == "redlink"
def test_resolve_wikilink_cross_project_indicator(env):
other = create_entity(entity_type="material", name="Invar", project="p06")
href, cls, suffix = _resolve_wikilink("Invar", current_project="p05")
assert href == f"/wiki/entities/{other.id}"
assert "wikilink-cross" in cls
assert "in p06" in suffix
def test_resolve_wikilink_case_insensitive(env):
tower = create_entity(entity_type="component", name="Tower", project="p05")
href, cls, _ = _resolve_wikilink("tower", current_project="p05")
assert href == f"/wiki/entities/{tower.id}"
assert cls == "wikilink"
def test_transform_replaces_brackets_with_anchor(env):
create_entity(entity_type="component", name="Base Plate", project="p05")
out = _wikilink_transform("See [[Base Plate]] for details.", current_project="p05")
assert '<a href="/wiki/entities/' in out
assert 'class="wikilink"' in out
assert "[[Base Plate]]" not in out
def test_transform_redlink_for_missing(env):
out = _wikilink_transform("Mentions [[Ghost]] nowhere.", current_project="p05")
assert 'class="redlink"' in out
assert "/wiki/new?name=Ghost" in out
def test_transform_alias_syntax(env):
tower = create_entity(entity_type="component", name="Tower", project="p05")
out = _wikilink_transform("The [[Tower|big tower]] is tall.", current_project="p05")
assert f'href="/wiki/entities/{tower.id}"' in out
assert ">big tower<" in out
def test_render_entity_description_has_redlink(env):
a = create_entity(
entity_type="component",
name="EntityA",
project="p05",
description="This depends on [[MissingPart]] which does not exist.",
)
html = render_entity(a.id)
assert 'class="redlink"' in html
assert "/wiki/new?name=MissingPart" in html
def test_regression_redlink_becomes_live_once_target_created(env):
a = create_entity(
entity_type="component",
name="EntityA",
project="p05",
description="Connected to [[EntityB]].",
)
# Pre-create: redlink.
html_before = render_entity(a.id)
assert 'class="redlink"' in html_before
b = create_entity(entity_type="component", name="EntityB", project="p05")
html_after = render_entity(a.id)
assert 'class="redlink"' not in html_after
assert f"/wiki/entities/{b.id}" in html_after
def test_new_entity_form_prefills_name():
html = render_new_entity_form(name="FreshEntity", project="p05")
assert 'value="FreshEntity"' in html
assert 'value="p05"' in html
assert "entity_type" in html
assert 'method="post"' not in html # JS-driven
def test_wiki_new_route_renders(env):
client = TestClient(app)
r = client.get("/wiki/new?name=NewThing&project=p05")
assert r.status_code == 200
assert "NewThing" in r.text
assert "Create entity" in r.text
def test_wiki_new_url_escapes_special_chars(env):
# "steel (likely)" is the kind of awkward name AKC produces
href, cls, _ = _resolve_wikilink("steel (likely)", current_project="p05")
assert cls == "redlink"
assert "name=steel%20%28likely%29" in href