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:
@@ -9,7 +9,7 @@
|
|||||||
- **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z)
|
- **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z)
|
||||||
- **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed)
|
- **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed)
|
||||||
- **main_tip**: `999788b`
|
- **main_tip**: `999788b`
|
||||||
- **test_count**: 521 (prior 509 + 12 new PATCH-entity tests)
|
- **test_count**: 533 (prior 521 + 12 new wikilink/redlink tests)
|
||||||
- **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression)
|
- **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression)
|
||||||
- **vectors**: 33,253
|
- **vectors**: 33,253
|
||||||
- **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity)
|
- **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity)
|
||||||
@@ -160,6 +160,8 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
|
|||||||
|
|
||||||
## Session Log
|
## Session Log
|
||||||
|
|
||||||
|
- **2026-04-22 Claude (pm)** Issue B (wiki redlinks) landed — last remaining P2 from Antoine's sprint plan. `_wikilink_transform(text, current_project)` in `src/atocore/engineering/wiki.py` replaces `[[Name]]` / `[[Name|Display]]` tokens (pre-markdown) with HTML anchors. Resolution order: same-project exact-name match → live `wikilink`; other-project match → live link with `(in project X)` scope indicator (`wikilink-cross`); no match → `redlink` pointing at `/wiki/new?name=<quoted>&project=<current>`. New route `GET /wiki/new` renders a pre-filled "create this entity" form that POSTs to `/v1/entities` via a minimal inline fetch() and redirects to the new entity's wiki page on success. Transform applied in `render_project` (over the mirror markdown) and `render_entity` (over the description body). CSS: dashed-underline accent for live wikilinks, red italic + dashed for redlinks. 12 new tests including the regression from the spec (entity A references `[[EntityB]]` → initial render has `class="redlink"`; after EntityB is created, re-render no longer has redlink and includes `/wiki/entities/{b.id}`). Tests 521 → 533. All 6 acceptance criteria from the sprint plan ("daily-usable") now green: retract/supersede, edit without cloning, cross-project has a home, visual evidence, wiki readable, AKC can capture reliably.
|
||||||
|
|
||||||
- **2026-04-22 Claude** PATCH `/entities/{id}` + Issue D (/v1/engineering/* aliases) landed. New `update_entity()` in `src/atocore/engineering/service.py` supports partial updates to description (replace), properties (shallow merge — `null` value deletes a key), confidence (0..1, 400 on bounds violation), source_refs (append + dedup). Writes an `updated` audit row with full before/after snapshots. Forbidden via this path: entity_type / project / name / status — those require supersede+create or the dedicated status endpoints, by design. New route `PATCH /entities/{id}` aliased under `/v1`. Issue D: all 10 `/engineering/*` query paths (decisions, systems, components/{id}/requirements, changes, gaps + sub-paths, impact, evidence) added to the `/v1` allowlist. 12 new PATCH tests (merge, null-delete, confidence bounds, source_refs dedup, 404, audit row, v1 alias). Tests 509 → 521. Next: commit + deploy, then Issue B (wiki redlinks) as the last remaining P2 per Antoine's sprint order.
|
- **2026-04-22 Claude** PATCH `/entities/{id}` + Issue D (/v1/engineering/* aliases) landed. New `update_entity()` in `src/atocore/engineering/service.py` supports partial updates to description (replace), properties (shallow merge — `null` value deletes a key), confidence (0..1, 400 on bounds violation), source_refs (append + dedup). Writes an `updated` audit row with full before/after snapshots. Forbidden via this path: entity_type / project / name / status — those require supersede+create or the dedicated status endpoints, by design. New route `PATCH /entities/{id}` aliased under `/v1`. Issue D: all 10 `/engineering/*` query paths (decisions, systems, components/{id}/requirements, changes, gaps + sub-paths, impact, evidence) added to the `/v1` allowlist. 12 new PATCH tests (merge, null-delete, confidence bounds, source_refs dedup, 404, audit row, v1 alias). Tests 509 → 521. Next: commit + deploy, then Issue B (wiki redlinks) as the last remaining P2 per Antoine's sprint order.
|
||||||
|
|
||||||
- **2026-04-21 Claude (night)** Issue E (retraction path for active entities + memories) landed. Two new entity endpoints and two new memory endpoints, all aliased under `/v1`: `POST /entities/{id}/invalidate` (active→invalid, 200 idempotent on already-invalid, 409 if candidate/superseded, 404 if missing), `POST /entities/{id}/supersede` (active→superseded + auto-creates `supersedes` relationship from the new entity to the old one; rejects self-supersede and unknown superseded_by with 400), `POST /memory/{id}/invalidate`, `POST /memory/{id}/supersede`. `invalidate_memory`/`supersede_memory` in service.py now take a `reason` string that lands in the audit `note`. New service helper `invalidate_active_entity(id, reason)` returns `(ok, code)` where code is one of `invalidated | already_invalid | not_active | not_found` for a clean HTTP-status mapping. 15 new tests. Tests 494 → 509. Unblocks correction workflows — no more SQL required to retract mistakes.
|
- **2026-04-21 Claude (night)** Issue E (retraction path for active entities + memories) landed. Two new entity endpoints and two new memory endpoints, all aliased under `/v1`: `POST /entities/{id}/invalidate` (active→invalid, 200 idempotent on already-invalid, 409 if candidate/superseded, 404 if missing), `POST /entities/{id}/supersede` (active→superseded + auto-creates `supersedes` relationship from the new entity to the old one; rejects self-supersede and unknown superseded_by with 400), `POST /memory/{id}/invalidate`, `POST /memory/{id}/supersede`. `invalidate_memory`/`supersede_memory` in service.py now take a `reason` string that lands in the audit `note`. New service helper `invalidate_active_entity(id, reason)` returns `(ok, code)` where code is one of `invalidated | already_invalid | not_active | not_found` for a clean HTTP-status mapping. 15 new tests. Tests 494 → 509. Unblocks correction workflows — no more SQL required to retract mistakes.
|
||||||
|
|||||||
@@ -129,6 +129,13 @@ def wiki_capture() -> HTMLResponse:
|
|||||||
return HTMLResponse(content=render_capture())
|
return HTMLResponse(content=render_capture())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wiki/new", response_class=HTMLResponse)
|
||||||
|
def wiki_new_entity(name: str = "", project: str = "") -> HTMLResponse:
|
||||||
|
"""Issue B: "create this entity" form pre-filled from a redlink."""
|
||||||
|
from atocore.engineering.wiki import render_new_entity_form
|
||||||
|
return HTMLResponse(content=render_new_entity_form(name=name, project=project))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/wiki/memories/{memory_id}", response_class=HTMLResponse)
|
@router.get("/wiki/memories/{memory_id}", response_class=HTMLResponse)
|
||||||
def wiki_memory(memory_id: str) -> HTMLResponse:
|
def wiki_memory(memory_id: str) -> HTMLResponse:
|
||||||
"""Phase 7E: memory detail with audit trail + neighbors."""
|
"""Phase 7E: memory detail with audit trail + neighbors."""
|
||||||
|
|||||||
@@ -262,6 +262,10 @@ def render_project(project: str) -> str:
|
|||||||
from atocore.engineering.mirror import generate_project_overview
|
from atocore.engineering.mirror import generate_project_overview
|
||||||
|
|
||||||
markdown_content = generate_project_overview(project)
|
markdown_content = generate_project_overview(project)
|
||||||
|
# Resolve [[Wikilinks]] before markdown so redlinks / cross-project
|
||||||
|
# indicators appear in the rendered HTML. (Issue B)
|
||||||
|
markdown_content = _wikilink_transform(markdown_content, current_project=project)
|
||||||
|
|
||||||
# Convert entity names to links
|
# Convert entity names to links
|
||||||
entities = get_entities(project=project, limit=200)
|
entities = get_entities(project=project, limit=200)
|
||||||
html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"])
|
html_body = md.markdown(markdown_content, extensions=["tables", "fenced_code"])
|
||||||
@@ -277,6 +281,145 @@ def render_project(project: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
_WIKILINK_PATTERN = _re.compile(r"\[\[([^\[\]|]+?)(?:\|([^\[\]]+?))?\]\]")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_wikilink(target: str, current_project: str | None) -> tuple[str, str, str]:
|
||||||
|
"""Resolve a ``[[Name]]`` target to ``(href, css_class, extra_suffix)``.
|
||||||
|
|
||||||
|
Resolution order (Issue B):
|
||||||
|
|
||||||
|
1. Same-project exact name match → live link (class ``wikilink``).
|
||||||
|
2. Other-project exact name match → live link with ``(in project X)``
|
||||||
|
suffix (class ``wikilink wikilink-cross``).
|
||||||
|
3. No match → redlink pointing at ``/wiki/new?name=...`` so clicking
|
||||||
|
opens a pre-filled "create this entity" form (class ``redlink``).
|
||||||
|
"""
|
||||||
|
needle = target.strip()
|
||||||
|
if not needle:
|
||||||
|
return ("/wiki/new", "redlink", "")
|
||||||
|
|
||||||
|
same_project = None
|
||||||
|
cross_project = None
|
||||||
|
try:
|
||||||
|
candidates = get_entities(name_contains=needle, limit=200)
|
||||||
|
except Exception:
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
lowered = needle.lower()
|
||||||
|
for ent in candidates:
|
||||||
|
if ent.name.lower() != lowered:
|
||||||
|
continue
|
||||||
|
if current_project and ent.project == current_project:
|
||||||
|
same_project = ent
|
||||||
|
break
|
||||||
|
if cross_project is None:
|
||||||
|
cross_project = ent
|
||||||
|
|
||||||
|
if same_project is not None:
|
||||||
|
return (f"/wiki/entities/{same_project.id}", "wikilink", "")
|
||||||
|
if cross_project is not None:
|
||||||
|
suffix = (
|
||||||
|
f' <span class="wikilink-scope">(in {cross_project.project})</span>'
|
||||||
|
if cross_project.project
|
||||||
|
else ' <span class="wikilink-scope">(global)</span>'
|
||||||
|
)
|
||||||
|
return (f"/wiki/entities/{cross_project.id}", "wikilink wikilink-cross", suffix)
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
|
href = f"/wiki/new?name={quote(needle)}"
|
||||||
|
if current_project:
|
||||||
|
href += f"&project={quote(current_project)}"
|
||||||
|
return (href, "redlink", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _wikilink_transform(text: str, current_project: str | None) -> str:
|
||||||
|
"""Replace ``[[Name]]`` / ``[[Name|Display]]`` tokens with HTML anchors.
|
||||||
|
|
||||||
|
Runs before markdown rendering. Emits raw HTML which python-markdown
|
||||||
|
preserves unchanged.
|
||||||
|
"""
|
||||||
|
if not text or "[[" not in text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _sub(match: _re.Match) -> str:
|
||||||
|
target = match.group(1)
|
||||||
|
display = (match.group(2) or target).strip()
|
||||||
|
href, cls, suffix = _resolve_wikilink(target, current_project)
|
||||||
|
title = "create this entity" if cls == "redlink" else target.strip()
|
||||||
|
return (
|
||||||
|
f'<a href="{href}" class="{cls}" title="{_escape_attr(title)}">'
|
||||||
|
f'{_escape_html(display)}</a>{suffix}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return _WIKILINK_PATTERN.sub(_sub, text)
|
||||||
|
|
||||||
|
|
||||||
|
def render_new_entity_form(name: str = "", project: str = "") -> str:
|
||||||
|
"""Issue B — "create this entity" form targeted by redlinks."""
|
||||||
|
from atocore.engineering.service import ENTITY_TYPES
|
||||||
|
|
||||||
|
safe_name = _escape_attr(name or "")
|
||||||
|
safe_project = _escape_attr(project or "")
|
||||||
|
opts = "".join(
|
||||||
|
f'<option value="{t}">{t}</option>' for t in ENTITY_TYPES
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
'<h1>Create entity</h1>',
|
||||||
|
('<p>This entity was referenced via a wikilink but does not exist yet. '
|
||||||
|
'Fill in the details to create it — the wiki link will resolve on reload.</p>'),
|
||||||
|
'<form id="new-entity-form" class="new-entity-form">',
|
||||||
|
f'<label>Name<br><input type="text" name="name" value="{safe_name}" required></label>',
|
||||||
|
f'<label>Entity type<br><select name="entity_type" required>{opts}</select></label>',
|
||||||
|
f'<label>Project<br><input type="text" name="project" value="{safe_project}" '
|
||||||
|
f'placeholder="leave blank for cross-project / global"></label>',
|
||||||
|
'<label>Description<br><textarea name="description" rows="4"></textarea></label>',
|
||||||
|
'<button type="submit">Create</button>',
|
||||||
|
'<div id="new-entity-result" class="new-entity-result"></div>',
|
||||||
|
'</form>',
|
||||||
|
"""<script>
|
||||||
|
(function() {
|
||||||
|
const form = document.getElementById('new-entity-form');
|
||||||
|
const out = document.getElementById('new-entity-result');
|
||||||
|
form.addEventListener('submit', async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const fd = new FormData(form);
|
||||||
|
const body = {
|
||||||
|
name: fd.get('name'),
|
||||||
|
entity_type: fd.get('entity_type'),
|
||||||
|
project: fd.get('project') || '',
|
||||||
|
description: fd.get('description') || '',
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const r = await fetch('/v1/entities', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (r.ok) {
|
||||||
|
out.innerHTML = 'Created: <a href="/wiki/entities/' + j.id + '">' +
|
||||||
|
(j.name || 'new entity') + '</a>';
|
||||||
|
setTimeout(() => { window.location.href = '/wiki/entities/' + j.id; }, 800);
|
||||||
|
} else {
|
||||||
|
out.textContent = 'Error: ' + (j.detail || JSON.stringify(j));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.textContent = 'Network error: ' + e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>""",
|
||||||
|
]
|
||||||
|
return render_html(
|
||||||
|
f"Create {name}" if name else "Create entity",
|
||||||
|
"\n".join(lines),
|
||||||
|
breadcrumbs=[("Wiki", "/wiki"), ("Create entity", "")],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_visual_evidence(entity_id: str, ctx: dict) -> str:
|
def _render_visual_evidence(entity_id: str, ctx: dict) -> str:
|
||||||
"""Render EVIDENCED_BY → artifact links as an inline thumbnail strip."""
|
"""Render EVIDENCED_BY → artifact links as an inline thumbnail strip."""
|
||||||
from atocore.assets import get_asset
|
from atocore.assets import get_asset
|
||||||
@@ -397,7 +540,8 @@ def render_entity(entity_id: str) -> str | None:
|
|||||||
if ent.project:
|
if ent.project:
|
||||||
lines.append(f'<p>Project: <a href="/wiki/projects/{ent.project}">{ent.project}</a></p>')
|
lines.append(f'<p>Project: <a href="/wiki/projects/{ent.project}">{ent.project}</a></p>')
|
||||||
if ent.description:
|
if ent.description:
|
||||||
lines.append(f'<p>{ent.description}</p>')
|
desc_html = _wikilink_transform(ent.description, current_project=ent.project)
|
||||||
|
lines.append(f'<p>{desc_html}</p>')
|
||||||
if ent.properties:
|
if ent.properties:
|
||||||
lines.append('<h2>Properties</h2><ul>')
|
lines.append('<h2>Properties</h2><ul>')
|
||||||
for k, v in ent.properties.items():
|
for k, v in ent.properties.items():
|
||||||
@@ -917,6 +1061,17 @@ _TEMPLATE = """<!DOCTYPE html>
|
|||||||
.stat-row { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.9rem; margin: 0.4rem 0; }
|
.stat-row { display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.9rem; margin: 0.4rem 0; }
|
||||||
.stat-row span { padding: 0.1rem 0.4rem; background: var(--hover); border-radius: 4px; }
|
.stat-row span { padding: 0.1rem 0.4rem; background: var(--hover); border-radius: 4px; }
|
||||||
.meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; }
|
.meta { font-size: 0.8em; opacity: 0.5; margin-top: 0.5rem; }
|
||||||
|
.wikilink { color: var(--accent); text-decoration: none; border-bottom: 1px dashed transparent; }
|
||||||
|
.wikilink:hover { border-bottom-color: var(--accent); }
|
||||||
|
.wikilink-cross { border-bottom-style: dotted; }
|
||||||
|
.wikilink-scope { font-size: 0.75em; opacity: 0.6; font-style: italic; }
|
||||||
|
.redlink { color: #d0473d; text-decoration: none; font-style: italic; border-bottom: 1px dashed #d0473d; }
|
||||||
|
.redlink:hover { background: rgba(208, 71, 61, 0.08); }
|
||||||
|
.new-entity-form { display: flex; flex-direction: column; gap: 0.9rem; max-width: 520px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.2rem; }
|
||||||
|
.new-entity-form label { display: flex; flex-direction: column; font-size: 0.88rem; opacity: 0.8; }
|
||||||
|
.new-entity-form input, .new-entity-form select, .new-entity-form textarea { margin-top: 0.3rem; padding: 0.45rem 0.6rem; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font-size: 0.95rem; }
|
||||||
|
.new-entity-form button { align-self: flex-start; padding: 0.5rem 1.1rem; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; }
|
||||||
|
.new-entity-result { font-size: 0.9rem; opacity: 0.85; min-height: 1em; }
|
||||||
.evidence-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.75rem 0 1.25rem; }
|
.evidence-strip { display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.75rem 0 1.25rem; }
|
||||||
.evidence-tile { margin: 0; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.4rem; max-width: 270px; }
|
.evidence-tile { margin: 0; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.4rem; max-width: 270px; }
|
||||||
.evidence-tile img { display: block; max-width: 100%; height: auto; border-radius: 3px; }
|
.evidence-tile img { display: block; max-width: 100%; height: auto; border-radius: 3px; }
|
||||||
|
|||||||
132
tests/test_wikilinks.py
Normal file
132
tests/test_wikilinks.py
Normal 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
|
||||||
Reference in New Issue
Block a user