Files
werkjournal/docs/plans/ui-refresh-selectors.md
2026-10-23 13:31:12 +02:00

301 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Selektor-Inventar der Tests (Schritt 1 des UI-Refresh)
Stand 2026-09-12, erstellt aus den Test-Quellen. Alles Folgende ist der Vertrag, an den die automatisierten Suiten gebunden sind. Ein Umbau ist sicher, solange jeder Eintrag in den Spalten „Contract“ erhalten bleibt. Zitate sind `Datei:Zeile`; `BS` = `src/test/kotlin/de/javagil/werkjournal/acceptance/browser/BrowserSteps.kt`.
## 0. Wo die UI-Verträge liegen
| Suite | Datei | Umfang |
|---|---|---|
| Playwright/Cucumber Browser-Abnahme (einzige echte DOM-Suite) | `src/test/kotlin/de/javagil/werkjournal/acceptance/browser/BrowserSteps.kt` (1069 Zeilen, ~223 Locator-/Viewport-/Screenshot-Aufrufe) | alle Seiten |
| Runner und Tag-Filter | `src/test/kotlin/de/javagil/werkjournal/acceptance/BrowserCucumberTest.kt:15` | `@WJ-038 @WJ-039 @WJ-041 @WJ-059 @WJ-058 @WJ-042 @WJ-001 @WJ-005 @WJ-006 @WJ-007 @WJ-008 @WJ-040 @WJ-054 @WJ-048 @WJ-051 @WJ-015 @WJ-037 @WJ-036 @WJ-061 @WJ-062` |
| Zweite Engine (Firefox), dieselben Schritte | `build.gradle.kts:163-180` (`firefoxAcceptance`, Tag `@browser`, `-Dwerkjournal.test.browser=firefox`) | dieselben |
| Vaadin-Komponentenbaum-Assertions (ohne Browser) | `src/test/kotlin/de/javagil/werkjournal/acceptance/habits/HabitSteps.kt` | HabitCounter |
| Serverseitige HTML-String-Assertions | `LoginControllerTest.kt`, `EmailLoginHttpTest.kt`, `AccountDeletionHttpTest.kt` | Login-/Löschseiten |
| Node-JS-Unit-Tests (DOM-Stubs) | `tests/frontend/*.test.cjs`, Aufruf über `tools/test-frontend:18` | Login-Skript, Entwürfe, Push, Session |
| Wartungs-HTML-Quelle | `src/test/kotlin/de/javagil/werkjournal/acceptance/browser/DeploymentProxy.kt:22-23` extrahiert per Regex das Literal aus `scripts/deploy-prototype-remote.py:103` |
Nicht DOM-gebunden: `WorkSteps.kt`, `IdentitySteps.kt`, `PushSteps.kt`, `production/*Steps.kt`, `tests/operations/*.py`. `@WJ-033` ist in `gherkin/push-tests.feature:50` als `@browser` markiert, hat aber keine Step-Definitionen und steht in keinem Runner-Filter (derzeit tot; würde an `/work?date=…&new=true` binden).
### Globale Harness-Annahmen
| Annahme | Vertrag | Wo |
|---|---|---|
| Viewport Desktop | `1280 × 900` | BS:153 |
| Viewport Mobil | `360 × 800`, plus `isMobile` und `hasTouch` (nur Chromium; Firefox testet das schmale Layout ohne Geräteemulation) | BS:153-155 |
| Zusätzliches Resize mitten im Szenario | `page.setViewportSize(360, 800)` vor WJ-061 | BS:1023 |
| Login-Screenshots | `build/reports/browser/<chromium|firefox>/login-{request,code}-{360,1280}.png`, ganze Seite | BS:112-118 |
| Fehlerartefakte | `build/reports/browser/<browser>/<scenario>.html`, `-frames.txt`, `.png`, `-desktop.png` | BS:84-90 |
| Locale/Zeitzone | `de-DE`, `en-GB`, `fr-FR`; `Europe/Berlin`; `ColorScheme.LIGHT` | BS:102, 152-153 |
| Standard-Timeout | 15 s pro Seite | BS:103, 156 |
| Vaadin läuft im Produktionsmodus (`vaadin.productionMode=true`), Debug-DOM fehlt | BS:64 |
## 1. Login-Seite (`LoginController`, `/login`)
Quelle: `src/main/kotlin/de/javagil/werkjournal/identity/LoginController.kt:33-52`. Texte sind inline `label(de, en)`-Literale, keine Bundle-Schlüssel.
### CSS-/Attribut-Selektoren
| Selektor | Test | Szenario | Erzeugt in |
|---|---|---|---|
| `form[action='/login/start']` | BS:126, 386 | alle Browser-Szenarien (Login-Helfer) | LoginController.kt:35 |
| `form[action='/login/code']` | BS:137, 390 | alle | LoginController.kt:45 |
| `input[name='email']` (in jedem Formular) | BS:127, 138, 387, 391 | alle | LoginController.kt:37, 47 |
| `select[name='kind']`, Optionen `login` / `invitation` / `bootstrap` | BS:129 | alle Erst-Logins, WJ-062 | LoginController.kt:40 |
| `input[name='credential']` | BS:130, 131 | alle | LoginController.kt:41 |
| `input[name='code']` | BS:139, 393 | alle | LoginController.kt:48 |
| `summary` (im Start-Formular, `<details id="access-options">`), wird geklickt, um `kind`/`credential` freizulegen | BS:128 | alle | LoginController.kt:39 |
| `#access-options` muss nach einem Einladungs-Deep-Link verborgen sein | BS:388 | WJ-042, WJ-040, WJ-062 | LoginController.kt:39 + `login-invitation.js:42` |
| `html[lang]` = `de` / `en` | BS:106, 145, 177, 184, 361, 949 | WJ-038, WJ-041, WJ-062, WJ-037 | LoginController.kt:33 |
| Start-Abschnitt muss verborgen sein, sobald der Code-Schritt sichtbar ist | BS:392 | WJ-042/040/062 | LoginController.kt:35 |
| `class="brand" href="/"` und `class="site-footer"` im Markup | `EmailLoginHttpTest.kt:39-40` | – | LoginController.kt:34, `PageFooter` |
| Literale Teilstrings `id="request-step" hidden` / `id="code-step" hidden` | `LoginControllerTest.kt:30, 34, 49` | – | LoginController.kt:35, 44 |
| `name="_csrf"`, `method="post"`, escaptes Token | `LoginControllerTest.kt:19-21` | – | LoginController.kt:32, 36 |
### Playwright-Rollen-/Text-Locator
| Locator | Literal | Hinweis |
|---|---|---|
| `start.getByRole(BUTTON)` (BS:132, 389) | (ohne Namen) | Setzt genau EINEN Button im Formular von `#request-step` voraus. Ein zweiter Button bricht jedes Browser-Szenario. Das `<a class="step-link">` (LoginController.kt:43) ist nur sicher, weil es ein Anker ist. |
| `codeForm.getByRole(BUTTON)` (BS:140, 394) | (ohne Namen) | Dieselbe Ein-Button-Annahme für `#code-step`. |
| `page.contains("Anmeldecode anfordern")` | DE-Submit-Label | `LoginControllerTest.kt:23`; LoginController.kt:42 |
| `page.contains("Continue as guest")` | EN-Gastbutton | `EmailLoginHttpTest.kt:38`; LoginController.kt:51 |
| Abwesenheit von `"Google"` | – | `EmailLoginHttpTest.kt:51` |
### JS-Vertrag (Node-Tests, `tests/frontend/login-invitation.test.cjs`)
`#request-step`, `#code-step`, `#access-options`, `#invitation-notice` (`.hidden` wird geschaltet) – `login-invitation.test.cjs:11, 70-73`; `#link-login` (verstecktes Formular, `elements.token`, `requestSubmit()`) – `:13, 19, 59-64`; `#credential-label` (`.hidden` abhängig von `kind`) – `:9, 14, 30`; `document.querySelector('form')` muss das Start-Formular mit `elements.kind/credential/email` liefern – `:19`; `document.body.dataset.signedOut` – `:50`. Erzeugt von `static/login-invitation.js:2, 12-14, 23, 40-43` und `LoginController.kt:34, 38, 39, 41, 52`.
Deep-Link-Form: `"$base/login#invitation=<43 Zeichen>&email=<urlencoded>"` – BS:372, 385; die 43-Zeichen-base64url-Regex wird in `login-invitation.test.cjs:35` geprüft.
## 2. MainLayout / Shell
Kaum berührt: die Suite navigiert per URL (`$base/`, `/work`, `/habits`, `/settings`, `/reports`, `/admin`) statt über die Navigation – BS:146, 181, 241, 365, 431, 493, 558, 611, 630, 695.
| Vertrag | Test | Quelle |
|---|---|---|
| `html[data-appearance]` ∈ `dark` / `system` | BS:201, 209, 219, 225 (WJ-039) | Theme-Skript |
| Berechnete `background-color` von `html` muss sich zwischen hell/dunkel unterscheiden, und `color-scheme: dark` | BS:202, 220-221, 226-230 | `styles.css` Root-Theming |
| `vaadin-notification-card` Anzahl 0 im Offline-Dokument | BS:954 (WJ-037) | Vaadin-Notifications |
| `page.getByRole(BUTTON)` Anzahl 0 im Offline-Dokument | BS:955 | – |
| Nav-`aria-label`/`title` aus `nav.*`-Schlüsseln | (heute nicht geprüft) | `MainLayout.kt:92-99` |
## 3. Today (`TodayView`, Route `""`)
| Selektor / Locator | Literal und Schlüssel | Test | Szenario |
|---|---|---|---|
| `.journal-today .journal-heading h2` sichtbar (`atHome()`) | – (Datum des gezeigten Tages, `TodayView.kt:120-121`) | BS:211, 318, 326, 360, 603, 615, 784, 852, 893, 921, 1151, 1197, 1401, 1469 | fast jedes Browser-Szenario (Post-Login-Sentinel) |
| Überschrift `"Deine Arbeit heute"` / `"Your work, today"` (exakt) als Sprachprobe | `journal.workToday` – `messages_de.properties:142`, `messages_en.properties:142` | BS:214, 246, 580 | WJ-038, WJ-062 |
| `.journal-hours` → `"3 h"` / `"0 h"` | `today.hours={0} h` – `:23` | BS:573, 757 | WJ-007, WJ-054 |
| `.journal-total-number` → genau zwei Elemente `["3 h","3 h"]` | `today.hours` | BS:574, 758 | WJ-007, WJ-054 |
| `.journal-work-column` Anzahl 0 bei deaktiviertem Arbeitstracker | – | BS:620 | WJ-008 |
| Überschrift `"Reading"` weiterhin sichtbar | Habit-Name (Startvorgabe) | BS:621 | WJ-008 |
| `getByText("Today's entries", exact)` wird geklickt, um `<vaadin-details>` zu öffnen | `journal.todayEntries=Today''s entries` – `:143` (gerader Apostroph nach MessageFormat) | BS:1028 | WJ-061 |
| `a.journal-entry` gefiltert nach Beschreibungstext, klickbarer Anker | – | BS:1029 | WJ-061 |
| `.summary-card` (Habit-Karten, gefiltert nach `H2` = Habit-Name) | – | BS:424-425 | WJ-001, 005, 006 |
| `.wj-day-nav vaadin-date-picker` → `hasJSProperty("value", ISO-Datum)` | – (`TodayView.kt:44-63`) | BS:1388, 1393 | WJ-076 |
| `.wj-day-nav` BUTTON `"Previous day"` / `"Today"` / `"Next day"` (exakt, `aria-label`) | `work.previousDay`, `work.todayDay`, `work.nextDay` – `:213-215` | BS:1389, 1410, 1437, 1443-1444 | WJ-076 |
| Überschrift `"Your work, that day"` (exakt) statt `"Your work, today"` | `journal.workDay` – `messages_en.properties:144` | BS:1416 | WJ-076 |
| `.journal-totals` → Text `"That week"` (exakt) statt `"This week"` | `journal.dayWeek` – `messages_en.properties:147` | BS:1421 | WJ-076 |
| `.journal-entries` enthält nur die Einträge des gezeigten Tages | – | BS:1418-1419 | WJ-076 |
| `.journal-today .journal-heading h2` nennt Tag und Jahr, `.journal-today .journal-eyebrow` Anzahl 0, `"A little, every day."` Anzahl 0 | – (`TodayView.kt:120-121`) | BS:1474-1480 | WJ-077 |
| `.wj-day-nav > *` → vier Elemente mit gleicher Oberkante, das letzte höchstens 44 px breit | – (`wj-day-jump`, `styles.css:257-260`) | BS:1484-1489 | WJ-077 |
| `vaadin-date-picker-overlay` sichtbar nach Klick auf `.wj-day-nav vaadin-date-picker`, darin GRIDCELL mit `aria-label` `"1 <Monat> <Jahr>, …"` | – | BS:1494-1499 | WJ-077 |
Quellen: `ui/TodayView.kt:38` (`journal-work-column`), `:40` (`journal-total-number`), `:46` (`journal-hours`), `:52` (`journal-entry` auf einem `Anchor`), `:54-57` (`journal-entry-details`, `isOpened = true`, dann JS `opened = !matchMedia('(max-width: 600px)')`).
Strukturelle Annahme: WJ-061 setzt 360 px (BS:1023), wodurch Details eingeklappt sind; der Test klickt zuerst die Summary (BS:1028), bevor `a.journal-entry` existiert. Wird die Klappe entfernt (Spezifikation 5.2), muss BS:1028 angepasst werden: der Klick auf „Today's entries“ entfällt, `a.journal-entry` muss direkt sichtbar sein.
## 4. Arbeit (`WorkView` / `WorkForm`, Route `/work`)
### Attribut-Selektoren (der tragendste Vertrag der Suite)
| Selektor | Verwendung | Testzeilen | Szenarien |
|---|---|---|---|
| `[data-work-field='customer'] input` | inneres `<input>` der ComboBox; `.fill()`, `.press("Tab")`, `hasValue` | BS:242, 268, 796, 798, 813 | WJ-059, 058, 048, 042, 054, 051, 061, 036, 037, 015 |
| `[data-work-field='project'] input` | inneres `<input>` der ComboBox | BS:243, 269, 814 | dieselben |
| `[data-work-field='hours'] input` | inneres `<input>` des `vaadin-integer-field` | BS:244, 266, 282, 328, 401, 820, 962, 1006 | dieselben |
| `[data-work-field='description'] textarea` | inneres `<textarea>` der `vaadin-text-area`; `.fill()`, `.press("Tab")` | BS:245-246, 262, 267, 283-284, 309, 329, 400, 821, 836, 961, 1005, 1062, 1066 | dieselben |
| `[data-work-field='date']` | `hasJSProperty("value", "2026-09-01")` auf dem `vaadin-date-picker` selbst | BS:270 | WJ-059 |
| `[data-work-recovery] button` | Anzahl 0, wenn kein Entwurf angeboten wird | BS:402 | WJ-042 |
| `[data-work-discard] vaadin-icon` → `hasAttribute("icon", "vaadin:trash")` | Der Verwerfen-Button muss ein `vaadin-icon`-Kind mit `icon="vaadin:trash"` behalten | BS:1063 | WJ-061 |
| `[data-work-entry]` = UUID des Eintrags auf der gewählten Karte | BS:1033 | WJ-061 |
| `.work-entry-selected` (Kartenhülle) | `containsText`, gescopte `getByRole` | BS:1033-1034, 1039, 1057 | WJ-061 |
| `.work-entry-list` | Scope für gespeicherten Eintragstext und Löschen-Button | BS:289, 762, 776 | WJ-059, 058, 054, 061, 051 |
| `.work-day-summary .journal-hours` → `"12 h"` / `"3 h"` | BS:1050, 1053 | WJ-061 |
Quellen: `work/WorkView.kt:150` (Map `date/customer/project/hours/description` → `data-work-field`), `:152` (`data-work-discard`), `:153` (`data-work-recovery`, `.draft-recovery`), `:90` (`work-entry-list`), `:121-124` (`summary-card`, `data-work-entry`, `work-entry-selected`), `:89` (`journal-work-card work-day-summary`), `:87` (`journal-hours`). Komponententypen in `WorkView.kt:137-140`: `DatePicker`, `CatalogPicker` (zwei `ComboBox`), `IntegerField` (`min=1 max=12`, Step-Buttons), `TextArea`. Dieselben Attribute werden von `src/main/frontend/work-drafts.js:38, 63, 78, 114-115` abgefragt: Umbenennen bricht die App, nicht nur die Tests.
### Rollen-/Text-Locator
| Locator | Schlüssel | Test | Szenario |
|---|---|---|---|
| BUTTON `"Save work"` | `work.save` | BS:288, 335, 846, 978 | 059, 058, 054, 051, 061, 036, 015, 007 |
| BUTTON `"Record work"` | `work.new` | BS:565 | WJ-007 |
| BUTTON `"Edit"` | `work.edit` | BS:308, 323, 835, 859, 1057 | WJ-058, 051, 061 |
| BUTTON `"Delete"` (Liste und Dialogbestätigung) | `work.delete` | BS:762, 782 | WJ-054 |
| BUTTON `"Cancel"` (Dialog) | `work.cancel` | BS:770 | WJ-054 |
| BUTTON `"Discard draft"` | `work.discard` | BS:1064 | WJ-061 |
| BUTTON `"Close and keep draft"` | `work.closeKeepDraft` | BS:1060 | WJ-061 |
| BUTTON `"Add one hour"` / `"Subtract one hour"` (gescoped auf `.work-entry-selected`), `isEnabled`/`isDisabled` an den Grenzen 1 und 12 | `work.increase`, `work.decrease` | BS:1040-1041, 1049, 1052 | WJ-061 |
| BUTTON `"Continue with my draft"` | `work.useDraft` | BS:338 | WJ-058 |
| BUTTON `"Open draft: 2026-09-01 · 3 Hours · <description>"` exakt zusammengesetzt | `work.recoverDraft` + `work.hours=Hours`; zusammengesetzt in `work-drafts.js:95` | BS:259-261 | WJ-059 |
| BUTTON `"Create “Explicit customer”"` mit typografischen Anführungszeichen | `catalog.create=Create “{0}”` | BS:799, 809 | WJ-048 |
| HEADING `"Currently saved version"` | `work.currentVersion` | BS:324, 851, 861 | WJ-058, 051 |
| Text `"Delete this work entry?"` (exakt, in `role=dialog`) | `work.confirmDelete` | BS:764 | WJ-054 |
| Text `"No work recorded for this date."` (exakt) | `work.noEntries` | BS:783 | WJ-054 |
| Text `"The entry changed elsewhere. Your draft has been kept. Reload the current record."` (exakt) | `work.conflict` | BS:336, 850 | WJ-058, 051 |
| Text `"Your draft is unchanged. The next save will replace the version shown above."` (exakt) | `work.draftReady` | BS:339 | WJ-058 |
| Text `"Local draft restored. Review it and save explicitly."` (exakt) | `work.draftRestored` | BS:265 | WJ-059 |
| Karte `containsText("· 4 Hours")`, Format der Zeile `customer / project · N Hours` | `work.hours`; Format in `WorkView.kt:125` | BS:1043 | WJ-061 |
| BUTTON `"Mark day off"` / `"Remove day off"`; Text `"Day off. Work entries remain possible."` (exakt) | `dayOff.mark`, `dayOff.remove`, `dayOff.marked` | BS:559-560, 579 | WJ-007 |
| `getByRole(DIALOG)` Anzahl 0 nach Abbrechen | | BS:771 | WJ-054 |
Hinweis für Spezifikation 5.3: Wenn die Eintragszeile kein „Bearbeiten“-Button mehr ist, sondern die Zeile selbst das Sheet öffnet, müssen BS:308, 323, 835, 859, 1057 einen Klick auf die Zeile (oder einen weiterhin vorhandenen, ggf. visuell versteckten Button mit Namen „Edit“) verwenden. Bevorzugt: einen Icon-Button „Edit“ mit `aria-label` in der Zeile behalten. Das Format `customer / project · N Hours` (BS:1043) muss als Text der Zeile erhalten bleiben oder BS:1043 wird angepasst.
### URL- und Storage-Verträge
`"$base/work?date=2026-09-01&new=true"` (BS:241, 399, 873, 879, 960, 1001, 1018); `"$base/work?date=…"` (BS:306, 558, 752, 833); URL muss nach einem Today-Klick `entry=<uuid>` enthalten (BS:1035). localStorage-Präfix `werkjournal.work.<ownerUuid>.` (BS:866-867, 890, 921), ebenso `account-session.test.cjs:54-55, 66-69` und `login-invitation.test.cjs:44-45`.
## 5. Habits (`HabitView` / `HabitCounter` / `HabitCalendarView`, Route `/habits`)
### Karte und Zähler
| Selektor | Vertrag | Test | Szenario |
|---|---|---|---|
| `.summary-card` gefiltert nach `getByRole(HEADING, name="Stretching", exact)` | Habit-Karte muss `.summary-card` mit `H2` = Habit-Name sein | BS:424-425 | WJ-001, 005, 006 |
| `.summary-value` → `"0"`, `"1"`, `"2"` | BS:438, 443 | WJ-001, 005 |
| `.summary-value.habit-warning` (beide Klassen auf einem Element) | vorhanden/abwesend genau bei Grenzverletzung | BS:466, 471, 473 | WJ-005 |
| `.habit-warning` Anzahl 0 / `p.habit-indicator` Anzahl 0 ohne Grenzen | BS:460-461 | WJ-001 |
| `p.habit-warning` → `"Weekly maximum: 1 · 1 above maximum"`, `<p>`-Tag erforderlich | `habit.weeklyMax` + `habit.above={0} above maximum`, verbunden mit `": " … " · "` in `HabitView.kt:36` | BS:478 | WJ-005 |
| `.habit-week-total` → `"This week: 1"` / `"This week: 2"` | `habit.week=This week: {0}` | BS:450, 457, 477 | WJ-001, 005 |
| `vaadin-details-summary` in der Karte wird geklickt, um zu expandieren | BS:449 | WJ-001, 005 |
| BUTTON `"Stretching: increase by one"` (auf Karte gescoped), `isEnabled` jenseits des Maximums | `habit.increment={0}: increase by one` | BS:442, 479 | WJ-001, 005 |
| `getByRole(DIALOG)` Anzahl 0 nach Überschreiten der Grenze | BS:470 | WJ-005 |
Quellen: `HabitView.kt:24` (`summary-card`), `:36-42` (`habit-indicator`/`habit-warning`, `role="status"`), `:45` (`habit-week-total`), `:66-70` (`summary-value` + warning/indicator), `:76` (`Details(text("habit.details"))` → `vaadin-details-summary`), `:60-63` (increment/decrement `aria-label`, Buttontext `+` / `−`).
Diese Szenarien laufen auf Today (BS:418-479). Die Kachelvariante der Spezifikation 5.2 muss daher weiterhin liefern: `.summary-card` mit `H2`-Überschrift = Name, `.summary-value` mit Klasse `habit-warning` bei Grenzverletzung, `.habit-week-total` mit `habit.week`-Text, `p.habit-warning`/`p.habit-indicator` mit `role=status` und den Indikator-Texten, sowie ein `vaadin-details` mit `vaadin-details-summary`, das diese Details enthält. Empfehlung: Kachel = `summary-card wj-tile` mit `H2` (visuell als Name), `.summary-value`, Stepper, und darunter ein `vaadin-details.wj-quiet`, das die bisherigen Info-Absätze und Neu-laden enthält (eingeklappt). So bleibt der Vertrag unverändert.
### Anlage-Dialog
| Locator | Schlüssel | Test |
|---|---|---|
| BUTTON `"Create habit"` | `habit.create` | BS:432 |
| TEXTBOX `"Name"` | `catalog.name` | BS:433 |
| `getByLabel("Weekly maximum", exact)`, labelbasiert | `habit.weeklyMax` | BS:434 |
| BUTTON `"Save changes"` | `catalog.save` | BS:435 |
| BUTTON mit Habit-Namen, `aria-pressed="true"` nach Klick | Habit-Name | BS:436, 494-496 |
### Kalender (WJ-006)
| Selektor | Vertrag | Test |
|---|---|---|
| `.calendar-period`, klickbarer Button, öffnet den Monatsdialog; Text `"September 2026"` / `"August 2026"` | `HabitCalendarView.kt:59-72`, Muster `MMMM uuuu` | BS:500, 511, 528, 536 |
| `vaadin-date-picker input` `.fill("9/1/2026")` (en-GB Vaadin-Kurzformat) | | BS:501 |
| `picker.evaluate("element => element.opened")`, `[part~=toggle-button]`, `hasJSProperty("opened", false)` | DatePicker-Interna | BS:502-503, 509 |
| `getByRole(GRIDCELL, "1 September 2026, Tuesday", exact)` dann `.focus()` + `.press("Enter")` | Overlay-Grid | BS:504-508 |
| BUTTON `"Show"` | `reports.show` | BS:510 |
| `.habit-calendar-week` gefiltert nach `button[aria-label='Aug 31, 2026 · 1'], vaadin-button[aria-label='Aug 31, 2026 · 1']` (`MEDIUM`-Datum + `" · "` + Zähler) | `HabitCalendarView.kt:96` | BS:514-515 |
| `.calendar-day-count` → Array `["1".."7"]` (7 Zellen je Zeile) | `HabitCalendarView.kt:95` | BS:520 |
| `.adjacent-month .calendar-day-count` | `HabitCalendarView.kt:100` | BS:526, 530 |
| XPath-Geschwister: `following-sibling::p[1]` → `"Week total: 28"`; `following-sibling::p[2]` → `"Weekly maximum: 30"` | die beiden `<p>` müssen unmittelbare Folgegeschwister der Wochenzeile bleiben, in dieser Reihenfolge (`HabitCalendarView.kt:107-109`) | BS:521, 534, 538 |
| BUTTON `"Previous month"` / `"Next month"` | `reports.previous`, `reports.next` | BS:527, 535 |
Höchstes Risiko: BS:521/534 hängen an der rohen `<p>`-Geschwisterreihenfolge. Die Wochensumme oder Grenze in einen Container zu packen bricht WJ-006. Die Spezifikation 5.4 verlangt daher keine Umstrukturierung; nur Styling.
### Komponentenbaum (ohne Browser), `HabitSteps.kt`, `@WJ-003` / `@WJ-056`
- Increment-Button wird über `Button.text == "+"` gefunden – `HabitSteps.kt:75`
- Konfliktelement: `role="alert"`, sichtbar, Text enthält `"changed elsewhere"` – `HabitSteps.kt:94` (`HabitView.kt:55`, `habit.conflict`)
- Klasse `summary-value` auf genau einem Nachkommen – `HabitSteps.kt:95, 132`
- Genau 2 Elemente mit Klasse `habit-warning` und `role="status"`, die `"above"` / `"below"` enthalten – `HabitSteps.kt:129-131` (`HabitView.kt:37-40`). Die Kachel-Statuszeile darf also nicht zusätzlich `habit-warning` mit `role=status` tragen, oder die Zählung im Test wird angepasst.
## 6. Berichte (`ReportsView`, `/reports`), WJ-008, WJ-040
| Locator | Schlüssel | Test |
|---|---|---|
| BUTTON `"Export JSON"` (öffnet `vaadin-dialog`) | `export.title`; `ReportsView.kt:53-54` | BS:631, 696 |
| LINK `"Export month · 2026-09"` (exakt) | `export.month`; `ReportsView.kt:56` | BS:697-698 |
| LINK `"Export year · 2026"` (exakt) | `export.year`; `ReportsView.kt:57` | BS:633, 697-698 |
| Muss ein `<a>` (Rolle LINK) sein, das `waitForDownload` auslöst | | BS:632-634, 698 |
## 7. Katalog (`CatalogView`, `/catalog`)
Kein eigenes Browser-Szenario. Einziger Vertrag läuft im Arbeitsformular (WJ-048): BUTTON `"Create “Explicit customer”"` aus `CatalogPicker.kt:27` / `catalog.create` – BS:799, 809; sowie `[data-work-field='customer'|'project'] input`. `catalog-customer` + `aria-pressed` sind ungetestet.
## 8. Einstellungen (`SettingsView`, `/settings`)
| Locator | Literal (EN / DE) | Schlüssel | Test | Szenario |
|---|---|---|---|---|
| `getByText("Language", exact)` | `Language` / `Sprache` | `settings.language` | BS:147 | WJ-041 |
| `getByText("Appearance", exact)` | `Appearance` / `Darstellung` | `settings.theme` | BS:214-215 | WJ-039 |
| `choose()`-Helfer: `vaadin-select` gefiltert nach `hasText(label)` → Klick `vaadin-select-value-button` → `getByRole(OPTION, name=value, exact)` | Labels `"Sprache"`, `"Appearance"`, `"Email language"`; Werte `"English"`, `"Deutsch"`, `"Dark"`, `"Follow system"` | `SettingsView.kt:32-34`; `settings.dark`, `settings.system` | BS:159-163, 182, 203, 208, 369 | WJ-038, 039, 062 |
| BUTTON `"Einstellungen speichern"` (exakt, DE) | | `settings.save` | BS:183 | WJ-038 |
| BUTTON `"Save settings"` (exakt, EN) | | `settings.save` | BS:613 | WJ-008 |
| Text `"Settings saved."` (exakt), Vaadin-Notification | | `settings.saved` | BS:614 | WJ-008 |
| CHECKBOX `"Track work"` → `.uncheck()` | | `settings.work`; `SettingsView.kt:33, 112` | BS:612 | WJ-008 |
| `getByText("Reminder time", exact)` | `Reminder time` / `Erinnerungszeit` | `settings.reminderTime` | BS:272 | WJ-075 |
| `locator("vaadin-time-picker").locator("input")` → `.fill(...)` + `Enter`; `locator("vaadin-time-picker[invalid]")` | Werte in der Sprache des Kontos, z. B. `"8:00 PM"`, `"5:30 PM"` | `settings.reminderTime`, `settings.reminderInvalid`; `SettingsView.kt:38-42` | BS:266, 273, 278-279 | WJ-075 |
| Text `"Mon–Fri at <Zeit>,"` (Teilstring) im Geräteabschnitt | | `push-settings.js:9-11` | BS:293 | WJ-075 |
| BUTTON `"Sign out"` (exakt) | | `settings.logout` | BS:188, 349 | WJ-038, 042, 040 |
| BUTTON `"Sign out everywhere"` (exakt) | | `settings.logoutAll` | BS:897 | WJ-015 |
`choose()` (BS:160) verlangt, dass das Label im `vaadin-select`-Element steht, nicht daneben. Sign-out-Buttons verlassen sich auf `aria-label` (`SettingsView.kt:110`), das die Label-Ausblendung von `responsive-action` überlebt.
## 9. Admin (`AdminView`, `/admin`), WJ-062, WJ-042, WJ-040
Alle Texte sind `label(de, en)`-Paare in `AdminView.kt`.
| Locator | Literal | Quelle | Test |
|---|---|---|---|
| BUTTON `"Create invitation"` (Seitenebene) | | `AdminView.kt:89` | BS:366 |
| `page.getByRole(DIALOG)`, Scope für alles Folgende | `Dialog` in `AdminView.kt:27` | BS:367 |
| TEXTBOX `"Email address"` (exakt) im Dialog | `E-Mail-Adresse` / `Email address` | `AdminView.kt:29` | BS:368 |
| `choose(page, "Email language", "Deutsch"|"English")` | `Sprache der E-Mails` / `Email language` | `AdminView.kt:33-37` | BS:369 |
| Dialog-BUTTON `"Create invitation"` (exakt) | | `AdminView.kt:62` | BS:370 |
| TEXTBOX `"Invitation link"` (exakt) → `.inputValue()` endet mit `&email=bob%40example.test` | `Einladungslink` / `Invitation link` | `AdminView.kt:40`, URL `:50-51` | BS:371-372 |
| BUTTON `"Copy link"` (exakt) | `Link kopieren` / `Copy link` | `AdminView.kt:42` | BS:377 |
| Text `"Link copied."` (exakt) im Dialog | | `AdminView.kt:46` | BS:378 |
| BUTTON `"Send email"` (exakt) | `E-Mail senden` / `Send email` | `AdminView.kt:51` | BS:380 |
| Text `"Invitation submitted for delivery."` (Teilstring) | | `AdminView.kt:56` | BS:381 |
| `navigator.clipboard.writeText` wird vor Copy gepatcht | | `AdminView.kt:43-45` | BS:374-379 |
Diese Verträge betreffen das Paket `auth-flow-refresh`; im UI-Refresh bleibt `AdminView` strukturell unverändert.
## 10. Offline / Wartung
### Offline (WJ-037)
| Vertrag | Test |
|---|---|
| Echter Service Worker: `navigator.serviceWorker.controller !== null` | BS:929 |
| `GET /offline-stub.html` → 200, Body enthält `"No connection"`, Header `X-Frame-Options: SAMEORIGIN` | BS:930-933 (`AuthenticatedSecurityConfiguration.kt:66-67`) |
| `GET /login` → `X-Frame-Options: DENY` | BS:934 |
| Fallback-Erkennung: `document.querySelector('iframe') || document.getElementById('en')` | BS:944-945 |
| Text `"No connection — please try again when you are online."` (exakt, Geviertstrich) im `frameLocator("iframe")` oder auf der Seite | `offline.html` (`&mdash;`), IDs `#de`/`#en` | BS:943, 946-948 |
| `html[lang]="en"` | BS:949 |
| `vaadin-notification-card` Anzahl 0; `getByRole(BUTTON)` Anzahl 0 | BS:954-955 |
### Wartung (WJ-036)
| Vertrag | Test |
|---|---|
| In-App: Text `"Connection interrupted — during maintenance, please try again in a few minutes."` (exakt) | `connection.retry`; BS:982 |
| Öffentlich: HTTP 503, `Retry-After: 60`, `Cache-Control: no-store` | BS:987-989 |
| Öffentlicher Seitentext `"Maintenance — please try again in a few minutes."` (exakt, `<p lang="en">`) | `scripts/deploy-prototype-remote.py:103`, extrahiert von `DeploymentProxy.kt:22-23`; BS:992 |
`DeploymentProxy.kt:23` nutzt eine Regex über die Deploy-Skript-Quelle (`atomic(site / 'maintenance.html', '<einfach zitiertes Literal>')`). Diese Python-Zeile nicht umformatieren und keinen Apostroph im HTML einführen.
## 11. Querschnittliche Strukturannahmen, die ein Umbau erhalten muss
1. `actionButton`-Barrierefreiheitsnamen: `ui/ActionButton.kt:8-11` setzt sichtbaren Text, `aria-label` und Tooltip. CSS blendet `::part(label)` bei `max-width:600px`, in Containern und in `.calendar-toolbar` aus. Jedes `getByRole(BUTTON, name=…)` der mobilen Szenarien überlebt nur wegen `aria-label`.
2. Ein-Button-Formulare auf der Login-Seite (BS:132, 140, 389, 394).
3. Geschwisterreihenfolge im Habit-Kalender (`following-sibling::p[1]` / `[2]`, BS:521/534).
4. `data-work-*`-Attribute sind ein gemeinsamer App-/Test-Vertrag (`work-drafts.js:38, 63, 78, 114-115`).
5. Innere native Elemente müssen erreichbar bleiben: `… input` für ComboBox/IntegerField/DatePicker und `… textarea` für TextArea. Kein Komponententyp-Wechsel bei diesen Feldern.
6. Zusammengesetzte Strings behalten ihre Trenner: `" · "` im Wiederherstellungs-Button (`work-drafts.js:95`), in der Eintragszeile (`WorkView.kt:125`), im Kalender-`aria-label` (`HabitCalendarView.kt:96`), im Habit-Indikator (`HabitView.kt:36`) und in den Export-Links (`ReportsView.kt:56-57`).
7. Typografische Zeichen werden literal geprüft: geschweifte Anführungszeichen in `Create “{0}”`, Geviertstrich in Offline-/Wartungshinweisen, `−` (U+2212) als Dekrement-Buttontext (`HabitView.kt:57`), `''`-Escaping in `journal.todayEntries`.
8. `role="alert"` / `role="status"` auf `WorkForm.error` (`WorkView.kt:141`), `HabitCounter`-Konflikt (`HabitView.kt:55`) und Indikatoren (`HabitView.kt:42`), `AdminView`-Status (`AdminView.kt:41`), `LoginController.kt:30-31`.
9. Beide Sprachbundles werden geprüft, insbesondere `journal.todayHeading` und `settings.save`; WJ-038/062 scheitern, wenn nur eines geändert wird.
10. `vaadin-details`-Default-Open-Regel in `TodayView.kt:56` und die eingeklappten `HabitCounter`-Details (`HabitView.kt:77`) sind in Tests kodiert (BS:1028 klickt erstere; BS:449 letztere). Bei Umsetzung von 5.2 (keine Klappe für Einträge) BS:1028 anpassen.