fix(auth): address review findings for semantics split
All checks were successful
All checks were successful
Refs #41 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
eb63636fd9
commit
ffc30db2e5
7 changed files with 94 additions and 70 deletions
|
|
@ -38,6 +38,8 @@ class AppFormScaffold extends StatelessWidget {
|
||||||
// nodes it folds title, text and buttons of the page into
|
// nodes it folds title, text and buttons of the page into
|
||||||
// ONE node (heading + button in one), so screen readers
|
// ONE node (heading + button in one), so screen readers
|
||||||
// find no button (issue #41).
|
// find no button (issue #41).
|
||||||
|
// Alternative: Card(semanticContainer: false); the explicit
|
||||||
|
// wrapper keeps the Card's own container semantics.
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
explicitChildNodes: true,
|
explicitChildNodes: true,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,24 @@ List<String> visibleTexts(WidgetTester tester) => [
|
||||||
/// Wide layout (>= 600 dp): the screen sits in a card (issue #41).
|
/// Wide layout (>= 600 dp): the screen sits in a card (issue #41).
|
||||||
const wideScreenSize = Size(1280, 720);
|
const wideScreenSize = Size(1280, 720);
|
||||||
|
|
||||||
|
/// Asserts that [target] is an enabled button node of its own with [label].
|
||||||
|
///
|
||||||
|
/// `isSemantics` only checks the given properties, so extra flags
|
||||||
|
/// (focusable, ...) added by future Flutter versions do not break it.
|
||||||
|
void expectButtonSemantics(WidgetTester tester, Finder target, String label) {
|
||||||
|
expect(
|
||||||
|
tester.getSemantics(target),
|
||||||
|
isSemantics(
|
||||||
|
label: label,
|
||||||
|
isButton: true,
|
||||||
|
isEnabled: true,
|
||||||
|
hasEnabledState: true,
|
||||||
|
hasTapAction: true,
|
||||||
|
),
|
||||||
|
reason: 'button must be a button node of its own',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Asserts that title, body text and button are separate semantics nodes
|
/// Asserts that title, body text and button are separate semantics nodes
|
||||||
/// (heading / text / button with label), not folded into one node (issue #41).
|
/// (heading / text / button with label), not folded into one node (issue #41).
|
||||||
void expectSeparateSemantics(
|
void expectSeparateSemantics(
|
||||||
|
|
@ -140,25 +158,15 @@ void expectSeparateSemantics(
|
||||||
}) {
|
}) {
|
||||||
expect(
|
expect(
|
||||||
tester.getSemantics(find.text(title).first),
|
tester.getSemantics(find.text(title).first),
|
||||||
matchesSemantics(label: title, isHeader: true),
|
isSemantics(label: title, isHeader: true, isButton: false),
|
||||||
reason: 'title must be a heading of its own',
|
reason: 'title must be a heading of its own',
|
||||||
);
|
);
|
||||||
if (bodyKey != null) {
|
if (bodyKey != null) {
|
||||||
expect(
|
expect(
|
||||||
tester.getSemantics(find.byKey(bodyKey)),
|
tester.getSemantics(find.byKey(bodyKey)),
|
||||||
matchesSemantics(label: bodyLabel),
|
isSemantics(label: bodyLabel, isHeader: false, isButton: false),
|
||||||
reason: 'body text must be a text node of its own',
|
reason: 'body text must be a text node of its own',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
expect(
|
expectButtonSemantics(tester, find.byKey(buttonKey), buttonLabel);
|
||||||
tester.getSemantics(find.byKey(buttonKey)),
|
|
||||||
matchesSemantics(
|
|
||||||
label: buttonLabel,
|
|
||||||
isButton: true,
|
|
||||||
isEnabled: true,
|
|
||||||
hasEnabledState: true,
|
|
||||||
hasTapAction: true,
|
|
||||||
),
|
|
||||||
reason: 'button must be a button node of its own',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,23 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
group('QUE-06 (issue #41): separate semantics nodes in the wide card', () {
|
group('QUE-06 (issue #41): separate semantics nodes in the wide card', () {
|
||||||
|
testWidgets('narrow layout: heading, text and resend button', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final handle = tester.ensureSemantics();
|
||||||
|
await open(tester);
|
||||||
|
|
||||||
|
expectSeparateSemantics(
|
||||||
|
tester,
|
||||||
|
title: _de.authCheckEmailTitle,
|
||||||
|
bodyKey: const Key('check_email_body'),
|
||||||
|
bodyLabel: _de.authCheckEmailBody,
|
||||||
|
buttonKey: const Key('check_email_resend'),
|
||||||
|
buttonLabel: _de.authCheckEmailResend,
|
||||||
|
);
|
||||||
|
handle.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('confirmation: heading, text and resend button', (
|
testWidgets('confirmation: heading, text and resend button', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -319,32 +336,22 @@ void main() {
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
tester.getSemantics(find.text(_de.authCheckEmailTitle).first),
|
tester.getSemantics(find.text(_de.authCheckEmailTitle).first),
|
||||||
matchesSemantics(label: _de.authCheckEmailTitle, isHeader: true),
|
isSemantics(label: _de.authCheckEmailTitle, isHeader: true),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
tester.getSemantics(find.byKey(const Key('check_email_body'))),
|
tester.getSemantics(find.byKey(const Key('check_email_body'))),
|
||||||
matchesSemantics(label: _de.authCheckEmailBodyReset),
|
isSemantics(label: _de.authCheckEmailBodyReset, isButton: false),
|
||||||
|
);
|
||||||
|
expectButtonSemantics(
|
||||||
|
tester,
|
||||||
|
find.byKey(const Key('check_email_different_address')),
|
||||||
|
_de.authCheckEmailDifferentAddress,
|
||||||
|
);
|
||||||
|
expectButtonSemantics(
|
||||||
|
tester,
|
||||||
|
find.byKey(const Key('check_email_back_to_login')),
|
||||||
|
_de.authBackToLogin,
|
||||||
);
|
);
|
||||||
for (final (key, label) in [
|
|
||||||
(
|
|
||||||
const Key('check_email_different_address'),
|
|
||||||
_de.authCheckEmailDifferentAddress,
|
|
||||||
),
|
|
||||||
(const Key('check_email_back_to_login'), _de.authBackToLogin),
|
|
||||||
]) {
|
|
||||||
expect(
|
|
||||||
tester.getSemantics(find.byKey(key)),
|
|
||||||
matchesSemantics(
|
|
||||||
label: label,
|
|
||||||
isButton: true,
|
|
||||||
isEnabled: true,
|
|
||||||
hasEnabledState: true,
|
|
||||||
hasTapAction: true,
|
|
||||||
hasFocusAction: true,
|
|
||||||
isFocusable: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
handle.dispose();
|
handle.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,10 @@ void main() {
|
||||||
handle.dispose();
|
handle.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('invalid link: banner action is a button of its own', (
|
// Checks the banner structure (live region + action button as own
|
||||||
|
// nodes). This also holds without the card fix, because AppBanner is a
|
||||||
|
// semantics container itself; the folding tests are the ones above.
|
||||||
|
testWidgets('invalid link: banner live region and action are own nodes', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
final handle = tester.ensureSemantics();
|
final handle = tester.ensureSemantics();
|
||||||
|
|
@ -313,20 +316,30 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
tester.getSemantics(find.text(_de.authConfirmTitle).first),
|
find.bySemanticsLabel('Fehler: ${_de.authErrorLinkInvalidOrExpired}'),
|
||||||
matchesSemantics(label: _de.authConfirmTitle, isHeader: true),
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(
|
expectButtonSemantics(
|
||||||
tester.getSemantics(find.text(_de.authActionRequestNewLink)),
|
tester,
|
||||||
matchesSemantics(
|
find.text(_de.authActionRequestNewLink),
|
||||||
label: _de.authActionRequestNewLink,
|
_de.authActionRequestNewLink,
|
||||||
isButton: true,
|
);
|
||||||
isEnabled: true,
|
handle.dispose();
|
||||||
hasEnabledState: true,
|
});
|
||||||
hasTapAction: true,
|
|
||||||
hasFocusAction: true,
|
testWidgets('narrow layout: heading, text and button are separate nodes', (
|
||||||
isFocusable: true,
|
tester,
|
||||||
),
|
) async {
|
||||||
|
final handle = tester.ensureSemantics();
|
||||||
|
await open(tester);
|
||||||
|
|
||||||
|
expectSeparateSemantics(
|
||||||
|
tester,
|
||||||
|
title: _de.authConfirmTitle,
|
||||||
|
bodyKey: const Key('email_link_body'),
|
||||||
|
bodyLabel: _de.authConfirmBody,
|
||||||
|
buttonKey: const Key('email_link_submit'),
|
||||||
|
buttonLabel: _de.authConfirmSubmit,
|
||||||
);
|
);
|
||||||
handle.dispose();
|
handle.dispose();
|
||||||
});
|
});
|
||||||
|
|
@ -342,15 +355,10 @@ void main() {
|
||||||
find.bySemanticsLabel('Hinweis: ${_de.authConfirmSuccess}'),
|
find.bySemanticsLabel('Hinweis: ${_de.authConfirmSuccess}'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(
|
expectButtonSemantics(
|
||||||
tester.getSemantics(find.byKey(const Key('email_link_continue'))),
|
tester,
|
||||||
matchesSemantics(
|
find.byKey(const Key('email_link_continue')),
|
||||||
label: _de.authConfirmContinue,
|
_de.authConfirmContinue,
|
||||||
isButton: true,
|
|
||||||
isEnabled: true,
|
|
||||||
hasEnabledState: true,
|
|
||||||
hasTapAction: true,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
handle.dispose();
|
handle.dispose();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -384,21 +384,16 @@ void main() {
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
tester.getSemantics(find.text(_de.authResetTitle).first),
|
tester.getSemantics(find.text(_de.authResetTitle).first),
|
||||||
matchesSemantics(label: _de.authResetTitle, isHeader: true),
|
isSemantics(label: _de.authResetTitle, isHeader: true),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
find.bySemanticsLabel('Hinweis: ${_de.authPasswordChanged}'),
|
find.bySemanticsLabel('Hinweis: ${_de.authPasswordChanged}'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(
|
expectButtonSemantics(
|
||||||
tester.getSemantics(find.byKey(const Key('reset_password_continue'))),
|
tester,
|
||||||
matchesSemantics(
|
find.byKey(const Key('reset_password_continue')),
|
||||||
label: _de.authConfirmContinue,
|
_de.authConfirmContinue,
|
||||||
isButton: true,
|
|
||||||
isEnabled: true,
|
|
||||||
hasEnabledState: true,
|
|
||||||
hasTapAction: true,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
handle.dispose();
|
handle.dispose();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ Schlüssel sind `lowerCamelCase` mit Präfix `auth`.
|
||||||
mit Label (`AppPrimaryButton`, `TextButton`). Das gilt in jeder Breite, auch in der Karte ab 600 dp.
|
mit Label (`AppPrimaryButton`, `TextButton`). Das gilt in jeder Breite, auch in der Karte ab 600 dp.
|
||||||
- Ursache des Fehlers: `Card` ist ein Semantics-Container ohne `explicitChildNodes`. Alle Kinder ohne eigenen
|
- Ursache des Fehlers: `Card` ist ein Semantics-Container ohne `explicitChildNodes`. Alle Kinder ohne eigenen
|
||||||
Container (Titel, `Text`, Knopf) werden in dessen einen Knoten gefaltet. Seiten mit Eingabefeld fielen nicht auf,
|
Container (Titel, `Text`, Knopf) werden in dessen einen Knoten gefaltet. Seiten mit Eingabefeld fielen nicht auf,
|
||||||
weil `AppTextField` & Co. eigene Container sind. Im Web und ab 600 dp trat es auf, auf dem Handy (ohne Karte) nicht.
|
weil `AppTextField` & Co. eigene Container sind. Der Fehler trat auf allen Plattformen ab 600 dp (Karte) auf und wurde im E2E (Web) sichtbar; auf dem Handy ohne Karte nicht.
|
||||||
- Deshalb kapselt `AppFormScaffold` den Karteninhalt in `Semantics(explicitChildNodes: true)`. Neue Layouts mit
|
- Deshalb kapselt `AppFormScaffold` den Karteninhalt in `Semantics(explicitChildNodes: true)`. Neue Layouts mit
|
||||||
`Card`/`Semantics(container: true)` um Seiteninhalt müssen das ebenfalls tun; `MergeSemantics` nur gezielt für
|
`Card`/`Semantics(container: true)` um Seiteninhalt müssen das ebenfalls tun; `MergeSemantics` nur gezielt für
|
||||||
zusammengehörige Einzelelemente, nie um ganze Seiten.
|
zusammengehörige Einzelelemente, nie um ganze Seiten.
|
||||||
|
|
|
||||||
|
|
@ -515,7 +515,7 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
|
||||||
### 2026-11-03 – T3 (Issue #38, Branch `feature/38-auth-e2e`)
|
### 2026-11-03 – T3 (Issue #38, Branch `feature/38-auth-e2e`)
|
||||||
|
|
||||||
1. **7.1 Krit. 9 (web), Rezept korrigiert:** Das Setzen von `expires_at` im `localStorage` erzwingt keinen Refresh, weil `Session.expiresAt` in `supabase_flutter` aus dem JWT-Claim `exp` berechnet wird. Der Test verschiebt stattdessen die Browser-Uhr (`Date`) um 2 h (`shiftBrowserClock` in `e2e/support/web.ts`); der Server rechnet mit der echten Zeit.
|
1. **7.1 Krit. 9 (web), Rezept korrigiert:** Das Setzen von `expires_at` im `localStorage` erzwingt keinen Refresh, weil `Session.expiresAt` in `supabase_flutter` aus dem JWT-Claim `exp` berechnet wird. Der Test verschiebt stattdessen die Browser-Uhr (`Date`) um 2 h (`shiftBrowserClock` in `e2e/support/web.ts`); der Server rechnet mit der echten Zeit.
|
||||||
2. **A11y-Befund (QUE-06):** Auf `/auth/confirm` und der CheckEmail-Seite faltet der Semantics-Baum Titel, Text und Knopf zu einem Knoten zusammen; die E2E-Tests bedienen den Knopf per Tab und Enter (`pressFirstButton`).
|
2. **A11y-Befund (QUE-06):** Auf `/auth/confirm` und der CheckEmail-Seite faltet der Semantics-Baum Titel, Text und Knopf zu einem Knoten zusammen; die E2E-Tests bedienen den Knopf per Tab und Enter (`pressFirstButton`). **Behoben in #41:** `AppFormScaffold` kapselt den Karteninhalt mit `explicitChildNodes`, die E2E-Tests klicken über `getByRole('button')` (`pressFirstButton` entfällt).
|
||||||
|
|
||||||
### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris
|
### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris
|
||||||
|
|
||||||
|
|
@ -529,3 +529,7 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
|
||||||
1. **Entscheidung Chris (Option 1):** Bei `recover`, `resend` und `signUp` zeigt die App für jeden Fehler, der weder Eingabe- noch Transportfehler noch IP-Drosselung ist (insbesondere HTTP 5xx / `unexpected_failure`), dieselbe neutrale Seite wie bei Erfolg. Umsetzung als Allowlist in `openEmailInputFailureFor` und `resendFailureFor`; der Mapper liefert 5xx mit Status bei diesen Aufrufen als `unexpected` (statt `network`), ein `signUp`-500 ohne bekannte Trigger-Meldung ebenfalls (statt `registrationRejected`).
|
1. **Entscheidung Chris (Option 1):** Bei `recover`, `resend` und `signUp` zeigt die App für jeden Fehler, der weder Eingabe- noch Transportfehler noch IP-Drosselung ist (insbesondere HTTP 5xx / `unexpected_failure`), dieselbe neutrale Seite wie bei Erfolg. Umsetzung als Allowlist in `openEmailInputFailureFor` und `resendFailureFor`; der Mapper liefert 5xx mit Status bei diesen Aufrufen als `unexpected` (statt `network`), ein `signUp`-500 ohne bekannte Trigger-Meldung ebenfalls (statt `registrationRejected`).
|
||||||
2. **Login-Resend (Krit. 6), Entscheidung Orchestrator (S4, Chris wird informiert):** Dort bleibt `unexpected` **sichtbar** (allgemeiner Fehler mit „Erneut versuchen“), weil das Konto durch das richtige Passwort bereits bewiesen ist und Neutralisieren nur einen echten SMTP-Ausfall verdecken würde. `emailSendLimited` zeigt dort weiter den Drosselungshinweis (F12). Neutral bleibt es auf CheckEmail-Resend, Forgot und Register. `registrationRejected` ist nur mit bekanntem Grund sichtbar.
|
2. **Login-Resend (Krit. 6), Entscheidung Orchestrator (S4, Chris wird informiert):** Dort bleibt `unexpected` **sichtbar** (allgemeiner Fehler mit „Erneut versuchen“), weil das Konto durch das richtige Passwort bereits bewiesen ist und Neutralisieren nur einen echten SMTP-Ausfall verdecken würde. `emailSendLimited` zeigt dort weiter den Drosselungshinweis (F12). Neutral bleibt es auf CheckEmail-Resend, Forgot und Register. `registrationRejected` ist nur mit bekanntem Grund sichtbar.
|
||||||
3. **4.4, R17, F13** ergänzt; Betriebshinweis in `docs/auth-konfiguration.md`.
|
3. **4.4, R17, F13** ergänzt; Betriebshinweis in `docs/auth-konfiguration.md`.
|
||||||
|
|
||||||
|
### 2026-11-03 – A11y-Fix Semantics-Split (Issue #41, Branch `fix/41-semantics-split`)
|
||||||
|
|
||||||
|
1. **Ursache und Fix:** Die `Card` im Layout ab 600 dp faltete Titel, Text und Knopf in einen Semantics-Knoten. `AppFormScaffold` setzt `Semantics(explicitChildNodes: true)` um den Karteninhalt (Regel in `docs/design/auth-screens.md`). Der Befund aus T3 (A11y-Befund QUE-06) ist damit behoben; `pressFirstButton` ist entfernt.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue