Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa5c61db5 | ||
|
|
3c226dab12 | ||
|
|
584cefa388 | ||
|
|
00665592f6 |
@@ -1,23 +0,0 @@
|
|||||||
name: Mail-Pflichttest-Gate
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "mail/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
pflichttest-gate:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.22"
|
|
||||||
- name: Gate bauen
|
|
||||||
working-directory: mail
|
|
||||||
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
|
|
||||||
- name: Geänderte Dateien gegen Pflichttest-Regel prüfen
|
|
||||||
run: |
|
|
||||||
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | /tmp/pflichttestgate
|
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
|
web/*/node_modules/
|
||||||
|
web/*/.next/
|
||||||
|
|||||||
@@ -44,3 +44,41 @@ Keine Commits in dieser Session.
|
|||||||
Keine Änderungen ermittelbar.
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-08-28 21:44 – 21:44 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** nexarch
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- .gitignore | 2 ++
|
||||||
|
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/index.ts | 25 +++++++++++++++++++++++++
|
||||||
|
- web/shl/package.json | 23 +++++++++++++++++++++++
|
||||||
|
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/tsconfig.json | 18 ++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-08-28 21:51 – 21:57 (5m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** nexarch
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- web/shl/package.json | 2 ++
|
||||||
|
- web/shl/vitest.config.ts | 8 ++++++++
|
||||||
|
- web/shl/vitest.setup.ts | 1 +
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
// Command pflichttestgate ist das CI-Gate aus docs/TESTSTRATEGIE-MAIL.md
|
|
||||||
// Abschnitt 4. Aufruf: pflichttestgate < geänderte-dateien.txt
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/mail/internal/pflichttestgate"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var changedFiles []string
|
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if line != "" {
|
|
||||||
changedFiles = append(changedFiles, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "pflichttestgate: eingabe konnte nicht gelesen werden: %v\n", err)
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
violations := pflichttestgate.CheckDiff(changedFiles)
|
|
||||||
if len(violations) == 0 {
|
|
||||||
fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:")
|
|
||||||
for _, v := range violations {
|
|
||||||
fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(os.Stderr, "\nSiehe docs/TESTSTRATEGIE-MAIL.md Abschnitt 4.")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# NEXARCH Mail – Teststrategie
|
|
||||||
|
|
||||||
Stand: 2026-08-30. Ticket: QA-01. Vorbild: Core `QA-01` (`docs/TESTSTRATEGIE-CORE.md`,
|
|
||||||
Fertig) — dieselbe Struktur, für das Mail-Modul übernommen, wo sinnvoll um
|
|
||||||
protokollspezifische Aspekte (IMAP/SMTP/MIME) ergänzt.
|
|
||||||
|
|
||||||
## 1. Warum dieses Dokument existiert
|
|
||||||
|
|
||||||
archivmail (Vorgängerprojekt) testete 2 von 18 Modulen trotz hoher Kritikalität
|
|
||||||
(Compliance-/Protokoll-Logik). Kein zentrales Issue-Tracking — Bugs wurden nur als
|
|
||||||
`BUG-N`-Kommentare im Code festgehalten (`known-issues-archivmail.md`). NEXARCH Mail
|
|
||||||
übernimmt denselben Grundsatz wie Core: **Testpflicht für Auth, Tenant-Scoping und
|
|
||||||
Protokoll-/Compliance-kritische Logik ist ein Merge-Gate, keine Nachrüstung.**
|
|
||||||
|
|
||||||
## 2. Testpyramide
|
|
||||||
|
|
||||||
| Ebene | Werkzeug | Umfang |
|
|
||||||
|---|---|---|
|
|
||||||
| Unit | `go test` (Standardbibliothek) | Einzelne Funktionen/Typen, keine externe Abhängigkeit (DB, Netzwerk, IMAP/SMTP-Socket) |
|
|
||||||
| Integration | `go test` gegen echte PostgreSQL-Instanz (`nexarch_test`-Rolle) | Repository-/Handler-Schicht, Tenant-Scoping, Objekt-Speicher |
|
|
||||||
| Protokoll-Zustandsmaschinen | `go test` gegen echten IMAP-/SMTP-Client-Roundtrip (kein reiner Parser-Unit-Test) | ING-01/ING-02/ING-03: Login-Zustände, Befehlssequenzen, Fehlerpfade |
|
|
||||||
| E2E | Echter HTTP-Roundtrip (`httptest.Server`) bis zum ersten Mail-Frontend-Ticket, danach Playwright/Jest gegen die echte UI | Vollständiger Request-Response-Zyklus, kein reiner Funktionsaufruf |
|
|
||||||
| Vertragstests | Analog Core `QA-07`/DMS-Äquivalent, sobald Mail öffentliche Modul-Adapter-Schnittstellen (RET-05-Konsument, siehe `ARC-11`) hat | Wire-Contract-Stabilität |
|
|
||||||
|
|
||||||
**E2E-Zwischenlösung begründet:** Mail hat aktuell kein Frontend-Ticket (0/66 Board).
|
|
||||||
Playwright/Jest bräuchte eine echte Browser-UI zum Testen — bis zum ersten
|
|
||||||
Mail-Frontend-Ticket ist ein echter HTTP-Roundtrip (kein reiner In-Process-Funktionsaufruf)
|
|
||||||
die ehrliche, tatsächlich verfügbare Untergrenze für "E2E". Siehe Beispiel in
|
|
||||||
Abschnitt 3.
|
|
||||||
|
|
||||||
## 3. Beispieltests je Testart (Akzeptanzkriterium/Pflichtprüfung 2)
|
|
||||||
|
|
||||||
`mail/internal/example` — kein Wegwerf-Demo, sondern eine kleine, tatsächlich nützliche
|
|
||||||
Funktion (E-Mail-Adress-Normalisierung), die spätere Ticket ohnehin brauchen:
|
|
||||||
|
|
||||||
- **Unit:** `normalize_test.go` — `TestNormalizeAddress_*`, keine externe Abhängigkeit.
|
|
||||||
- **Integration:** `store_integration_test.go` — `TestAddressStore_SaveAndCheckExists`,
|
|
||||||
echte Postgres-Instanz, `TEST_TENANT_DSN`, `t.Cleanup`.
|
|
||||||
- **E2E:** `handler_e2e_test.go` — `TestNormalizeHandler_RealHTTPRoundTrip`, echter
|
|
||||||
`httptest.Server`-Roundtrip (TCP, nicht nur Funktionsaufruf).
|
|
||||||
|
|
||||||
Alle sechs Tests real ausgeführt (siehe Prüfungen, Abschnitt 6).
|
|
||||||
|
|
||||||
## 4. Pflichttests als Merge-Gate (Akzeptanzkriterium 3/4)
|
|
||||||
|
|
||||||
Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche ändert:
|
|
||||||
|
|
||||||
- **Auth** (`mail/internal/auth/` — sobald durch ein späteres Ticket angelegt)
|
|
||||||
- **Tenant-Scoping** (`mail/internal/tenant/`, jede Repository-Schicht mit Mandanten-Bezug)
|
|
||||||
- **Protokoll-kritisch** (`mail/internal/ingest/`, `mail/internal/imap/`,
|
|
||||||
`mail/internal/smtp/` — Zustandsmaschinen, Auth-Handshakes der Protokolle selbst)
|
|
||||||
- **Compliance-kritisch** (`mail/internal/arc/` oder gleichwertig — RET-05-Konsument,
|
|
||||||
Löschung/Archivierung, siehe `ARC-11`)
|
|
||||||
|
|
||||||
Regel (identisch zu Core `QA-01`): **jede geänderte `.go`-Datei in einem dieser
|
|
||||||
Bereiche muss von einer geänderten oder neuen `_test.go`-Datei im selben Package
|
|
||||||
begleitet sein.**
|
|
||||||
|
|
||||||
`mail/internal/pflichttestgate` implementiert das Gate (Code-Kopie des Musters aus
|
|
||||||
Core `internal/pflichttestgate`, mit mail-spezifischen Pfadmustern statt Core-Pfaden
|
|
||||||
— bewusst keine Cross-Modul-Abhängigkeit, da Mail als eigenständiges Go-Modul Core
|
|
||||||
nicht importieren kann). `.gitea/workflows/mail-pflichttest-gate.yml` führt es gegen
|
|
||||||
jeden PR-Diff aus.
|
|
||||||
|
|
||||||
Negativtest des Gates selbst (Prüfung 1 dieses Tickets):
|
|
||||||
`mail/internal/pflichttestgate/gate_test.go` simuliert einen Diff mit geänderter
|
|
||||||
`mail/internal/auth/login.go` ohne begleitende Testdatei und erwartet, dass das Gate
|
|
||||||
das als Verstoß erkennt.
|
|
||||||
|
|
||||||
## 5. Bug-Tracking (Akzeptanzkriterium 3)
|
|
||||||
|
|
||||||
**Konvention: Gitea-Issues** auf `gitea.perlbach24.de/scripte/nexarch`, Label `mail`
|
|
||||||
plus Schweregrad-Label (`bug-kritisch`/`bug-normal`/`bug-kosmetisch`). Durchsuchbar
|
|
||||||
über Gitea-Suche/Label-Filter — explizit KEIN Code-Kommentar-Tracking (`BUG-N` wie in
|
|
||||||
archivmail), das laut `known-issues-archivmail.md` genau diese Sichtbarkeitslücke
|
|
||||||
verursacht hat.
|
|
||||||
|
|
||||||
**Realer Durchspiel-Nachweis (Prüfung 3):** Diese Session (nicht Mail-spezifisch, aber
|
|
||||||
derselbe reale Vorgang) fand mehrere echte Bugs, dokumentiert nach exakt diesem
|
|
||||||
Muster in den jeweiligen `*-PRUEFPROTOKOLL.md`-Dateien statt als Code-Kommentar, z. B.
|
|
||||||
`archive/docs/RET-10-PRUEFPROTOKOLL.md`: fehlende CORS-Header bei RET-06-API,
|
|
||||||
gefunden bei einer Sichtprüfung, Symptom (Browser hätte Fetch blockiert), Ursache
|
|
||||||
(kein `Access-Control-Allow-Origin`), Fix (RET-10-Ticket), Nachweis (curl-Test vorher/
|
|
||||||
nachher) — alles durchsuchbar in der Protokolldatei, nicht im Quelltext verstreut.
|
|
||||||
|
|
||||||
**Ehrlich vermerkt:** Ein ECHTER Gitea-Issue konnte in dieser Session nicht angelegt
|
|
||||||
werden (kein Gitea-API-Token verfügbar, nur Git-SSH/HTTPS-Push-Zugriff). Das oben
|
|
||||||
verlinkte Beispiel demonstriert das Vorgehen strukturell (Symptom → Ursache → Fix →
|
|
||||||
Nachweis, durchsuchbar abgelegt), aber NICHT über die Gitea-Issue-Oberfläche selbst.
|
|
||||||
Sobald ein Gitea-Zugriffstoken verfügbar ist, sollte mindestens ein Test-Issue real
|
|
||||||
angelegt werden, um die Konvention vollständig nachzuweisen — offener Punkt, siehe
|
|
||||||
Abschnitt 7.
|
|
||||||
|
|
||||||
## 6. Prüfungen (real durchgeführt)
|
|
||||||
|
|
||||||
| # | Prüfung | Ergebnis |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | Dokument liegt vor und wurde von zweiter Person gegengelesen | **bestanden** — Dokument von der Nutzerin/dem Nutzer (zweite Person) gegengelesen und freigegeben (2026-08-30) |
|
|
||||||
| 2 | Stichprobe: mindestens ein Beispieltest je benannter Testart ist umgesetzt | **bestanden** — 6 Tests real ausgeführt auf 131: `go test ./mail/internal/example/... -v -p 1`, alle grün (3 Unit, 1 Integration, 2 E2E) |
|
|
||||||
| 3 | Bug-Tracking-Vorgehen wurde einmal exemplarisch für einen realen Befund durchgespielt | **teilweise bestanden** — Vorgehen strukturell durchgespielt anhand eines realen, bereits dokumentierten Befunds (RET-10), aber NICHT über die echte Gitea-Issue-Oberfläche (kein API-Token verfügbar). Siehe Abschnitt 5, offener Punkt in Abschnitt 7 |
|
|
||||||
|
|
||||||
## 7. Offene Punkte
|
|
||||||
|
|
||||||
- Echter Gitea-Issue als Nachweis der Bug-Tracking-Konvention noch nicht angelegt
|
|
||||||
(fehlendes API-Token in dieser Session). Sollte nachgeholt werden, sobald Zugriff
|
|
||||||
besteht.
|
|
||||||
- `mail/internal/auth/`, `mail/internal/tenant/`, `mail/internal/ingest/` etc. existieren
|
|
||||||
noch nicht — die Pflichttest-Gate-Pfadmuster sind auf Basis der geplanten
|
|
||||||
Modulstruktur vordefiniert, nicht an echtem Code verifiziert. Erste Nagelprobe: das
|
|
||||||
erste Ticket, das einen dieser Pfade tatsächlich anlegt (voraussichtlich `ING-01`).
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
module gitea.perlbach24.de/scripte/nexarch/mail
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.6.0
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
|
||||||
golang.org/x/crypto v0.17.0 // indirect
|
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
|
||||||
golang.org/x/text v0.14.0 // indirect
|
|
||||||
)
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
|
||||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
|
||||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
|
||||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package example
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NormalizeHandler ist das E2E-Test-Beispiel (QA-01): ein echter
|
|
||||||
// HTTP-Endpunkt, gegen den ein Test einen vollständigen Request-Response-
|
|
||||||
// Zyklus fährt (httptest.Server, echter TCP-Roundtrip, kein reiner
|
|
||||||
// Funktionsaufruf). Sobald das erste Mail-Frontend-Ticket eine echte
|
|
||||||
// Browser-UI mitbringt, wird die E2E-Ebene um Playwright/Jest ergänzt
|
|
||||||
// (siehe QA-01-Teststrategiedokument, Abschnitt 2) — bis dahin ist ein
|
|
||||||
// echter HTTP-Roundtrip die ehrliche, verfügbare Untergrenze für "E2E".
|
|
||||||
type normalizeRequest struct {
|
|
||||||
Address string `json:"address"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type normalizeResponse struct {
|
|
||||||
Normalized string `json:"normalized"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NormalizeHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req normalizeRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungültiger request-body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
normalized, err := NormalizeAddress(req.Address)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_ = json.NewEncoder(w).Encode(normalizeResponse{Normalized: normalized})
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
// E2E-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): echter
|
|
||||||
// HTTP-Request über einen laufenden httptest.Server (TCP-Roundtrip),
|
|
||||||
// nicht nur ein Funktionsaufruf im selben Prozess.
|
|
||||||
package example
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNormalizeHandler_RealHTTPRoundTrip(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(NormalizeHandler))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
body, _ := json.Marshal(normalizeRequest{Address: "Kunde@Beispiel.DE"})
|
|
||||||
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("post: %v", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
||||||
}
|
|
||||||
var out normalizeResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
||||||
t.Fatalf("antwort dekodieren: %v", err)
|
|
||||||
}
|
|
||||||
if out.Normalized != "Kunde@beispiel.de" {
|
|
||||||
t.Fatalf("got %q", out.Normalized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeHandler_InvalidAddressReturns400(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(NormalizeHandler))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
body, _ := json.Marshal(normalizeRequest{Address: "keine-gueltige-adresse"})
|
|
||||||
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("post: %v", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
|
||||||
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// Package example dient QA-01 (Mail): liefert je Testart (Unit,
|
|
||||||
// Integration, E2E) genau EIN reales, lauffähiges Beispiel, an dem sich
|
|
||||||
// spätere Mail-Tickets orientieren können — keine Wegwerf-Demo, sondern
|
|
||||||
// eine tatsächlich nützliche, kleine Funktion (Adress-Normalisierung),
|
|
||||||
// die spätere Ticket (z. B. ING-01/ING-04) ohnehin brauchen werden.
|
|
||||||
package example
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrInvalidAddress = errors.New("example: keine gültige e-mail-adresse")
|
|
||||||
|
|
||||||
// NormalizeAddress normalisiert eine E-Mail-Adresse für den
|
|
||||||
// tenant-scoped Vergleich: Kleinschreibung der Domain-Hälfte
|
|
||||||
// (lokaler Teil bleibt case-sensitive, RFC 5321), Leerraum entfernt.
|
|
||||||
func NormalizeAddress(addr string) (string, error) {
|
|
||||||
addr = strings.TrimSpace(addr)
|
|
||||||
at := strings.LastIndex(addr, "@")
|
|
||||||
if at <= 0 || at == len(addr)-1 {
|
|
||||||
return "", ErrInvalidAddress
|
|
||||||
}
|
|
||||||
local, domain := addr[:at], addr[at+1:]
|
|
||||||
return local + "@" + strings.ToLower(domain), nil
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// Unit-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): keine
|
|
||||||
// externe Abhängigkeit (DB, Netzwerk), reine Funktionsprüfung.
|
|
||||||
package example
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNormalizeAddress_LowercasesDomainOnly(t *testing.T) {
|
|
||||||
got, err := NormalizeAddress("User.Name@Example.COM")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unerwarteter fehler: %v", err)
|
|
||||||
}
|
|
||||||
want := "User.Name@example.com"
|
|
||||||
if got != want {
|
|
||||||
t.Fatalf("got %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeAddress_TrimsWhitespace(t *testing.T) {
|
|
||||||
got, err := NormalizeAddress(" user@example.com ")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unerwarteter fehler: %v", err)
|
|
||||||
}
|
|
||||||
if got != "user@example.com" {
|
|
||||||
t.Fatalf("got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeAddress_RejectsInvalidInput(t *testing.T) {
|
|
||||||
cases := []string{"", "no-at-sign", "@nolocalpart.com", "trailing@"}
|
|
||||||
for _, c := range cases {
|
|
||||||
if _, err := NormalizeAddress(c); !errors.Is(err, ErrInvalidAddress) {
|
|
||||||
t.Fatalf("input %q: erwartet ErrInvalidAddress, habe: %v", c, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
package example
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AddressStore ist das Integrationstest-Beispiel (QA-01): eine
|
|
||||||
// minimale, aber echte DB-gestützte Komponente — nutzt dieselbe
|
|
||||||
// Tenant-DB-Isolationskonvention wie DMS/Archive (t.Cleanup, geteilte
|
|
||||||
// physische Postgres-Instanz auf dem Testhost).
|
|
||||||
type AddressStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAddressStore(pool *pgxpool.Pool) *AddressStore {
|
|
||||||
return &AddressStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AddressStore) SaveNormalized(ctx context.Context, addr string) (string, error) {
|
|
||||||
normalized, err := NormalizeAddress(addr)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if _, err := s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO example_addresses (address) VALUES ($1)
|
|
||||||
ON CONFLICT (address) DO NOTHING
|
|
||||||
`, normalized); err != nil {
|
|
||||||
return "", fmt.Errorf("example: adresse speichern: %w", err)
|
|
||||||
}
|
|
||||||
return normalized, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AddressStore) Exists(ctx context.Context, addr string) (bool, error) {
|
|
||||||
var exists bool
|
|
||||||
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM example_addresses WHERE address = $1)`, addr).Scan(&exists); err != nil {
|
|
||||||
return false, fmt.Errorf("example: existenz prüfen: %w", err)
|
|
||||||
}
|
|
||||||
return exists, nil
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
// Integrations-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2):
|
|
||||||
// echte Postgres-Instanz, folgt derselben Testhost-Konvention wie
|
|
||||||
// DMS/Archive/Core (TEST_TENANT_DSN, t.Cleanup, geteilte physische
|
|
||||||
// Instanz auf 192.168.1.131 — siehe project-nexarch-test-infra).
|
|
||||||
package example
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) *pgxpool.Pool {
|
|
||||||
t.Helper()
|
|
||||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
|
||||||
if dsn == "" {
|
|
||||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
pool, err := pgxpool.New(ctx, dsn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("pool: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { pool.Close() })
|
|
||||||
|
|
||||||
if _, err := pool.Exec(ctx, `
|
|
||||||
CREATE TABLE IF NOT EXISTS example_addresses (
|
|
||||||
address TEXT PRIMARY KEY
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_, _ = pool.Exec(context.Background(), `TRUNCATE example_addresses`)
|
|
||||||
})
|
|
||||||
return pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddressStore_SaveAndCheckExists(t *testing.T) {
|
|
||||||
pool := setupTest(t)
|
|
||||||
store := NewAddressStore(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
normalized, err := store.SaveNormalized(ctx, "Kunde@Beispiel.DE")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("savenormalized: %v", err)
|
|
||||||
}
|
|
||||||
if normalized != "Kunde@beispiel.de" {
|
|
||||||
t.Fatalf("erwartet normalisierte adresse, habe %q", normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
exists, err := store.Exists(ctx, normalized)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
t.Fatal("erwartet real gespeicherte adresse")
|
|
||||||
}
|
|
||||||
|
|
||||||
notExists, err := store.Exists(ctx, "unbekannt@beispiel.de")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if notExists {
|
|
||||||
t.Fatal("nie gespeicherte adresse haette nicht existieren duerfen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
// Package pflichttestgate erzwingt die in docs/TESTSTRATEGIE-MAIL.md
|
|
||||||
// Abschnitt 4 festgelegte Regel: jede geänderte Go-Datei in einem
|
|
||||||
// sicherheitskritischen Bereich (Auth, Tenant-Scoping, Protokoll-/
|
|
||||||
// Compliance-kritisch) muss von einer geänderten oder neuen _test.go-
|
|
||||||
// Datei im selben Package begleitet sein. Bewusste Code-Kopie des
|
|
||||||
// Musters aus Core internal/pflichttestgate — Mail ist ein eigenständiges
|
|
||||||
// Go-Modul und kann Core nicht importieren.
|
|
||||||
package pflichttestgate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sensitivePathPatterns beschreibt die Bereiche aus
|
|
||||||
// TESTSTRATEGIE-MAIL.md Abschnitt 4.
|
|
||||||
var sensitivePathPatterns = []*regexp.Regexp{
|
|
||||||
regexp.MustCompile(`(^|/)mail/internal/auth/`),
|
|
||||||
regexp.MustCompile(`(^|/)mail/internal/tenant/`),
|
|
||||||
regexp.MustCompile(`(^|/)mail/internal/ingest/`),
|
|
||||||
regexp.MustCompile(`(^|/)mail/internal/imap/`),
|
|
||||||
regexp.MustCompile(`(^|/)mail/internal/smtp/`),
|
|
||||||
regexp.MustCompile(`(^|/)mail/internal/arc/`),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Violation beschreibt ein Package mit sicherheitskritischer Änderung
|
|
||||||
// ohne begleitende Testdatei.
|
|
||||||
type Violation struct {
|
|
||||||
Package string
|
|
||||||
ChangedFile string
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSensitive(file string) bool {
|
|
||||||
if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, re := range sensitivePathPatterns {
|
|
||||||
if re.MatchString(file) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckDiff prüft eine Liste geänderter Dateipfade gegen die
|
|
||||||
// Pflichttest-Regel — ein leeres Ergebnis bedeutet: Gate besteht.
|
|
||||||
func CheckDiff(changedFiles []string) []Violation {
|
|
||||||
sensitiveByPkg := map[string]string{}
|
|
||||||
testTouchedPkgs := map[string]bool{}
|
|
||||||
|
|
||||||
for _, f := range changedFiles {
|
|
||||||
pkg := path.Dir(f)
|
|
||||||
if strings.HasSuffix(f, "_test.go") {
|
|
||||||
testTouchedPkgs[pkg] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if isSensitive(f) {
|
|
||||||
if _, seen := sensitiveByPkg[pkg]; !seen {
|
|
||||||
sensitiveByPkg[pkg] = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var violations []Violation
|
|
||||||
for pkg, file := range sensitiveByPkg {
|
|
||||||
if !testTouchedPkgs[pkg] {
|
|
||||||
violations = append(violations, Violation{Package: pkg, ChangedFile: file})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return violations
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// Negativtest des Gates selbst (QA-01 Prüfung 1): ein Diff mit
|
|
||||||
// geänderter mail/internal/auth/login.go ohne begleitende Testdatei
|
|
||||||
// muss als Verstoß erkannt werden.
|
|
||||||
package pflichttestgate
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestCheckDiff_FlagsSensitiveChangeWithoutTest(t *testing.T) {
|
|
||||||
violations := CheckDiff([]string{"mail/internal/auth/login.go"})
|
|
||||||
if len(violations) != 1 {
|
|
||||||
t.Fatalf("erwartet genau 1 verstoß, habe %d: %+v", len(violations), violations)
|
|
||||||
}
|
|
||||||
if violations[0].Package != "mail/internal/auth" {
|
|
||||||
t.Fatalf("falsches package gemeldet: %+v", violations[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckDiff_PassesWhenTestFileAccompanies(t *testing.T) {
|
|
||||||
violations := CheckDiff([]string{
|
|
||||||
"mail/internal/auth/login.go",
|
|
||||||
"mail/internal/auth/login_test.go",
|
|
||||||
})
|
|
||||||
if len(violations) != 0 {
|
|
||||||
t.Fatalf("erwartet keine verstöße, habe: %+v", violations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckDiff_IgnoresNonSensitivePaths(t *testing.T) {
|
|
||||||
violations := CheckDiff([]string{"mail/internal/example/normalize.go"})
|
|
||||||
if len(violations) != 0 {
|
|
||||||
t.Fatalf("erwartet keine verstöße für nicht-sensiblen pfad, habe: %+v", violations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
|
||||||
|
|
||||||
|
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
|
||||||
|
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
|
||||||
|
|
||||||
|
## Enthält
|
||||||
|
|
||||||
|
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
|
||||||
|
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
|
||||||
|
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
|
||||||
|
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
|
||||||
|
|
||||||
|
## Verwendung in einem Modul-Frontend
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<I18nProvider initialLocale="de">
|
||||||
|
<ToastProvider>
|
||||||
|
<Shell modules={[]} tenantLabel="Mandant XY">
|
||||||
|
{children}
|
||||||
|
</Shell>
|
||||||
|
</ToastProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bekannter offener Punkt
|
||||||
|
|
||||||
|
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { Dialog } from "../components/Dialog";
|
||||||
|
import { I18nProvider } from "../i18n/i18n";
|
||||||
|
|
||||||
|
function renderDialog(onClose: () => void) {
|
||||||
|
return render(
|
||||||
|
<I18nProvider>
|
||||||
|
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
|
||||||
|
<button type="button">Erste Aktion</button>
|
||||||
|
<button type="button">Zweite Aktion</button>
|
||||||
|
</Dialog>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dialog: Tastaturbedienung", () => {
|
||||||
|
it("schließt sich bei ESC", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderDialog(onClose);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
|
||||||
|
renderDialog(vi.fn());
|
||||||
|
const closeButton = screen.getByRole("button", { name: /schließen/i });
|
||||||
|
expect(document.activeElement).toBe(closeButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
|
||||||
|
renderDialog(vi.fn());
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||||
|
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { colorTokens } from "../tokens/tokens";
|
||||||
|
|
||||||
|
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
|
||||||
|
function relLuminance(hex: string): number {
|
||||||
|
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||||
|
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrastRatio(a: string, b: string): number {
|
||||||
|
const l1 = relLuminance(a);
|
||||||
|
const l2 = relLuminance(b);
|
||||||
|
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
|
||||||
|
for (const scheme of ["light", "dark"] as const) {
|
||||||
|
const c = colorTokens[scheme];
|
||||||
|
|
||||||
|
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
|
||||||
|
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
|
||||||
|
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
|
||||||
|
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
|
||||||
|
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useI18n } from "../i18n/i18n";
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
export interface DialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
titleId: string;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
const node = dialogRef.current;
|
||||||
|
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||||
|
focusables?.[0]?.focus();
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab" || !node) return;
|
||||||
|
|
||||||
|
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
previouslyFocused.current?.focus();
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="shl-dialog-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className="shl-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
>
|
||||||
|
<div className="shl-dialog-header">
|
||||||
|
<h2 id={titleId}>{title}</h2>
|
||||||
|
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="shl-dialog-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
|
||||||
|
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
|
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
interface FieldWrapperProps {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
|
||||||
|
const inputId = useId();
|
||||||
|
const hintId = hint ? `${inputId}-hint` : undefined;
|
||||||
|
const errorId = error ? `${inputId}-error` : undefined;
|
||||||
|
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shl-field">
|
||||||
|
<label htmlFor={inputId}>{label}</label>
|
||||||
|
{children({ inputId, describedBy })}
|
||||||
|
{hint && (
|
||||||
|
<p id={hintId} className="shl-field-hint">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} className="shl-field-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextFieldProps
|
||||||
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
|
||||||
|
return (
|
||||||
|
<FieldWrapper label={label} error={error} hint={hint}>
|
||||||
|
{({ inputId, describedBy }) => (
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldProps
|
||||||
|
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
|
||||||
|
return (
|
||||||
|
<FieldWrapper label={label} error={error} hint={hint}>
|
||||||
|
{({ inputId, describedBy }) => (
|
||||||
|
<select
|
||||||
|
id={inputId}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
{...selectProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckboxFieldProps
|
||||||
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
|
||||||
|
const inputId = useId();
|
||||||
|
return (
|
||||||
|
<div className="shl-field shl-field-checkbox">
|
||||||
|
<input id={inputId} type="checkbox" {...inputProps} />
|
||||||
|
<label htmlFor={inputId}>{label}</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
|
||||||
|
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useI18n } from "../i18n/i18n";
|
||||||
|
import { useTheme } from "../theme/ThemeProvider";
|
||||||
|
|
||||||
|
export interface ModuleLink {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellProps {
|
||||||
|
modules: ModuleLink[];
|
||||||
|
tenantLabel: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Shell({ modules, tenantLabel, children }: ShellProps) {
|
||||||
|
const { scheme, toggle } = useTheme();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shl-shell">
|
||||||
|
<a className="shl-skip-link" href="#shl-main-content">
|
||||||
|
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
|
||||||
|
</a>
|
||||||
|
<header className="shl-shell-header">
|
||||||
|
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
|
||||||
|
<ul>
|
||||||
|
{modules.map((mod) => (
|
||||||
|
<li key={mod.key}>
|
||||||
|
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
|
||||||
|
{mod.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="shl-shell-context">
|
||||||
|
<span className="shl-tenant-context">{tenantLabel}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={
|
||||||
|
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{scheme === "light" ? "🌙" : "☀️"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useI18n } from "../i18n/i18n";
|
||||||
|
|
||||||
|
export interface TableColumn<Row> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
render: (row: Row) => ReactNode;
|
||||||
|
sortable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableProps<Row> {
|
||||||
|
columns: TableColumn<Row>[];
|
||||||
|
rows: Row[];
|
||||||
|
rowKey: (row: Row) => string;
|
||||||
|
sortKey?: string;
|
||||||
|
sortDirection?: "asc" | "desc";
|
||||||
|
onSort?: (key: string) => void;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Table<Row>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
onSort,
|
||||||
|
caption,
|
||||||
|
}: TableProps<Row>) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="shl-table">
|
||||||
|
{caption && <caption>{caption}</caption>}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const isSorted = column.key === sortKey;
|
||||||
|
const ariaSort = column.sortable
|
||||||
|
? isSorted
|
||||||
|
? sortDirection === "asc"
|
||||||
|
? "ascending"
|
||||||
|
: "descending"
|
||||||
|
: "none"
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<th key={column.key} scope="col" aria-sort={ariaSort}>
|
||||||
|
{column.sortable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSort?.(column.key)}
|
||||||
|
className="shl-table-sort-button"
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
column.header
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<tr key={rowKey(row)}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column.key}>{column.render(row)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useI18n } from "../i18n/i18n";
|
||||||
|
|
||||||
|
export type ToastVariant = "info" | "success" | "danger" | "warning";
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
variant: ToastVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toasts: ToastMessage[];
|
||||||
|
push: (text: string, variant?: ToastVariant) => void;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: string) => {
|
||||||
|
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const push = useCallback((text: string, variant: ToastVariant = "info") => {
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
setToasts((current) => [...current, { id, text, variant }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
|
||||||
|
<span>{toast.text}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(toast.id)}
|
||||||
|
aria-label={t("shl.toast.dismiss")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
|
||||||
|
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
|
||||||
|
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
|
||||||
|
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type Locale = "de" | "en";
|
||||||
|
|
||||||
|
type MessageDict = Record<string, string>;
|
||||||
|
type MessageBundle = Record<Locale, MessageDict>;
|
||||||
|
|
||||||
|
const baseMessages: MessageBundle = {
|
||||||
|
de: {
|
||||||
|
"shl.dialog.close": "Schließen",
|
||||||
|
"shl.toast.dismiss": "Meldung schließen",
|
||||||
|
"shl.table.noRows": "Keine Einträge vorhanden",
|
||||||
|
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
|
||||||
|
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
"shl.dialog.close": "Close",
|
||||||
|
"shl.toast.dismiss": "Dismiss message",
|
||||||
|
"shl.table.noRows": "No entries",
|
||||||
|
"shl.theme.toggleToLight": "Switch to light theme",
|
||||||
|
"shl.theme.toggleToDark": "Switch to dark theme",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
|
||||||
|
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
|
||||||
|
|
||||||
|
export function registerMessages(locale: Locale, messages: MessageDict): void {
|
||||||
|
registry[locale] = { ...registry[locale], ...messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface I18nContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
t: (key: string, fallback?: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
|
|
||||||
|
export function I18nProvider({
|
||||||
|
initialLocale = "de",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initialLocale?: Locale;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [locale, setLocale] = useState<Locale>(initialLocale);
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(
|
||||||
|
() => ({
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
|
||||||
|
}),
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
const ctx = useContext(I18nContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
|
||||||
|
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
|
||||||
|
|
||||||
|
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
|
||||||
|
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
|
||||||
|
|
||||||
|
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
|
||||||
|
|
||||||
|
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
|
||||||
|
export type { Locale } from "./i18n/i18n";
|
||||||
|
|
||||||
|
export { Shell } from "./components/Shell";
|
||||||
|
export type { ShellProps, ModuleLink } from "./components/Shell";
|
||||||
|
|
||||||
|
export { Dialog } from "./components/Dialog";
|
||||||
|
export type { DialogProps } from "./components/Dialog";
|
||||||
|
|
||||||
|
export { Table } from "./components/Table";
|
||||||
|
export type { TableProps, TableColumn } from "./components/Table";
|
||||||
|
|
||||||
|
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
|
||||||
|
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
|
||||||
|
|
||||||
|
export { ToastProvider, useToast } from "./components/Toast";
|
||||||
|
export type { ToastMessage, ToastVariant } from "./components/Toast";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@nexarch/shl",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
|
||||||
|
"main": "index.ts",
|
||||||
|
"types": "index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.4.8",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@types/react": "18.3.3",
|
||||||
|
"@types/react-dom": "18.3.0",
|
||||||
|
"jsdom": "^24.1.1",
|
||||||
|
"typescript": "5.5.3",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
|
||||||
|
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "nexarch-shl-theme";
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
scheme: ColorScheme;
|
||||||
|
setScheme: (scheme: ColorScheme) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
function readStoredScheme(): ColorScheme | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored === "light" || stored === "dark" ? stored : null;
|
||||||
|
} catch {
|
||||||
|
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [scheme, setSchemeState] = useState<ColorScheme>("light");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = readStoredScheme();
|
||||||
|
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setScheme = useCallback((next: ColorScheme) => {
|
||||||
|
setSchemeState(next);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setScheme(scheme === "light" ? "dark" : "light");
|
||||||
|
}, [scheme, setScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vars = cssVariables(scheme);
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
root.style.setProperty(key, value);
|
||||||
|
}
|
||||||
|
root.dataset.shlTheme = scheme;
|
||||||
|
}, [scheme]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentColors(scheme: ColorScheme) {
|
||||||
|
return colorTokens[scheme];
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
|
||||||
|
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
|
||||||
|
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
|
||||||
|
|
||||||
|
export type ColorScheme = "light" | "dark";
|
||||||
|
|
||||||
|
export interface ColorTokens {
|
||||||
|
background: string;
|
||||||
|
surface: string;
|
||||||
|
surfaceRaised: string;
|
||||||
|
border: string;
|
||||||
|
textPrimary: string;
|
||||||
|
textSecondary: string;
|
||||||
|
accent: string;
|
||||||
|
accentContrast: string;
|
||||||
|
danger: string;
|
||||||
|
dangerContrast: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
focusRing: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
|
||||||
|
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
|
||||||
|
export const colorTokens: Record<ColorScheme, ColorTokens> = {
|
||||||
|
light: {
|
||||||
|
background: "#FFFFFF",
|
||||||
|
surface: "#F5F6F8",
|
||||||
|
surfaceRaised: "#FFFFFF",
|
||||||
|
border: "#D7DBE0",
|
||||||
|
textPrimary: "#14181F",
|
||||||
|
textSecondary: "#4B5563",
|
||||||
|
accent: "#1D4ED8",
|
||||||
|
accentContrast: "#FFFFFF",
|
||||||
|
danger: "#B91C1C",
|
||||||
|
dangerContrast: "#FFFFFF",
|
||||||
|
success: "#15803D",
|
||||||
|
warning: "#B45309",
|
||||||
|
focusRing: "#1D4ED8",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "#0F1115",
|
||||||
|
surface: "#181B21",
|
||||||
|
surfaceRaised: "#20242C",
|
||||||
|
border: "#333944",
|
||||||
|
textPrimary: "#F2F4F7",
|
||||||
|
textSecondary: "#B4BAC4",
|
||||||
|
accent: "#5B8DEF",
|
||||||
|
accentContrast: "#0F1115",
|
||||||
|
danger: "#F87171",
|
||||||
|
dangerContrast: "#0F1115",
|
||||||
|
success: "#4ADE80",
|
||||||
|
warning: "#FBBF24",
|
||||||
|
focusRing: "#5B8DEF",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spacing = {
|
||||||
|
xs: "4px",
|
||||||
|
sm: "8px",
|
||||||
|
md: "16px",
|
||||||
|
lg: "24px",
|
||||||
|
xl: "32px",
|
||||||
|
xxl: "48px",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const breakpoints = {
|
||||||
|
mobile: "0px",
|
||||||
|
tablet: "768px",
|
||||||
|
desktop: "1200px",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const typography = {
|
||||||
|
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||||
|
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
|
||||||
|
sizeSm: "13px",
|
||||||
|
sizeMd: "15px",
|
||||||
|
sizeLg: "18px",
|
||||||
|
sizeXl: "24px",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
weightRegular: 400,
|
||||||
|
weightMedium: 500,
|
||||||
|
weightBold: 600,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function cssVariables(scheme: ColorScheme): Record<string, string> {
|
||||||
|
const c = colorTokens[scheme];
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(c)) {
|
||||||
|
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(spacing)) {
|
||||||
|
vars[`--shl-spacing-${key}`] = value;
|
||||||
|
}
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
|
||||||
|
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user