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
+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([]);
});
});