feat(taiga): Bulk-Abfrage und Abweichungs-Marke — das Diagramm zeigt, wo Ticket und Plan auseinanderlaufen (D91-Nachtrag 10, SPEC §9)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-28 11:44:33 +02:00
co-authored by Claude Opus 5
parent e77a4d1ce4
commit a1e4a3bbab
17 changed files with 663 additions and 7 deletions
+9
View File
@@ -81,6 +81,15 @@ docs/SPEC.md §10 testen — niemals eine zweite, abweichende Grammatik pflegen.
deshalb den `slug` (aus `&taiga.<slug>`, SPEC §1) und fragen erst
`/projects/by_slug`, dann `by_ref` — der Slug kommt vom Client und wird
**kodiert** angehängt, sonst hängte ein `&` darin einen weiteren Filter an.
- **Bulk-Lesen (D91-Nachtrag 10):** `GET /taiga/tickets?slug=&refs=US-1,T-2`
fächert serverseitig in `by_ref`-Einzelabfragen auf — parallel (virtuelle
Threads) mit Semaphore-Deckel (6): Die Kosten skalieren mit den Refs im
Plan, nie mit der Projektgröße; eine Projekt-Volliste ist bei Tausenden
Tickets die falsche Bulk-Einheit. Eine 404-Ref fehlt still in der
Antwort-Map, jeder andere Fehler bricht die Anfrage ab; ungültige Refs
sind ein 400 (`TaigaBadRequestException`), Deckel 200, Dubletten werden
zusammengelegt. Messnotiz: Ob Taigas Listen-Endpunkte eine Ref-Liste als
Filter nehmen, ist offen — falls ja, tauscht nur das Innere des Proxys.
- **Schreiben (D91-Nachtrag 8):** `PATCH /taiga/{userstories|tasks}/{ref}/status`
nimmt die Status-**Id** (aus `GET /taiga/{userstory|task}-statuses?slug=`)
und die zuletzt gelesene `version` — Taigas optimistische Sperre; ein
@@ -8,6 +8,7 @@ import de.werkbaum.service.DocumentDeletedException
import de.werkbaum.service.DocumentNotFoundException
import de.werkbaum.service.InvalidPatchException
import de.werkbaum.service.StalePatchSequenceException
import de.werkbaum.integration.taiga.TaigaBadRequestException
import de.werkbaum.integration.taiga.TaigaNotConfiguredException
import de.werkbaum.integration.taiga.TaigaUnavailableException
import de.werkbaum.integration.taiga.TaigaUpstreamException
@@ -89,6 +90,14 @@ class GlobalExceptionHandler {
ex.message ?: "Taiga nicht konfiguriert",
).apply { title = "Taiga nicht konfiguriert" }
/** Eine Anfrage, die schon der Proxy ablehnt (Refs-Liste der Bulk-Abfrage). */
@ExceptionHandler(TaigaBadRequestException::class)
fun handleTaigaBadRequest(ex: TaigaBadRequestException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
ex.message ?: "Ungültige Anfrage",
).apply { title = "Ungültige Anfrage" }
@ExceptionHandler(TaigaUnavailableException::class)
fun handleTaigaUnavailable(ex: TaigaUnavailableException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
@@ -10,6 +10,8 @@ import de.werkbaum.generated.model.TaigaStatus
import de.werkbaum.generated.model.TaigaStatusPatch
import de.werkbaum.generated.model.TaigaTicket
import de.werkbaum.generated.model.TaigaTicketDetail
import de.werkbaum.integration.taiga.TaigaBadRequestException
import de.werkbaum.integration.taiga.TaigaBulkRefData
import de.werkbaum.integration.taiga.TaigaClient
import de.werkbaum.integration.taiga.TaigaTicketDetailData
import de.werkbaum.integration.taiga.TaigaTicketData
@@ -76,6 +78,33 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
return created(ticket)
}
/* Bulk-Lesen (D91-Nachtrag 10): eine Anfrage vom Editor, der Fächer läuft
im Client. Die Refs kommen MIT Werkbaum-Präfix (`US-`/`T-`) — es trägt
den Typ; geparst wird streng, Ungültiges ist ein 400 statt still
übersprungen (D59-Linie). Doppelte Refs werden vor dem Fächer
zusammengelegt. */
override fun taigaTickets(
xTaigaToken: String,
slug: String,
refs: String,
): ResponseEntity<Map<String, TaigaTicketDetail>> =
ResponseEntity.ok(
client.tickets(xTaigaToken, slug, parseBulkRefs(refs))
.mapValues { it.value.toApi() }
)
private fun parseBulkRefs(refs: String): List<TaigaBulkRefData> {
val teile = refs.split(',').distinct()
if (teile.size > MAX_BULK_REFS) {
throw TaigaBadRequestException("Höchstens $MAX_BULK_REFS Refs je Anfrage")
}
return teile.map { teil ->
val m = BULK_REF.matchEntire(teil)
?: throw TaigaBadRequestException("Ungültige Ref: '$teil'")
TaigaBulkRefData(key = teil, task = m.groupValues[1] == "T", nr = m.groupValues[2].toLong())
}
}
/* Lesen (D91-Nachtrag 6): zwei Endpunkte statt eines mit Typ-Parameter —
das Präfix der Ref trägt den Typ, und Taiga hat getrennte
`by_ref`-Endpunkte. Die Abbildung des Status auf die Notation macht der
@@ -146,4 +175,10 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
ResponseEntity.status(HttpStatus.CREATED).body(
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
)
companion object {
/** Höchstens so viele Refs je Bulk-Anfrage — mehr referenziert kein Plan. */
private const val MAX_BULK_REFS = 200
private val BULK_REF = Regex("(US|T)-(\\d{1,10})")
}
}
@@ -11,6 +11,10 @@ import java.net.URLEncoder
import java.net.http.HttpClient
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util.concurrent.Callable
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.Semaphore
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
class TaigaNotConfiguredException :
@@ -58,6 +62,17 @@ data class TaigaTicketDetailData(
/** Eine Spalte des Projekt-Workflows (D91-Nachtrag 8). */
data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?)
/**
* Eine Ref der Bulk-Abfrage (D91-Nachtrag 10): `key` ist die Werkbaum-Ref
* (`US-123`), unter der die Antwort zurueckgeht; `task` und `nr` sind das
* zerlegte Praefix — der Typ steht in der Ref, genau dafuer schreibt
* Werkbaum ihn (SPEC par. 11).
*/
data class TaigaBulkRefData(val key: String, val task: Boolean, val nr: Long)
/** Eine Anfrage, die schon der Proxy ablehnt (400) — kein Taiga-Fehler. */
class TaigaBadRequestException(message: String) : RuntimeException(message)
/**
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
@@ -71,6 +86,11 @@ data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?)
@Service
class TaigaClient(private val properties: TaigaProperties) {
/* Der Faecher der Bulk-Abfrage: virtuelle Threads (praktisch kostenlos,
D76-Nachtrag 5), die Semaphore deckelt die GLEICHZEITIGEN Anfragen an
die fremde Instanz — Parallelitaet ja, Hammer nein. */
private val bulkPool = Executors.newVirtualThreadPerTaskExecutor()
private val rest: RestClient = RestClient.builder()
.requestFactory(
JdkClientHttpRequestFactory(
@@ -124,8 +144,10 @@ class TaigaClient(private val properties: TaigaProperties) {
* Aufrufer aus dem Präfix der Ref (`US-`/`T-`, SPEC §11) — hier steht
* nur, wohin gefragt wird.
*/
fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData {
val project = projectId(token, slug)
fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData =
detailByRef(token, projectId(token, slug), ref, task)
private fun detailByRef(token: String, project: Long, ref: Long, task: Boolean): TaigaTicketDetailData {
val pfad = if (task) "/tasks/by_ref" else "/userstories/by_ref"
val map = exchange {
rest.get().uri(url("$pfad?project=$project&ref=$ref"))
@@ -135,6 +157,47 @@ class TaigaClient(private val properties: TaigaProperties) {
return detail(map)
}
/**
* Viele Tickets auf einmal (D91-Nachtrag 10): EIN Aufruf vom Browser, der
* Proxy fächert in `by_ref`-Einzelabfragen auf — parallel, mit
* **gedeckelter** Nebenläufigkeit (Höflichkeit gegenüber der fremden
* Instanz; Backend und Taiga sitzen beim selben Hoster, die Einzelabfrage
* kostet dort Millisekunden). Die Kosten skalieren mit den Refs im Plan,
* nie mit der Projektgröße — eine Projekt-Volliste holte bei Tausenden
* Tickets Megabytes, um fast alles wegzuwerfen.
*
* Eine Ref, die es nicht (mehr) gibt (Taiga-404), **fehlt still** in der
* Antwort — der Rest kommt trotzdem; jeder andere Fehler (401, Projekt
* unbekannt, Instanz weg) bricht die ganze Anfrage ab, denn er beträfe
* ohnehin jede einzelne Ref.
*/
fun tickets(token: String, slug: String, refs: List<TaigaBulkRefData>): Map<String, TaigaTicketDetailData> {
val project = projectId(token, slug)
val sem = Semaphore(BULK_CONCURRENCY)
val futures = refs.map { r ->
bulkPool.submit(Callable {
sem.acquire()
try {
r.key to detailByRef(token, project, r.nr, r.task)
} finally {
sem.release()
}
})
}
val out = LinkedHashMap<String, TaigaTicketDetailData>()
for (f in futures) {
try {
val (key, wert) = f.get()
out[key] = wert
} catch (e: ExecutionException) {
val grund = e.cause
if (grund is TaigaUpstreamException && grund.status == 404) continue
throw grund ?: e
}
}
return out
}
/**
* Die Spalten des Projekt-Workflows (D91-Nachtrag 8) — Taiga schreibt nach
* Status-**Id**, und die Namen sind je Projekt frei. Welche Spalte gemeint
@@ -253,6 +316,8 @@ class TaigaClient(private val properties: TaigaProperties) {
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
companion object {
/** Hoechstens so viele gleichzeitige Upstream-Anfragen je Bulk-Aufruf. */
private const val BULK_CONCURRENCY = 6
private val MAP = object : ParameterizedTypeReference<Map<String, Any?>>() {}
private val LIST = object : ParameterizedTypeReference<List<Map<String, Any?>>>() {}
}
@@ -606,6 +606,73 @@ paths:
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/tickets:
get:
tags: [Taiga]
operationId: taigaTickets
summary: Den Stand vieler Tickets auf einmal lesen (Proxy)
description: >
Bulk-Lesen fuer die Abweichungs-Marken im Diagramm (D91-Nachtrag 10):
Der Editor stellt EINE Anfrage mit allen Refs eines Dokuments, der
Proxy faechert sie in `by_ref`-Einzelabfragen auf - parallel mit
gedeckelter Nebenlaeufigkeit, denn Backend und Taiga-Instanz sitzen
nah beieinander, der Browser nicht. Die Kosten skalieren mit den
Refs im Plan, nie mit der Groesse des Taiga-Projekts (dort koennen
Tausende Tickets liegen - eine Projekt-Volliste waere die falsche
Bulk-Einheit). Das Praefix jeder Ref traegt den Typ
(`US-`/`T-`, SPEC par. 11). Eine Ref, die es im Projekt nicht
(mehr) gibt, fehlt still in der Antwort - die uebrigen kommen
trotzdem; jeder andere Fehler (401, Projekt unbekannt, Taiga weg)
gilt der ganzen Anfrage.
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaSlug"
- name: refs
in: query
required: true
description: >
Kommagetrennte Werkbaum-Refs (`US-123,T-1234`), hoechstens 200 -
mehr referenziert kein Plan, und der Deckel haelt den Faecher
endlich. Ungueltige Eintraege sind ein 400, nicht still
uebersprungen.
schema:
type: string
minLength: 1
maxLength: 4000
responses:
"200":
description: >
Map Werkbaum-Ref (`US-123`) auf den Ticket-Stand. Fehlende
Schluessel heissen: dieses Ticket gibt es im Projekt nicht (mehr).
content:
application/json:
schema:
type: object
additionalProperties:
$ref: "#/components/schemas/TaigaTicketDetail"
"400":
description: Refs-Liste ungueltig oder zu lang
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: Das Projekt zum Slug gibt es nicht
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/userstories/{ref}/status:
patch:
tags: [Taiga]
@@ -154,6 +154,31 @@ class TaigaApiTest {
result.responseBody!! shouldContain """{"id":12,"name":"In progress","closed":false}"""
}
@Test
fun `die Bulk-Abfrage liefert eine Map unter den Werkbaum-Refs`() {
val result = client.get()
.uri("/api/v1/taiga/tickets?slug=mi-kunde&refs=US-123,T-1234")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"US-123\""
result.responseBody!! shouldContain "\"T-1234\""
result.responseBody!! shouldContain "\"status\":\"In progress\""
result.responseBody!! shouldContain "\"status\":\"Done\""
}
@Test
fun `eine ungueltige Ref in der Bulk-Liste ist ein 400, nicht still uebersprungen`() {
val result = client.get()
.uri("/api/v1/taiga/tickets?slug=mi-kunde&refs=US-123,kaputt")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 400
result.responseBody!! shouldContain "kaputt"
}
@Test
fun `ein Status wird per Ref gesetzt und antwortet mit dem neuen Stand`() {
val result = client.patch()
@@ -220,6 +220,49 @@ class TaigaClientTest {
client().ticket("tok-abc123", "mi-kunde", 123, task = false).version shouldBe 7L
}
/* ---- Bulk-Lesen (D91-Nachtrag 10) ---- */
@Test
fun `tickets faechert je Ref auf und liefert die Map unter den Werkbaum-Refs`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 200 to STORY_DETAIL
routes["/api/v1/tasks/by_ref?project=7&ref=1234"] = 200 to TASK_DETAIL
val map = client().tickets("tok-abc123", "mi-kunde", listOf(
TaigaBulkRefData("US-123", task = false, nr = 123),
TaigaBulkRefData("T-1234", task = true, nr = 1234),
))
map.keys shouldBe setOf("US-123", "T-1234")
map["US-123"]!!.status shouldBe "In progress"
map["T-1234"]!!.status shouldBe "Done"
/* Die Projekt-Aufloesung laeuft EINMAL, nicht je Ref. */
requests.count { it.path == "/api/v1/projects/by_slug" } shouldBe 1
}
@Test
fun `eine Ref, die es nicht mehr gibt, fehlt still - die uebrigen kommen trotzdem`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 200 to STORY_DETAIL
routes["/api/v1/userstories/by_ref?project=7&ref=999"] = 404 to NOT_FOUND
val map = client().tickets("tok-abc123", "mi-kunde", listOf(
TaigaBulkRefData("US-123", task = false, nr = 123),
TaigaBulkRefData("US-999", task = false, nr = 999),
))
map.keys shouldBe setOf("US-123")
}
@Test
fun `ein abgelaufenes Token bricht die ganze Bulk-Anfrage ab`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 401 to NOT_FOUND
val ex = shouldThrow<TaigaUpstreamException> {
client().tickets("tok-abc123", "mi-kunde",
listOf(TaigaBulkRefData("US-123", task = false, nr = 123)))
}
ex.status shouldBe 401
}
@Test
fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
val bare = TaigaClient(TaigaProperties(apiUrl = ""))
@@ -315,7 +358,10 @@ class TaigaClientTest {
body = ex.requestBody.readBytes().decodeToString(),
)
requests += recorded!!
val route = routes[ex.requestURI.path]
/* Query-genaue Route vor der Pfad-Route: Die Bulk-Abfrage
trifft denselben Pfad mit verschiedenen Refs. */
val route = routes[ex.requestURI.path + "?" + (ex.requestURI.query ?: "")]
?: routes[ex.requestURI.path]
val status = route?.first ?: responseStatus
val bytes = (route?.second ?: responseBody).encodeToByteArray()
ex.responseHeaders.set("Content-Type", "application/json")
+4
View File
@@ -19,6 +19,10 @@ reverse.
## 2026-08-28
- One bulk request reads the status of all referenced tickets: the proxy fans the refs out right next to the Taiga instance, so the cost scales with the refs in the plan — not with a project that holds thousands of tickets
- With a Taiga login, diverging tickets show up in the diagram: a ref whose ticket status no longer matches the node's box turns amber — as its own little badge where the ref is the node id and thus not part of the title
- The bulk result pre-fills the node window's ticket cache, so hovering a ticket node usually needs no request of its own any more
- The divergence mark is session knowledge and stays out of the SVG export and of print — what you share is the plan, not your fetch state
- The create buttons in the node window are recut: one "create story" button always opens the dialog with project choice and sub-package checkboxes — unchecking covers the story-only case
- A "create task" button appears where an ancestor node already carries a story: the task lands in that story without any dialog — project and story follow from the tree, only an error gets a surface
+85
View File
@@ -8386,3 +8386,88 @@ benannte Test.
Seite nicht (Klicks schon: das DOM ist geteilt, die JS-Objekte nicht). Eine
Rückfrage lässt sich dort also nicht wegstubben; Aufräumen, das durch eine
Rückfrage führt, geht stattdessen über die Ablage selbst (D83-Schema).
**Nachtrag 10 — Bulk-Abfrage und Abweichungs-Marke: das Diagramm zeigt, wo
Ticket und Plan auseinanderlaufen (2026-08-28).** Nutzerwunsch in zwei
Schritten: erst die Frage nach einer Bulk-Abfrage („Einzelabfrage wäre
wahrscheinlich zu langsam"), dann — auf den Befund, dass die Taiga-Projekte
**Tausende** Stories und Tasks haben — die nach einem Vorfilter. Die
Antwort auf beide ist dieselbe: **Der Filter sind die Refs im Plan, und
angewandt wird er im Proxy.**
- **`GET /taiga/tickets?slug=…&refs=US-1,T-2,…`** — EIN Aufruf vom Browser,
der Proxy fächert in `by_ref`-Einzelabfragen auf: **parallel** (virtuelle
Threads, D76-Nachtrag 5) mit **gedeckelter Nebenläufigkeit** (Semaphore,
6 gleichzeitig — Höflichkeit gegenüber der fremden Instanz). Die teure
Strecke Browser→Backend (~130 ms, gemessen) fällt einmal an; Backend und
Taiga-API sitzen beim selben Hoster, dort kosten die Einzelabfragen
Millisekunden. Die Kosten skalieren mit den Refs im Plan (Dutzende), nie
mit dem Projekt (Tausende) — die zuerst erwogene **Projekt-Volliste ist
damit verworfen**: Sie holte Megabytes, um fast alles wegzuwerfen, und
ließe Taiga je Anfrage eine große Liste rechnen.
- **Ein Endpunkt für beide Typen:** Das Präfix jeder Ref trägt den Typ
(`US-`/`T-`, Nachtrag 2) — genau die Auflösung, für die Werkbaum es
schreibt. Antwort ist eine Map Ref → Stand (dieselben Felder wie der
Einzel-Abruf, samt `version` — der Bulk füllt den Ticket-Cache des
Knoten-Fensters vor, das Fenster braucht dann meist keinen Abruf mehr).
- **Fehler-Semantik:** Eine Ref, die es nicht (mehr) gibt, **fehlt still**
in der Antwort — die übrigen kommen trotzdem; 401, unbekanntes Projekt
oder eine tote Instanz brechen die ganze Anfrage ab (sie beträfen jede
Ref). Ungültige Refs in der Liste sind ein **400, nicht still
übersprungen** (D59); doppelte werden vor dem Fächer zusammengelegt, und
bei **200 Refs** ist benannt Schluss (Client schneidet, Server lehnt ab).
- **Die Marke:** Eine Ref, deren Ticket-Status abgebildet ist und nicht zur
Statusbox passt, färbt sich **warnfarben**. Beim Bauen fiel die Lücke des
Vorschlags auf: `- [ ] Login #US-123` macht die Ref zur **Knoten-ID**
(§1, erstes `#`-Token) — sie steht gar nicht im Label, es gibt nichts zu
färben. Für diesen Fall hängt der **Renderer** ein kleines nachgestelltes
Badge mit der Ref an (die D40-Bauform der ”-Marke: vor dem Messen, die
Geometrie stimmt); sichtbare Refs (eigene ID auf der Zeile, D60-Knoten)
färben sich selbst. Ein unabgebildeter Spaltenname markiert nichts —
dieselbe Regel wie im Fenster.
- **Wann:** einmal je Projekt und Sitzung, angestoßen vom Neubau, von
`GET /info` (die Antwort kommt nach dem ersten Neubau) und von jeder
Anmeldung; ohne Anmeldung nie (die Nachtrag-6-Linie). Das Markieren aus
dem Cache ist kostenlos und läuft je Neubau mit; kommt der Bulk an,
rendert sein Callback — **außer** ein Knoten-Fenster ist offen (der Neubau
schlösse es): dann nur die Klassen-Marken, Badges kommen mit dem nächsten
Neubau. Einzel-Abrufe (↻, Schreiben) ziehen die Marken mit nach; nach
„nach Taiga schreiben" räumt `markTicketDiffs` erst ab und setzt neu —
sonst stünde die Marke auf einem Ticket, das wieder einig ist.
- **Nicht im Export, nicht im Druck:** Die Marke hängt an der Sitzung
(Anmeldung, Abrufzeitpunkt) — exportiert wird der Plan, nicht der
persönliche Abrufstand (dieselbe Linie wie der gelbe Kranz, D28). Der
Export liest die Label-Farbe vom **Knoten**, nicht von der Spanne
(nachgemessen), das Badge ist per `excludeSel` ausgenommen, der Druck
blendet es aus. Und im `aria-label` steht die Abweichung nicht — sie ist
asynchrones Sitzungswissen, der Screenreader-Weg ist das Knoten-Fenster;
benannt als Grenze, nicht übersehen.
- **Blass bleibt blass:** Auf einem vom Pfad zurückgetretenen Knoten dimmt
die Marke mit (die frontend/CLAUDE.md-Prüffrage ist gestellt): Anders als
bei `fresh`/`focusmark` ist die Aussage hier an einem nicht gebrauchten
Knoten auch weniger dringend, und erledigte Knoten treten seit D46 ohnehin
nie zurück.
- **Messnotiz für später:** Ob Taigas Listen-Endpunkte eine **Ref-Liste**
als Filter nehmen (`?project=…&refs=…`), ist nicht gemessen — die API-Doku
ist dort dünn. Falls ja, tauscht der Proxy sein Inneres (ein Aufruf statt
Fächer), ohne dass sich am Endpunkt oder im Editor etwas ändert.
**Nachgemessen** Ende-zu-Ende (lokales Backend + Taiga-Stub): Die
Bulk-Antwort trägt beide Refs samt Status und `version` (curl); im Browser
bekommt der Ref-als-ID-Knoten mit Abweichung das Badge in `--warn`
(`rgb(180,83,9)`), der einige Knoten nichts, der Label-Ref färbt sich ohne
Badge; der Stub-Mitschnitt zeigt je Bulk **einmal** `by_slug` und je
eindeutiger Ref eine `by_ref`-Abfrage (Dedupe greift); das Knoten-Fenster
liest danach aus dem Cache (kein weiterer Abruf); im exportierten SVG steht
der Label-Ref genau einmal, in Knotenfarbe, ohne Badge und ohne Bernstein.
Backend-`check` grün (5 neue Tests: Fächer mit Map, 404 fällt still,
401 bricht ab, Bulk-E2E, ungültige Ref → 400); Frontend 617 Tests (15
neue). Gegenproben per Mutation: 404-Überspringen entfernt → genau der
danach benannte Test fällt (1 von 21); Dedupe entfernt → genau der eine.
**Werkzeug-Notizen:** Der D82-Abschieds-Flush gewinnt gegen ein
`localStorage.setItem` + `location.reload()` — wer den Speicher unter einer
lebenden Seite umschreibt, bekommt beim Reload deren Gedächtnis zurück;
wiederhergestellt wird ein Dokument als gewöhnliche Änderung über das
Textfeld. Und der Debug-Reset hängt am `confirm`, das die isolierte Welt
des Prüf-Panes nicht stubben kann (Nachtrag 9) — er ist dort wirkungslos.
+16
View File
@@ -529,6 +529,22 @@ es einen gibt, den **Zuständigen**.
Siehe D91-Nachträge 6, 7 und 8.
**Abweichungs-Marke im Diagramm.** Mit Anmeldung an der Instanz holt **eine
Bulk-Anfrage je Projekt und Sitzung** den Stand aller referenzierten Tickets
(der Proxy fächert sie serverseitig in Einzelabfragen auf — die Kosten
skalieren mit den Refs im Plan, nie mit der Größe des Taiga-Projekts). Eine
Ref, deren Ticket-Status **abgebildet** ist und nicht zur Statusbox des
Knotens passt, färbt sich **warnfarben** — steht sie im Label, die Ref
selbst; ist sie (nur) die Knoten-ID (§1: das erste `#`-Token), erscheint sie
als kleines nachgestelltes Badge hinter dem Titel (die Bauform der ”-Marke).
Ein unabgebildeter Spaltenname markiert nichts — er sagt nichts über den
Plan. Die Einzelheiten samt der beiden Aktionen stehen wie gehabt im
Knoten-Fenster; ein Ticket, das es nicht (mehr) gibt, markiert nichts, und
ein Fehler der Hintergrund-Abfrage bleibt still. Ohne Anmeldung wird nichts
geholt. Die Marke hängt an der Sitzung (Anmeldung, Abrufzeitpunkt) und
erscheint deshalb **weder im Grafikexport noch im Druck** — exportiert wird
der Plan, nicht der persönliche Abrufstand. Siehe D91-Nachtrag 10.
**Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen —
das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status
**weiter** ist als der effektive (der Knoten wird von Abhängigkeiten
+24
View File
@@ -205,6 +205,9 @@
- [^] #trk.resolve.read: Read title, link and status (S)
- [/] #trk.resolve.map: Map the workflow onto the states (S)
- [^] #trk.write: Write the status back (M) :#trk.resolve
- [x] #trk.bulk: Diverging tickets show up in the diagram (M) :#trk.resolve.read
- [x] #trk.bulk.proxy: One request fans out into by_ref queries server-side (S)
- [x] #trk.bulk.mark: A diverging ref turns amber on its node (S)
- [^] #trk.create: Create tickets from nodes (XL)
- [^] #trk.create.proxy: Backend proxy with named endpoints (M)
- [^] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy
@@ -1158,6 +1161,27 @@
The default five are mapped, an unknown column name stays unmapped and is
shown as plain text; making the mapping configurable is still open.
#trk.bulk
With a Taiga login, one bulk request per project and session reads the
status of every referenced ticket, and a ref whose ticket no longer
matches the node's status box turns amber. Details and both sync actions
stay in the node window; the mark is session knowledge and stays out of
export and print.
#trk.bulk.proxy
GET /taiga/tickets takes the plan's refs and fans them out into by_ref
queries next to the Taiga instance, in parallel with capped concurrency.
The cost scales with the refs in the plan, never with a project holding
thousands of tickets - the refs themselves are the pre-filter. A ref that
no longer exists is silently absent; the bulk also pre-fills the node
window's ticket cache.
#trk.bulk.mark
A visible ref colours itself; where the ref is the node id and thus not
part of the title, the renderer appends it as a small badge behind the
label - the same construction as the description mark. An unmapped column
name marks nothing: it says nothing about the plan.
#trk.write
The other direction. Neither side wins by itself: a difference between the
ticket and the status box is marked, and both directions are offered as
+12
View File
@@ -684,6 +684,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
läuft schon beim Aufbau — sonst temporale Todeszone). Der
`pointerdown`-Wächter lässt `.tabmodal-overlay` durch: Der Anmelde-Dialog
gehört zu einer Aktion AUS dem Fenster und darf es nicht zumachen.
- **Abweichungs-Marken im Diagramm (D91-Nachtrag 10):** `collectTicketRefs`,
`bulkPath` (dedupliziert, Deckel 200), `ticketDiverges` und
`refVisibleInLabel` liegen headless in taiga.js. app.js: `scheduleTaigaBulk`
holt je Slug EINMAL je Sitzung (nur mit Sitzung; Hintergrund-Fehler still),
füllt den `taigaTickets`-Cache vor und rendert — außer ein Knoten-Fenster
ist offen (der Neubau schlösse es), dann nur `markTicketDiffs`
(Klassen-Marken, räumt erst ab). Wo die Ref die **Knoten-ID** ist, steht
sie nicht im Label — der **Renderer** hängt dann ein `tref-badge` an
(`tdiffRefs`-Option, die D40-Bauform der ”-Marke: vor dem Messen, die
Geometrie stimmt). Nicht im Export (`excludeSel` + Label-Farbe kommt vom
Knoten), nicht im Druck, nicht im `aria-label` (benannte Grenze: der
Screenreader-Weg ist das Knoten-Fenster).
- **Status zurückschreiben (D91-Nachtrag 7/8):** Weicht der Ticket-Status von
der Statusbox ab, zeigt `paintDiff()` beides und bietet **zwei** Knöpfe —
von selbst geschieht in keine Richtung etwas. `pushStatus()` sucht die
+97 -3
View File
@@ -1,7 +1,7 @@
import './style.css';
import { parse, setFoldMark, setStatusBox, expandShortIds, shortIdClosed } from './parser.js';
import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.js';
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor } from './taiga.js';
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel } from './taiga.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js';
import * as live from './live.js';
@@ -322,7 +322,8 @@ function render(){
freshSet, collapsedSet,
effStatus: effectiveStatus(roots),
overloadTag: overload ? overload.tag : null,
lensTag: lens ? lens.tag : null});
lensTag: lens ? lens.tag : null,
tdiffRefs: ticketDiffBadgeMap(parsed.roots)});
out.innerHTML = r.html;
warnings = warnings.concat(r.warnings);
/* Personen-Leiste (D87): Belastung je Person aus der offenen Pfad-Arbeit
@@ -365,6 +366,12 @@ function render(){
updateFreshBtn(); /* Zähler folgt der gerade gerenderten Menge (D28) */
updateLeanBtn(); /* Stationen des Pfads haben sich geändert (D47) */
updateFoldBtn(); /* Icon + Tooltip = nächster Schritt des Durchschalters (D75) */
/* Abweichungs-Marken (D91-Nachtrag 10): aus dem Cache kostenlos; die
Bulk-Abfrage selbst läuft je Slug nur einmal je Sitzung. Auf den
UNGEFILTERTEN roots, denn die Slug-Vererbung läuft über den ganzen
Baum; was nicht im DOM steht, überspringt das Markieren selbst. */
markTicketDiffs(parsed.roots);
scheduleTaigaBulk(parsed.roots);
}
/* ---------- Personen-Leiste (SPEC §9, D87) ----------
@@ -892,7 +899,7 @@ function diagramToSvg(){
const deco = cs.textDecorationLine.includes('line-through') ? ' text-decoration="line-through"' : '';
/* Seit Knoten umbrechen (D64), belegt ein Label mehrere Zeilenboxen ein
SVG-<text> bricht nicht von selbst; je gemessene Zeile ein Element. */
for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark' + stripFold)){
for(const ln of labelLines(node, '.size,.tags,.ext,.risk,.ownst,.desc-mark,.tref-badge' + stripFold)){
const cx = (ln.left + ln.right) / 2 + ox, cy = (ln.top + ln.bottom) / 2 + oy;
parts.push(`<text x="${cx.toFixed(1)}" y="${(cy+5).toFixed(1)}" text-anchor="middle" fill="${cs.color}" font-size="14" font-weight="${cs.fontWeight}"${deco}>${esc(ln.text)}</text>`);
}
@@ -1742,6 +1749,10 @@ function storeTaigaSession(s){
if(s) localStorage.setItem(LS_TAIGA, JSON.stringify(s));
else localStorage.removeItem(LS_TAIGA);
}catch(_){}
/* Frisch angemeldet: die Abweichungs-Marken holen (D91-Nachtrag 10)
sonst warteten sie auf den naechsten Tastendruck. Deckt jeden
Login-Weg ab, weil alle hier durchlaufen. */
if(s) scheduleTaigaBulk(parse(src.value).roots);
}
/* Welches Backend, und kann es Taiga? Dieselbe Basis-Herleitung wie beim
@@ -1767,6 +1778,9 @@ async function refreshTaiga(){
}
const info = await taigaProbeCache.get(basis);
if(taigaBase === basis){ taigaOn = info.on; taigaWeb = info.web; }
/* Die Antwort kommt NACH dem ersten Neubau die Bulk-Abfrage (D91-N10)
anstoßen, sonst wartete sie auf den nächsten Tastendruck. */
if(taigaOn) scheduleTaigaBulk(parse(src.value).roots);
}
function taigaFetch(path, options, token){
@@ -2020,6 +2034,7 @@ async function pushStatus(key, slug, ref, name, version){
taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)});
}
repaintTicket(key);
markTicketDiffs(parse(src.value).roots); /* die Marke folgt dem geschriebenen Stand */
}
/* Die Spalten je Projekt und Typ einmal je Sitzung, wie der Ticket-Stand
@@ -2061,6 +2076,7 @@ async function loadTicket(key, slug, ref, interactive){
taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)});
}
repaintTicket(key);
markTicketDiffs(parse(src.value).roots); /* die Marke folgt dem frischen Stand */
}
/* Gemalt wird nur, wenn genau dieses Ticket noch im offenen Fenster steht
@@ -2072,6 +2088,84 @@ function repaintTicket(key){
if(tipNode) placeNodeTip(tipNode);
}
/* ---------- Abweichungs-Marken im Diagramm (D91-Nachtrag 10) ----------
Mit Anmeldung holt EINE Bulk-Anfrage je Projekt und Sitzung den Stand
aller referenzierten Tickets; eine Ref, deren Ticket-Status nicht zur
Statusbox des Knotens passt, färbt sich warnfarben (`tref-diff`) die
Einzelheiten samt der zwei Knöpfe stehen wie gehabt im Knoten-Fenster.
Der Fächer läuft im Proxy: Die Kosten skalieren mit den Refs im Plan,
nie mit der Projektgröße. Ohne Anmeldung wird NICHTS geholt (die
D91-Nachtrag-6-Linie), und ein Hintergrund-Fehler bleibt still kein
Dialog, keine Warnung; das Markieren aus dem Cache ist dagegen
kostenlos und läuft bei jedem Neubau mit. */
const taigaBulkDone = new Set(); /* Slugs, die diese Sitzung schon geholt sind */
function scheduleTaigaBulk(roots){
if(!taigaOn) return;
const session = taigaSession();
if(!session || !session.token) return;
for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){
if(taigaBulkDone.has(slug)) continue;
taigaBulkDone.add(slug);
const pfad = bulkPath(slug, eintraege.map(e => e.ref));
if(!pfad) continue;
taigaFetch(pfad, null, session.token).then(map => {
for(const [ref, data] of Object.entries(map || {})){
/* Ein schon einzeln geholtes Ticket bleibt stehen es ist nie
älter als der Bulk (der läuft einmal, ganz am Anfang). */
const key = slug + '/' + ref;
if(!taigaTickets.has(key)) taigaTickets.set(key, {kind: 'ok', data});
}
/* Badges an unsichtbaren Refs brauchen den Neubau (Renderer, oben);
ein offenes Knoten-Fenster würde der aber schließen dann nur die
Klassen-Marken, das Badge kommt mit dem nächsten Neubau. */
if(tipNode) markTicketDiffs(parse(src.value).roots);
else render();
}).catch(err => {
if(err && err.status === 401) storeTaigaSession(null);
});
}
}
/* Welche Knoten brauchen das Abweichungs-BADGE? Nur die, deren Ref nicht
im Label steht (sie ist die Knoten-ID, §1 erstes `#`-Token) sichtbare
Refs färben sich selbst (markTicketDiffs). Entschieden aus dem Cache zum
Zeitpunkt des Neubaus; kommt der Bulk später an, rendert sein Callback. */
function ticketDiffBadgeMap(roots){
const map = new Map();
if(!taigaOn) return map;
for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){
for(const e of eintraege){
const st = taigaTickets.get(slug + '/' + e.ref);
if(st && st.kind === 'ok' && ticketDiverges(e.node, st.data.status)
&& !refVisibleInLabel(e.node, e.ref)){
map.set(e.node.line, e.ref);
}
}
}
return map;
}
/* Die Marken aus dem Cache an die `tref`-Spannen legen reine Anzeige, je
Neubau neu (das DOM ist frisch). Eingeklappte Knoten stehen nicht im DOM
und bleiben unmarkiert; ihr Ticket-Stand liegt trotzdem im Cache. */
function markTicketDiffs(roots){
/* Erst räumen, dann setzen nach nach Taiga schreiben" stimmen Ticket
und Box wieder überein, ohne dass der Baum neu gebaut wurde. */
for(const alt of out.querySelectorAll('.tref-diff')) alt.classList.remove('tref-diff');
for(const [slug, eintraege] of collectTicketRefs(roots, taigaSlugs(roots))){
for(const e of eintraege){
const st = taigaTickets.get(slug + '/' + e.ref);
if(!st || st.kind !== 'ok' || !ticketDiverges(e.node, st.data.status)) continue;
const el = out.querySelector('.node[data-line="' + e.node.line + '"]');
if(!el) continue;
for(const span of el.querySelectorAll('.tref')){
if(span.textContent === '#' + e.ref) span.classList.add('tref-diff');
}
}
}
}
/* Strg+Klick (macOS auch Cmd) auf einen Knoten mit Ticket-Referenz öffnet
das Ticket im Taiga-Frontend (D91-Nachtrag 5) dieselbe Geste wie im
Text. Der einfache Klick bleibt der Link (§6), Alt der Sprung (D25); ohne
+10
View File
@@ -290,6 +290,16 @@ function nodeHtml(n, extra, opts, fold){
Notation. Nicht im Export: Der Text selbst kann dort nicht
erscheinen, eine Marke ohne Ziel wäre Rauschen. */
(n.desc ? '<span class="desc-mark" aria-hidden="true">”</span>' : '') +
/* Abweichungs-Badge (D91-Nachtrag 10): Wo die Ref die
KNOTEN-ID ist, steht sie nicht im Label die Marke
braucht dann einen sichtbaren Träger. Welche Zeilen eines
brauchen, entscheidet app.js aus dem Ticket-Cache; hier
wird nur angehängt (Geometrie stimmt, weil es VOR dem
Messen geschieht die D40-Bauform der -Marke). Nicht im
Export (Sitzungswissen): dort per excludeSel ausgenommen. */
(opts.tdiffRefs && opts.tdiffRefs.has(n.line)
? `<span class="tref tref-diff tref-badge" aria-hidden="true">#${esc(opts.tdiffRefs.get(n.line))}</span>`
: '') +
(n.url ? '<span class="ext" aria-hidden="true">↗</span>' : '') +
riskMark +
sizeBadge +
+13
View File
@@ -779,6 +779,15 @@
bräche eine zu breit geratene Zeile sonst am Bindestrich mitten im Token
(`#US-`/`123`). Die Spanne setzt render.js (markTicketRefs). */
.node .tref{white-space:nowrap}
/* Abweichungs-Marke (D91-Nachtrag 10): Das Ticket hinter dieser Ref sagt
etwas anderes als die Statusbox die Ref selbst wird warnfarben, kein
neues Element in den ohnehin dichten Knoten-Ecken. Nur Anzeige aus der
Sitzung: nicht im Grafikexport (der liest die Knotenfarbe, nicht die
Spanne) und nicht im Druck. */
.node .tref-diff{color:var(--warn);font-weight:600}
/* Das Badge für Refs, die (nur) die Knoten-ID sind dort steht die Ref
sonst nirgends im Knoten. Klein und nachgestellt wie die -Marke. */
.node .tref-badge{margin-left:4px;font-size:.86em}
/* Der Knopf trägt das Zeichen selbst statt eines Icons in der Größe der
SVGs daneben, damit die Kopfzeile ihre Höhe behält. */
.copybtn.idsbtn span{
@@ -1635,6 +1644,10 @@
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
/* Knoten nicht über den Seitenrand zerschneiden */
#out li{break-inside:avoid}
/* Die Abweichungs-Marke hängt an der Sitzung (Anmeldung, Abrufzeit)
gedruckt wird der Plan, nicht mein Abrufstand (D91-Nachtrag 10). */
.node .tref-diff{color:inherit!important;font-weight:inherit!important}
.node .tref-badge{display:none!important}
/* Editierhilfe bzw. Zuruf, nicht drucken (D25, Fokusmarke SPEC §1).
Bei der Cursor-Zeile gehören Erhebung und Puls mit dazu sonst stünde ein
Knoten im Druck grundlos vergrößert und schief zu seiner Anschlusslinie. */
+52
View File
@@ -113,6 +113,58 @@ export function statusListPath(ref, slug){
'?slug=' + encodeURIComponent(slug);
}
/* Alle Ticket-Refs eines Baums, gruppiert nach ihrem geerbten Projekt-Slug
(D91-Nachtrag 10): die Menge, die die Bulk-Abfrage holt die Refs im Plan
sind der Filter, nie die Projektgröße. Knoten ohne Slug fallen heraus
(ohne Projekt ist eine Ref nicht auflösbar dieselbe stille Regel wie
beim Öffnen, D91-Nachtrag 5). Map slug -> [{ref, node}]; dieselbe Ref an
mehreren Knoten steht mehrfach darin (markiert wird je Knoten),
dedupliziert wird erst im Anfrage-Pfad. */
export function collectTicketRefs(roots, slugMap){
const out = new Map();
(function w(ns){
for(const n of ns){
const ref = ticketRefOf(n);
const slug = slugMap.get(n);
if(ref && slug){
if(!out.has(slug)) out.set(slug, []);
out.get(slug).push({ref, node: n});
}
w(n.children || []);
}
})(roots);
return out;
}
/* Der Bulk-Pfad am Proxy (eine Anfrage je Projekt): Refs dedupliziert und
bei 200 gedeckelt der benannte Server-Deckel; mehr referenziert kein
Plan, und die ersten 200 markiert zu bekommen ist besser als wegen eines
400 gar keine. */
export function bulkPath(slug, refs){
const uniq = [...new Set(refs)].slice(0, 200);
if(!uniq.length || !slug) return null;
return '/tickets?slug=' + encodeURIComponent(slug) + '&refs=' + uniq.join(',');
}
/* Steht die Ref sichtbar im LABEL des Knotens? Nein heißt: Sie ist (nur)
seine Knoten-ID (§1, erstes `#`-Token) die Abweichungs-Marke braucht
dann ein Badge hinter dem Label als sichtbaren Träger (D91-Nachtrag 10).
Ein D60-Knoten (die ID vertritt das Label) zählt als sichtbar. */
export function refVisibleInLabel(n, ref){
return new RegExp('(^|\\s)#' + ref + '(?=\\s|$)').test(n.label);
}
/* Weicht der Ticket-Status von der Statusbox des Knotens ab? (Die
Abweichungs-Marke im Diagramm, D91-Nachtrag 10.) Verglichen wird nur, was
abbildbar ist ein unbekannter Spaltenname sagt nichts über den Plan
(dieselbe Regel wie im Knoten-Fenster). Ein Knoten OHNE Statusbox weicht
von jedem abgebildeten Ticket-Status ab auch das zeigt das Fenster so. */
export function ticketDiverges(node, statusName){
const ticket = mapTaigaStatus(statusName);
if(!ticket) return false;
return (node.status ? node.status.code : null) !== ticket.code;
}
/* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text,
D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil.
Dieselben Ausschlüsse wie beim Abhängigkeits-Sprung (D67): nicht im
+91 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { taigaSlugs } from '../src/model.js';
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor } from '../src/taiga.js';
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel } from '../src/taiga.js';
import { setStatusBox } from '../src/parser.js';
/* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die
@@ -188,6 +188,96 @@ describe('storyAncestor — der nächste Vorfahr mit Story-Ref (D91-Nachtrag 9)'
});
});
describe('collectTicketRefs — die Refs eines Baums je Projekt (D91-Nachtrag 10)', () => {
const collect = text => {
const { roots } = parse(text);
return collectTicketRefs(roots, taigaSlugs(roots));
};
it('gruppiert nach dem geerbten Slug, mit Knoten und Zeile', () => {
const m = collect('- P &taiga.a\n - S #US-1\n - T #T-2\n- Q &taiga.b\n - R #US-3');
expect([...m.keys()]).toEqual(['a', 'b']);
expect(m.get('a').map(e => e.ref)).toEqual(['US-1', 'T-2']);
expect(m.get('a')[0].node.line).toBe(2);
expect(m.get('b').map(e => e.ref)).toEqual(['US-3']);
});
it('ohne Slug fällt die Ref heraus — ohne Projekt ist sie nicht auflösbar', () => {
const m = collect('- P\n - S #US-1');
expect(m.size).toBe(0);
});
it('Knoten ohne Ref stehen nicht darin', () => {
const m = collect('- P &taiga.a\n - ohne Ref\n - S #US-1');
expect(m.get('a').length).toBe(1);
});
it('ein übersteuernder Slug ordnet den Teilbaum seinem Projekt zu', () => {
const m = collect('- P &taiga.a\n - S #US-1\n - Fremd &taiga.b #US-9');
expect(m.get('a').map(e => e.ref)).toEqual(['US-1']);
expect(m.get('b').map(e => e.ref)).toEqual(['US-9']);
});
});
describe('bulkPath — der Bulk-Pfad am Proxy (D91-Nachtrag 10)', () => {
it('baut Slug (kodiert) und kommagetrennte Refs', () => {
expect(bulkPath('mi kunde', ['US-1', 'T-2']))
.toBe('/tickets?slug=mi%20kunde&refs=US-1,T-2');
});
it('dedupliziert — dieselbe Ref an zwei Knoten wird einmal geholt', () => {
expect(bulkPath('a', ['US-1', 'US-1', 'T-2'])).toBe('/tickets?slug=a&refs=US-1,T-2');
});
it('deckelt bei 200 — der benannte Server-Deckel', () => {
const viele = Array.from({length: 250}, (_, i) => 'US-' + i);
const pfad = bulkPath('a', viele);
expect(pfad.split(',').length).toBe(200);
});
it('null ohne Refs oder ohne Slug', () => {
expect(bulkPath('a', [])).toBe(null);
expect(bulkPath('', ['US-1'])).toBe(null);
});
});
describe('refVisibleInLabel — braucht die Marke ein Badge? (D91-Nachtrag 10)', () => {
const node = text => parse(text).roots[0];
it('mit eigener Knoten-ID bleibt die Ref im Label: sichtbar', () => {
expect(refVisibleInLabel(node('- #auth: Login #US-123'), 'US-123')).toBe(true);
});
it('als Knoten-ID steht sie nicht im Label: unsichtbar, Badge nötig', () => {
expect(refVisibleInLabel(node('- Login bauen #US-123'), 'US-123')).toBe(false);
});
it('ein D60-Knoten (die ID vertritt das Label) zählt als sichtbar', () => {
expect(refVisibleInLabel(node('- #US-123'), 'US-123')).toBe(true);
});
});
describe('ticketDiverges — die Abweichungs-Marke (D91-Nachtrag 10)', () => {
const node = text => parse(text).roots[0];
it('markiert, wo Ticket und Statusbox Verschiedenes sagen', () => {
expect(ticketDiverges(node('- [ ] A #US-1'), 'In progress')).toBe(true);
});
it('einig — keine Marke', () => {
expect(ticketDiverges(node('- [~] A #US-1'), 'In progress')).toBe(false);
});
it('ein unbekannter Spaltenname sagt nichts über den Plan', () => {
expect(ticketDiverges(node('- [ ] A #US-1'), 'Blocked upstream')).toBe(false);
expect(ticketDiverges(node('- [ ] A #US-1'), null)).toBe(false);
});
it('ein Knoten ohne Statusbox weicht von jedem abgebildeten Status ab', () => {
expect(ticketDiverges(node('- A #US-1'), 'New')).toBe(true);
});
});
describe('appendToken — Token ans sichtbare Zeilenende (D91)', () => {
it('hängt hinter den Inhalt an', () => {
expect(appendToken(' - [ ] Backend (M)', '#US-123')).toBe(' - [ ] Backend (M) #US-123');