feat: ID-Vorschläge beim Tippen von Abhängigkeiten (:#) — D63

Wer :# tippt, bekommt die vergebenen IDs als Liste an der Schreibmarke:
Präfix- vor Teilstring-Treffern, Knotentitel als Kontext, schon gelistete
und die eigene ID ausgenommen. ↑/↓ wählt, Enter/Tab übernimmt (undo-fähig),
Esc schließt; Live-Region für Screenreader. Eingabehilfe wie die Kurzform
(D55) — der Parser sieht nie etwas davon. Regeln headless in autocomplete.js
(20 Tests), app.js verdrahtet Popup, Tasten und Einfügen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-24 17:13:18 +02:00
co-authored by Claude Fable 5
parent 9cb6ec1197
commit 1debb7d9da
9 changed files with 507 additions and 4 deletions
+9
View File
@@ -587,6 +587,15 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`src.value =` ist nur beim **Laden eines anderen** Dokuments richtig
(`loadActiveIntoEditor`, Dokumentwechsel, Pad-Abruf) — dorthin gibt es nichts
zurückzunehmen.
- **ID-Vorschläge `:#` (D63):** Die Regeln stehen headless in `autocomplete.js`
(`depFragment`/`collectIds`/`matchIds`, Tests); app.js verdrahtet nur Popup,
Tasten, Einfügen (`writeAt`, undo-fähig). Der Tasten-Handler hängt an
`document` in der **Capture-Phase** — die Textfeld-Handler (Tab rückt ein,
Esc löst die Tab-Falle, D53) sind früher registriert und kämen sonst zuerst;
`stopPropagation` hält sie nur bei **offener** Liste heraus. `acSuppress`
hält denselben Kontext nach Übernahme/Esc geschlossen — ohne das öffnet ihn
das nächste keyup sofort wieder. Popup auf `<body>` mit `position:fixed`
(Klipp-Falle D50, wie das Knoten-Fenster).
- **Undo lässt sich hier nicht per Tastendruck prüfen.** Ein synthetisches
`ctrl+z` aus der Automatisierung löst **kein** natives Undo aus (gemessen:
Text unverändert), während `document.execCommand('undo')` im selben Moment
+157 -3
View File
@@ -4,6 +4,7 @@ import { computeCheapPlan, freshProdSet, initialCollapsed, nodeKeys, effectiveSt
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js';
import { padUrls } from './remote.js';
import { depFragment, collectIds, matchIds } from './autocomplete.js';
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
from './snapshots.js';
/* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in
@@ -97,6 +98,11 @@ let foldOverrides = new Map(), foldByLine = new Map();
`nodeOfLine()` greift darauf zurück, wenn die Zeile keinen DOM-Knoten hat. */
let lineTargetMap = new Map();
/* Der zuletzt geparste Baum, UNGEFILTERT die ID-Vorschläge (D63) lesen
daraus die vergebenen IDs, und eine Abhängigkeit darf auch auf Verworfenes
zeigen. */
let acRoots = [];
/* ---------- Renderer (Anbindung an den DOM) ----------
parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren ->
render.js baut den HTML-String -> in #out schreiben -> Pfadlinie zeichnen. */
@@ -105,6 +111,7 @@ function render(){
und seine Position ist ohnehin gemessen, also gleich hinfällig. */
closeNodeTip();
const parsed = parse(src.value);
acRoots = parsed.roots;
let roots = parsed.roots;
const showDiscarded = discardedShown();
if(!showDiscarded){
@@ -847,15 +854,21 @@ function syncMirror(){
return mirrorEl;
}
const ZWSP = '';
function offsetTopInEditor(offset){
/* Position eines Zeichenoffsets im Spiegel `top` im Koordinatensystem von
`src.scrollTop`, `left` in dem von `src.scrollLeft` (Innenabstände stecken
je drin). Das `left` braucht nur die Vorschlagsliste (D63). */
function caretPosInEditor(offset){
const m = syncMirror();
m.textContent = src.value.slice(0, offset);
const marker = document.createElement('span');
marker.textContent = ZWSP;
m.appendChild(marker);
const top = marker.offsetTop;
const pos = {top: marker.offsetTop, left: marker.offsetLeft};
m.textContent = '';
return top;
return pos;
}
function offsetTopInEditor(offset){
return caretPosInEditor(offset).top;
}
/* Oberkante jeder **logischen** Zeile, im selben Koordinatensystem wie
@@ -1456,6 +1469,7 @@ function syncCaret(){
if(moved) resolveShortId(caretLine);
caretLine = caretLineOf(); /* neu lesen: das Auflösen kann den Text ändern */
highlightCurrentNode(moved);
updateAc(); /* ID-Vorschläge folgen der Schreibmarke (D63) */
}
for(const ev of ['click','keyup','input','focus']) src.addEventListener(ev, syncCaret);
@@ -1561,6 +1575,137 @@ window.addEventListener('keydown', e => { if(e.key === 'Alt') setAltMode(true);
window.addEventListener('keyup', e => { if(e.key === 'Alt' || !e.altKey) setAltMode(false); });
window.addEventListener('blur', () => setAltMode(false));
/* ---------- ID-Vorschläge beim Tippen von Abhängigkeiten (D63) ----------
Wer `:#` tippt, bekommt die vergebenen IDs als Liste an der Schreibmarke.
Die Regeln (wann ein Kontext vorliegt, welche IDs passen) stehen headless
in autocomplete.js; hier hängen nur Popup, Tasten und das Einfügen. Eine
Eingabehilfe wie die ID-Kurzform (D55): Der Parser sieht nie etwas davon,
und wer die Liste ignoriert, tippt einfach weiter. */
let acEl = null, acLiveEl = null, acItems = [], acIndex = 0, acCtx = null;
/* Nach Übernahme oder Esc bleibt DERSELBE Kontext zu sonst öffnete ihn das
nächste keyup sofort wieder. Weitertippen ändert das Fragment und löst ihn. */
let acSuppress = null;
function acBox(){
if(!acEl){
acEl = document.createElement('div');
acEl.className = 'aclist';
/* aria-hidden wie das Knoten-Fenster (D57): Das saubere Combobox-Muster
passt nicht auf ein <textarea>; die Live-Region unten sagt, was es gibt,
und normales Tippen bleibt von der Liste unberührt. */
acEl.setAttribute('aria-hidden', 'true');
acEl.hidden = true;
/* pointerdown statt click: läuft VOR dem Fokuswechsel, und preventDefault
lässt den Fokus im Textfeld auch auf Touch. */
acEl.addEventListener('pointerdown', e => {
const it = e.target.closest('.acitem');
if(!it) return;
e.preventDefault();
acIndex = Number(it.dataset.i);
acAccept();
});
document.body.appendChild(acEl);
acLiveEl = document.createElement('div');
acLiveEl.className = 'vh';
acLiveEl.setAttribute('role', 'status');
document.body.appendChild(acLiveEl);
}
return acEl;
}
function acIsOpen(){ return !!acEl && !acEl.hidden; }
function closeAc(){
if(acEl) acEl.hidden = true;
if(acLiveEl) acLiveEl.textContent = '';
acCtx = null;
}
function updateAc(){
if(src.readOnly || document.activeElement !== src ||
src.selectionStart !== src.selectionEnd){ closeAc(); return; }
const ctx = depFragment(src.value, src.selectionStart);
if(!ctx){ acSuppress = null; closeAc(); return; }
if(acSuppress && acSuppress.start === ctx.start && acSuppress.fragment === ctx.fragment){
closeAc(); return;
}
acSuppress = null;
/* Pfeiltasten ändern nur die Auswahl, nicht den Kontext Liste und
gewählter Eintrag bleiben dann stehen. */
if(acIsOpen() && acCtx && acCtx.start === ctx.start && acCtx.fragment === ctx.fragment) return;
const cands = matchIds(collectIds(acRoots), ctx.fragment, ctx.exclude);
/* Nichts zu zeigen oder der eine exakte Treffer wäre nur ein Echo dessen,
was schon vollständig dasteht. */
if(!cands.length ||
(cands.length === 1 && cands[0].id === ctx.fragment && ctx.end === src.selectionStart)){
closeAc(); return;
}
acCtx = ctx; acItems = cands; acIndex = 0;
renderAc();
acLiveEl.textContent = t('acHint', {n: acItems.length});
}
function renderAc(){
const box = acBox();
box.innerHTML = acItems.map((c, i) =>
`<div class="acitem${i === acIndex ? ' sel' : ''}" data-i="${i}">` +
`<span class="acid">#${esc(c.id)}</span>` +
(c.label ? `<span class="aclabel">${esc(c.label)}</span>` : '') +
`</div>`).join('');
box.hidden = false;
placeAc();
const sel = box.children[acIndex];
if(sel) sel.scrollIntoView({block: 'nearest'});
}
/* Unter dem `#` des Fragments; nach oben ausweichend, wenn unten kein Platz
ist. Wie das Knoten-Fenster (D52) `position:fixed` auf <body> in einem
Vorfahren mit `overflow` würde die Liste geklippt (D50). */
function placeAc(){
const rect = src.getBoundingClientRect();
const pos = caretPosInEditor(Math.max(0, acCtx.start - 1));
const lh = parseFloat(getComputedStyle(src).lineHeight) || 18;
let x = rect.left + pos.left - src.scrollLeft;
let y = rect.top + pos.top - src.scrollTop + lh;
x = Math.max(8, Math.min(x, window.innerWidth - acEl.offsetWidth - 8));
if(y + acEl.offsetHeight > window.innerHeight - 8){
y = Math.max(8, rect.top + pos.top - src.scrollTop - acEl.offsetHeight - 4);
}
acEl.style.left = x + 'px';
acEl.style.top = y + 'px';
}
function acMove(d){
acIndex = (acIndex + d + acItems.length) % acItems.length;
renderAc();
acLiveEl.textContent = '#' + acItems[acIndex].id; /* der gewählte Eintrag */
}
function acAccept(){
const c = acItems[acIndex], ctx = acCtx;
closeAc();
if(!c || !ctx) return;
acSuppress = {start: ctx.start, fragment: c.id};
const p = ctx.start + c.id.length;
/* writeAt (D53) ersetzt undo-fähig hier läuft es aus keydown/pointerdown,
nicht re-entrant aus `input`, execCommand greift also (anders als D55). */
writeAt(ctx.start, ctx.end, c.id, p, p);
}
/* Auf `document` in der Capture-Phase: Die Handler am Textfeld (Tab rückt ein,
Esc löst die Tab-Falle, D53) sind früher registriert und kämen sonst zuerst.
stopPropagation hält sie heraus, solange die Liste offen ist. */
document.addEventListener('keydown', e => {
if(!acIsOpen() || e.target !== src) return;
if(e.key === 'ArrowDown' || e.key === 'ArrowUp'){
e.preventDefault();
acMove(e.key === 'ArrowDown' ? 1 : -1);
} else if(e.key === 'Enter' || e.key === 'Tab'){
e.preventDefault();
e.stopPropagation();
acAccept();
} else if(e.key === 'Escape'){
e.stopPropagation();
acSuppress = {start: acCtx.start, fragment: acCtx.fragment};
closeAc();
}
}, true);
/* Zu, wenn die Position nicht mehr stimmt oder niemand mehr tippt. */
src.addEventListener('blur', closeAc);
src.addEventListener('scroll', closeAc);
window.addEventListener('resize', closeAc);
const app = document.getElementById('app');
function applyLayout(mode){
out.classList.toggle('vertical', mode === 'vertikal');
@@ -1917,6 +2062,7 @@ const I18N = {
jumpHint:"Alt+Klick: zur Zeile im Text",
/* Auf Touch nennt das Knoten-Fenster (D52) den langen Druck — Alt gibt es dort nicht. */
jumpHintTouch:"Langer Druck: zur Zeile im Text",
acHint:"{n} ID-Vorschläge ↑/↓ wählt, Enter übernimmt",
tipClose:"Schließen",
tipOpenLink:"Link öffnen",
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
@@ -2021,6 +2167,7 @@ const I18N = {
ghostTooltip:"From size M upward, an item should be broken down further.",
jumpHint:"Alt+click: jump to the line in the text",
jumpHintTouch:"Long press: jump to the line in the text",
acHint:"{n} id suggestions ↑/↓ to choose, Enter to insert",
tipClose:"Close",
tipOpenLink:"Open link",
padReadonly:"Edited in the pad — read-only here.",
@@ -2124,6 +2271,7 @@ const I18N = {
ghostTooltip:"A partir de la talla M, un elemento debería desglosarse más.",
jumpHint:"Alt+clic: ir a la línea en el texto",
jumpHintTouch:"Pulsación larga: ir a la línea en el texto",
acHint:"{n} sugerencias de ID ↑/↓ elige, Intro inserta",
tipClose:"Cerrar",
tipOpenLink:"Abrir enlace",
padReadonly:"Se edita en el pad — aquí solo lectura.",
@@ -2227,6 +2375,7 @@ const I18N = {
ghostTooltip:"À partir de la taille M, un élément devrait être décomposé davantage.",
jumpHint:"Alt+clic : aller à la ligne dans le texte",
jumpHintTouch:"Appui long : aller à la ligne dans le texte",
acHint:"{n} suggestions d'ID ↑/↓ pour choisir, Entrée pour insérer",
tipClose:"Fermer",
tipOpenLink:"Ouvrir le lien",
padReadonly:"Modifié dans le pad — lecture seule ici.",
@@ -2330,6 +2479,7 @@ const I18N = {
ghostTooltip:"Od rozmiaru M element powinien być dalej podzielony.",
jumpHint:"Alt+kliknięcie: przejdź do wiersza w tekście",
jumpHintTouch:"Długie przytrzymanie: przejdź do wiersza w tekście",
acHint:"{n} podpowiedzi ID ↑/↓ wybiera, Enter wstawia",
tipClose:"Zamknij",
tipOpenLink:"Otwórz link",
padReadonly:"Edytowane w padzie — tu tylko do czytania.",
@@ -2433,6 +2583,7 @@ const I18N = {
ghostTooltip:"Начиная с размера M элемент следует далее декомпозировать.",
jumpHint:"Alt+клик: перейти к строке в тексте",
jumpHintTouch:"Долгое нажатие: перейти к строке в тексте",
acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет",
tipClose:"Закрыть",
tipOpenLink:"Открыть ссылку",
padReadonly:"Редактируется в паде — здесь только чтение.",
@@ -2536,6 +2687,7 @@ const I18N = {
ghostTooltip:"आकार M से ऊपर किसी तत्व को और अधिक उप-विभाजित करना चाहिए।",
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
jumpHintTouch:"देर तक दबाएँ: टेक्स्ट में उस पंक्ति पर जाएँ",
acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें",
tipClose:"बंद करें",
tipOpenLink:"लिंक खोलें",
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
@@ -2646,6 +2798,7 @@ const I18N = {
ghostTooltip:"从 M 号起,元素应进一步细分。",
jumpHint:"Alt+点击:跳转到文本中的该行",
jumpHintTouch:"长按:跳转到文本中的该行",
acHint:"{n} 个 ID 建议 ↑/↓ 选择,Enter 插入",
tipClose:"关闭",
tipOpenLink:"打开链接",
padReadonly:"在 Pad 中编辑 — 此处只读。",
@@ -2749,6 +2902,7 @@ const I18N = {
ghostTooltip:"サイズ M 以上の要素はさらに分解すべきです。",
jumpHint:"Alt+クリック:テキストの該当行へ移動",
jumpHintTouch:"長押し:テキストの該当行へ移動",
acHint:"ID候補 {n} 件 ↑/↓で選択、Enterで挿入",
tipClose:"閉じる",
tipOpenLink:"リンクを開く",
padReadonly:"パッドで編集します — ここでは読み取り専用です。",
+87
View File
@@ -0,0 +1,87 @@
/* ID-Vorschläge beim Tippen von Abhängigkeiten (D63).
Eine Eingabehilfe wie die ID-Kurzform (D55), keine Notation: Der Parser
sieht nie etwas davon, SPEC und llms.md bleiben unberührt. Hier steht, WAS
gilt — wann ein `:#…`-Kontext vorliegt und welche IDs dazu passen; app.js
verdrahtet nur (Popup, Tasten, Einfügen). Frontend-Hausregel: Was
entscheidbar ist, gehört in ein Modul (D54-Nachtrag 3). */
const ID_CHARS = '[\\p{L}\\p{N}._-]';
/* Der Abhängigkeits-Kontext an der Schreibmarke: null, oder
{start, end, fragment, exclude}. `start`..caret ist das angefangene
ID-Fragment hinter dem letzten `#`; `end` reicht über die Schreibmarke
hinaus bis ans Ende der ID-Zeichen (wer mitten im Wort ersetzt, soll kein
`#authth` bekommen). `exclude` sind die IDs, die eine Auswahl nicht mehr
anbieten soll: die schon gelisteten des Tokens und die eigene ID der Zeile
(die Selbst-Abhängigkeit ist zulässig, aber nie das, was man tippen will).
Erkannt wird dieselbe Form, die der Parser liest (SPEC §1): das Token
alleinstehend angesetzt — `(^|\s):#…` — oder unmittelbar hinter der
Knoten-ID (`#auth:#db`, D36). `(:#a` bleibt damit Zitat, `Regel: #x`
bleibt Label. Kein Kontext im Kommentar (hinter `%%`) und nicht im
Beschreibungsteil hinter `---` — dort ist alles Freitext. */
const TOKEN_RE = new RegExp(
'(?:^|[ \\t])(?:#(' + ID_CHARS + '+))?:(#' + ID_CHARS + '*(?:,#' + ID_CHARS + '*)*)$', 'u');
const OWN_ID_RE = new RegExp('(?:^|\\s)#(' + ID_CHARS + '+)', 'u');
const TAIL_RE = new RegExp('^' + ID_CHARS + '*', 'u');
export function depFragment(text, caret){
const before = text.slice(0, caret);
const lines = before.split('\n');
const cur = lines[lines.length - 1];
/* Beschreibungsteil (§1): alles hinter dem ersten `---`-Trenner ist Freitext. */
for(let i = 0; i < lines.length - 1; i++){
if(/^\s*-{3,}\s*$/.test(lines[i])) return null;
}
if(cur.includes('%%')) return null; /* die Schreibmarke steht im Kommentar */
const m = TOKEN_RE.exec(cur);
if(!m) return null;
const token = m[2]; /* "#a,#b…" bis zur Schreibmarke */
const parts = token.slice(1).split(',#');
const fragment = parts[parts.length - 1];
const exclude = parts.slice(0, -1);
if(m[1]) exclude.push(m[1]); /* Kopf-Form `#auth:#…` */
else {
/* Eigene ID der Zeile, wenn sie weiter vorn steht (`#auth: … :#…`) —
erster alleinstehender `#`-Treffer, wie in der Extraktion (Schritt 6).
Der Zeilenrest vor dem Token genügt: Die IDs im Token selbst sind nie
alleinstehend (`:`/`,` davor). */
const own = OWN_ID_RE.exec(cur.slice(0, m.index + 1));
if(own) exclude.push(own[1]);
}
const end = caret + TAIL_RE.exec(text.slice(caret))[0].length;
return {start: caret - fragment.length, end, fragment, exclude};
}
/* Alle vergebenen IDs in Dokumentreihenfolge, mit Titel als Kontext.
Bewusst ALLE Knoten — auch verworfene und eingeklappte: Eine Abhängigkeit
darf überallhin zeigen, und die Faltung ist nur Ansicht (D38). */
export function collectIds(roots){
const out = [];
const walk = ns => {
for(const n of ns){
if(n.id) out.push({id: n.id, label: n.labelFromId ? '' : n.label});
walk(n.children);
}
};
walk(roots);
return out;
}
/* Passende Kandidaten: erst Präfix-Treffer, dann Teilstring-Treffer, je in
Dokumentreihenfolge; Groß-/Kleinschreibung egal (die IDs selbst bleiben,
wie sie geschrieben sind). Leeres Fragment (direkt nach `:#`) zeigt alle. */
export function matchIds(ids, fragment, exclude = []){
const ex = new Set(exclude);
const pool = ids.filter(c => !ex.has(c.id));
if(!fragment) return pool;
const f = fragment.toLowerCase();
const pre = [], sub = [];
for(const c of pool){
const lo = c.id.toLowerCase();
if(lo.startsWith(f)) pre.push(c);
else if(lo.includes(f)) sub.push(c);
}
return pre.concat(sub);
}
+28 -1
View File
@@ -1006,6 +1006,33 @@
`ul.or .node{box-shadow:none}` spezifischer ist. */
#out .node.tipped{box-shadow:0 0 0 3px rgba(15,118,110,.55)}
/* ---------- ID-Vorschläge beim Tippen von Abhängigkeiten (D63) ----------
`position:fixed` auf <body> wie das Knoten-Fenster: In einem Vorfahren
mit `overflow` würde die Liste geklippt (D50). Die IDs in der
Mono-Schrift des Textfelds, der Titel als gedämpfter Kontext daneben. */
.aclist{
position:fixed;z-index:70;min-width:160px;max-width:min(24rem, calc(100vw - 16px));
max-height:12.5rem;overflow-y:auto;
background:var(--card);border:1px solid rgba(36,52,71,.15);border-radius:8px;
box-shadow:0 8px 24px rgba(36,52,71,.25);padding:4px;
}
.aclist[hidden]{display:none}
.acitem{
display:flex;align-items:baseline;gap:8px;padding:4px 8px;border-radius:5px;
cursor:pointer;white-space:nowrap;
}
.acitem.sel,.acitem:hover{background:rgba(15,118,110,.12)}
.acid{font-family:'IBM Plex Mono',monospace;font-size:.74rem;color:var(--ink)}
.aclabel{
font-size:.7rem;color:var(--muted);
overflow:hidden;text-overflow:ellipsis;max-width:14rem;
}
/* Visuell versteckt, für Screenreader da — die Live-Region der Vorschläge. */
.vh{
position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;
clip-path:inset(50%);white-space:nowrap;
}
/* --- ALL OF: Kinder nebeneinander, durchgezogener Verteiler --- */
ul.and{display:flex;align-items:flex-start;position:relative;padding-top:14px}
ul.and::before{
@@ -1431,7 +1458,7 @@
@media print{
html,body{height:auto!important;overflow:visible!important;background:#fff!important}
header,.panel.editor,.gutter,.panel.right .panel-head,
.warnings,.zoomctl,.site-footer,.nodetip{display:none!important}
.warnings,.zoomctl,.site-footer,.nodetip,.aclist{display:none!important}
.app,.app.side{display:block!important;max-width:none!important;width:auto!important;
height:auto!important;margin:0!important;gap:0!important}
.panel.right{grid-area:auto!important;background:#fff!important;border:0!important;
+114
View File
@@ -0,0 +1,114 @@
// ID-Vorschläge beim Tippen von Abhängigkeiten (D63): Kontext-Erkennung und
// Kandidaten-Auswahl. Die Verdrahtung (Popup, Tasten, Einfügen) bleibt
// Browser-Sache — hier steht, WAS gilt.
import { describe, it, expect } from 'vitest';
import { depFragment, collectIds, matchIds } from '../src/autocomplete.js';
import { parse } from '../src/parser.js';
/* Kontext am Zeilenende: Text bis `|` ist alles vor der Schreibmarke. */
const at = text => {
const caret = text.indexOf('|');
return depFragment(text.replace('|', ''), caret);
};
describe('depFragment: wann ein :#-Kontext vorliegt', () => {
it('öffnet direkt nach :# mit leerem Fragment', () => {
const ctx = at('- Backend :#|');
expect(ctx).toMatchObject({fragment: '', exclude: []});
expect(ctx.start).toBe(12);
expect(ctx.end).toBe(12);
});
it('liefert das angefangene Fragment', () => {
expect(at('- Backend :#au|')).toMatchObject({fragment: 'au', start: 12});
});
it('setzt in der Liste fort und schließt Gelistetes aus', () => {
expect(at('- X :#auth,#a|')).toMatchObject({fragment: 'a', exclude: ['auth']});
});
it('erkennt die Kopf-Form #auth:#… und schließt die eigene ID aus', () => {
expect(at('- #auth:#d|')).toMatchObject({fragment: 'd', exclude: ['auth']});
});
it('schließt die eigene ID der Zeile auch weiter vorn aus', () => {
expect(at('- #auth: Backend :#|')).toMatchObject({fragment: '', exclude: ['auth']});
});
it('reicht über die Schreibmarke bis ans Ende der ID-Zeichen (end)', () => {
const text = '- X :#auth (S)';
const ctx = depFragment(text, 8); /* Schreibmarke mitten in `auth` */
expect(ctx).toMatchObject({fragment: 'au', start: 6, end: 10});
});
it('kein Kontext bei bloßem # — das definiert eine ID', () => {
expect(at('- Backend #au|')).toBeNull();
});
it('kein Kontext in der Zitier-Klammer und nach Label-Doppelpunkten', () => {
expect(at('- siehe (:#auth|')).toBeNull();
expect(at('- Regel:#x|')).toBeNull();
});
it('kein Kontext im Kommentar', () => {
expect(at('- A %% braucht :#auth|')).toBeNull();
});
it('kein Kontext im Beschreibungsteil hinter ---', () => {
expect(at('- A\n---\n#a\n siehe :#|')).toBeNull();
});
it('kein Kontext, wenn das Token nicht bis zur Schreibmarke reicht', () => {
expect(at('- A :#auth |')).toBeNull();
});
it('am Anfang einer Fortsetzungszeile gilt der Zeilenanfang als Leerraum', () => {
expect(at('- Langer Titel \\\n:#au|')).toMatchObject({fragment: 'au'});
});
});
describe('collectIds: alle vergebenen IDs in Dokumentreihenfolge', () => {
it('sammelt über alle Ebenen, mit Titel als Kontext', () => {
const { roots } = parse('#a: Wurzel\n - #b: Kind\n - Ohne ID\n - #c: Kind 2');
expect(collectIds(roots)).toEqual([
{id: 'a', label: 'Wurzel'}, {id: 'b', label: 'Kind'}, {id: 'c', label: 'Kind 2'},
]);
});
it('nimmt auch verworfene Knoten mit — Abhängigkeiten dürfen dorthin zeigen', () => {
const { roots } = parse('A\n - [-] #alt: Verworfen');
expect(collectIds(roots)).toEqual([{id: 'alt', label: 'Verworfen'}]);
});
it('lässt den Titel leer, wenn die ID ihn nur vertritt (D60)', () => {
const { roots } = parse('- #US-123');
expect(collectIds(roots)).toEqual([{id: 'US-123', label: ''}]);
});
});
describe('matchIds: Präfix vor Teilstring, Dokumentreihenfolge, ohne exclude', () => {
const ids = [
{id: 'auth', label: ''}, {id: 'db', label: ''},
{id: 'be.auth', label: ''}, {id: 'author', label: ''},
];
it('leeres Fragment zeigt alle', () => {
expect(matchIds(ids, '').map(c => c.id)).toEqual(['auth', 'db', 'be.auth', 'author']);
});
it('Präfix-Treffer stehen vor Teilstring-Treffern, je in Reihenfolge', () => {
expect(matchIds(ids, 'au').map(c => c.id)).toEqual(['auth', 'author', 'be.auth']);
});
it('vergleicht ohne Groß-/Kleinschreibung, behält die Schreibweise', () => {
expect(matchIds([{id: 'US-123', label: ''}], 'us').map(c => c.id)).toEqual(['US-123']);
});
it('ausgeschlossene IDs erscheinen nicht', () => {
expect(matchIds(ids, 'au', ['auth']).map(c => c.id)).toEqual(['author', 'be.auth']);
});
it('ohne Treffer bleibt die Liste leer', () => {
expect(matchIds(ids, 'xyz')).toEqual([]);
});
});