Compare commits

...

2 commits

Author SHA1 Message Date
cschlaefke
eb63636fd9 test(e2e): click buttons via getByRole instead of keyboard helper
Removes pressFirstButton; the web build now exposes real button nodes.

Refs #41

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 13:07:01 +01:00
cschlaefke
cda9417353 fix(auth): keep heading, text and button as separate semantics nodes in the wide card
Card is a semantics container without explicitChildNodes, so on >= 600 dp
(web/tablet) title, text and button folded into one node. AppFormScaffold now
wraps the card content in Semantics(explicitChildNodes: true).

Refs #41

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 13:07:01 +01:00
10 changed files with 256 additions and 24 deletions

View file

@ -7,6 +7,10 @@ import 'package:shuffle/core/theme/app_spacing.dart';
/// centered card of at most [maxWidth] (default
/// [AppBreakpoints.authCardMaxWidth]). The body scrolls, the keyboard pushes
/// the content up (Scaffold resizes) and drags dismiss the keyboard.
///
/// Semantics: the card keeps its children as separate semantics nodes
/// (`explicitChildNodes`), so heading, text and buttons stay individually
/// reachable (see `docs/design/auth-screens.md`).
class AppFormScaffold extends StatelessWidget {
const AppFormScaffold({
required this.child,
@ -30,9 +34,16 @@ class AppFormScaffold extends StatelessWidget {
final carded = width >= AppBreakpoints.medium;
final content = carded
? Card(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: child,
// Card is a semantics container; without explicit child
// nodes it folds title, text and buttons of the page into
// ONE node (heading + button in one), so screen readers
// find no button (issue #41).
child: Semantics(
explicitChildNodes: true,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
child: child,
),
),
)
: child;

View file

@ -124,3 +124,41 @@ List<String> visibleTexts(WidgetTester tester) => [
for (final text in tester.widgetList<Text>(find.byType(Text)))
if (text.data != null) text.data!,
];
/// Wide layout (>= 600 dp): the screen sits in a card (issue #41).
const wideScreenSize = Size(1280, 720);
/// Asserts that title, body text and button are separate semantics nodes
/// (heading / text / button with label), not folded into one node (issue #41).
void expectSeparateSemantics(
WidgetTester tester, {
required String title,
required Key buttonKey,
required String buttonLabel,
Key? bodyKey,
String? bodyLabel,
}) {
expect(
tester.getSemantics(find.text(title).first),
matchesSemantics(label: title, isHeader: true),
reason: 'title must be a heading of its own',
);
if (bodyKey != null) {
expect(
tester.getSemantics(find.byKey(bodyKey)),
matchesSemantics(label: bodyLabel),
reason: 'body text must be a text node of its own',
);
}
expect(
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',
);
}

View file

@ -290,6 +290,65 @@ void main() {
expect(currentPath(router), '/login');
});
group('QUE-06 (issue #41): separate semantics nodes in the wide card', () {
testWidgets('confirmation: heading, text and resend button', (
tester,
) async {
final handle = tester.ensureSemantics();
await open(tester, size: wideScreenSize);
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('reset: heading, text and secondary buttons', (tester) async {
final handle = tester.ensureSemantics();
await pumpAuthRoute(
tester,
repository,
location: '/forgot-password/check-email',
size: wideScreenSize,
);
expect(
tester.getSemantics(find.text(_de.authCheckEmailTitle).first),
matchesSemantics(label: _de.authCheckEmailTitle, isHeader: true),
);
expect(
tester.getSemantics(find.byKey(const Key('check_email_body'))),
matchesSemantics(label: _de.authCheckEmailBodyReset),
);
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();
});
});
testWidgets('QUE-05 Kriterium 2: english texts', (tester) async {
await open(tester, locale: const Locale('en'));

View file

@ -263,6 +263,99 @@ void main() {
});
});
group('QUE-06 (issue #41): separate semantics nodes in the wide card', () {
testWidgets('ready: heading, text and button are separate nodes', (
tester,
) async {
final handle = tester.ensureSemantics();
await open(tester, size: wideScreenSize);
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();
});
testWidgets('recovery: heading, text and button are separate nodes', (
tester,
) async {
final handle = tester.ensureSemantics();
await open(
tester,
location: '/auth/confirm?token_hash=abc123&type=recovery',
size: wideScreenSize,
);
expectSeparateSemantics(
tester,
title: _de.authRecoveryTitle,
bodyKey: const Key('email_link_body'),
bodyLabel: _de.authRecoveryBody,
buttonKey: const Key('email_link_submit'),
buttonLabel: _de.authRecoverySubmit,
);
handle.dispose();
});
testWidgets('invalid link: banner action is a button of its own', (
tester,
) async {
final handle = tester.ensureSemantics();
await open(
tester,
location: '/auth/confirm?token_hash=x&type=bad',
size: wideScreenSize,
);
expect(
tester.getSemantics(find.text(_de.authConfirmTitle).first),
matchesSemantics(label: _de.authConfirmTitle, isHeader: true),
);
expect(
tester.getSemantics(find.text(_de.authActionRequestNewLink)),
matchesSemantics(
label: _de.authActionRequestNewLink,
isButton: true,
isEnabled: true,
hasEnabledState: true,
hasTapAction: true,
hasFocusAction: true,
isFocusable: true,
),
);
handle.dispose();
});
testWidgets('success: banner stays a live region, button is separate', (
tester,
) async {
final handle = tester.ensureSemantics();
await open(tester, size: wideScreenSize);
await tapSubmit(tester);
expect(
find.bySemanticsLabel('Hinweis: ${_de.authConfirmSuccess}'),
findsOneWidget,
);
expect(
tester.getSemantics(find.byKey(const Key('email_link_continue'))),
matchesSemantics(
label: _de.authConfirmContinue,
isButton: true,
isEnabled: true,
hasEnabledState: true,
hasTapAction: true,
),
);
handle.dispose();
});
});
testWidgets('QUE-05 Kriterium 2: english texts', (tester) async {
await open(tester, locale: const Locale('en'));

View file

@ -375,6 +375,34 @@ void main() {
handle.dispose();
});
testWidgets('success view: heading, live region and button (issue #41)', (
tester,
) async {
final handle = tester.ensureSemantics();
when(() => repository.currentStatus).thenReturn(_changed);
await open(tester, size: wideScreenSize);
expect(
tester.getSemantics(find.text(_de.authResetTitle).first),
matchesSemantics(label: _de.authResetTitle, isHeader: true),
);
expect(
find.bySemanticsLabel('Hinweis: ${_de.authPasswordChanged}'),
findsOneWidget,
);
expect(
tester.getSemantics(find.byKey(const Key('reset_password_continue'))),
matchesSemantics(
label: _de.authConfirmContinue,
isButton: true,
isEnabled: true,
hasEnabledState: true,
hasTapAction: true,
),
);
handle.dispose();
});
testWidgets('text scale 2.0 on a small phone does not overflow', (
tester,
) async {

View file

@ -22,6 +22,20 @@ Schlüssel sind `lowerCamelCase` mit Präfix `auth`.
Fokus am Knopf, das Banner wird als Live-Region angesagt.
- Enter im letzten Feld sendet ab (`TextInputAction.done`), sonst `next`.
### Semantics-Struktur (Regel, Issue #41)
- Überschrift, Text und Knopf sind **getrennte Semantics-Knoten**: `heading` (`AuthScreenTitle`), Text, `button`
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
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.
- 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
zusammengehörige Einzelelemente, nie um ganze Seiten.
- Banner bleiben eigene Live-Regionen (`AppBanner`), ihre Aktion ist ein eigener Knopf-Knoten.
- Test: `expectSeparateSemantics` (`app/test/features/auth/presentation/auth_ui_support.dart`) im weiten Layout
(`wideScreenSize`); E2E klickt per `getByRole('button', …)`.
Legende der Skizzen: `[ ]` Feld, `[x]` Checkbox, `( )` Primärknopf, `<…>` Link/TextButton, `!` Banner.
## 2. Skizzen

View file

@ -23,6 +23,8 @@ export const t = {
termsNotAccepted: 'Please agree to the privacy policy and the terms of use.',
confirmSuccess: 'Email confirmed.',
passwordChanged: 'Password changed. Other devices will be signed out; this can take up to an hour.',
confirmSubmit: 'Confirm email address',
recoverySubmit: 'Continue to new password',
home: 'Welcome to Shuffle. The board game catalog is coming soon.',
} as const;
@ -42,19 +44,6 @@ export async function enableSemantics(page: Page): Promise<void> {
await page.locator('flt-semantics').first().waitFor({ state: 'attached' });
}
/**
* Bedient die Schaltflaeche auf Seiten ohne Eingabefeld (Link-Seite, CheckEmail) per Tastatur.
*
* BEFUND (a11y, QUE-06): Auf diesen Seiten faltet der Semantics-Baum Titel, Text und Knopf zu
* EINEM `heading` (bzw. `button`) zusammen; ein eigener `button`-Knoten fehlt, ein Klick auf
* die Mitte bewirkt nichts. Per Tab + Enter geht es. Wird die App korrigiert, kann der Helfer
* auf `getByRole('button', ...)` umgestellt werden.
*/
export async function pressFirstButton(page: Page, tabs = 1): Promise<void> {
for (let i = 0; i < tabs; i++) await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
}
/** Navigiert und schaltet Semantics ein. */
export async function open(page: Page, path: string): Promise<void> {
await page.goto(path);

View file

@ -3,7 +3,7 @@ import { subjects } from '../../support/auth-config';
import { profileByEmail } from '../../support/db';
import { randomEmail, randomPassword } from '../../support/gotrue';
import { extractTokenHash, waitForMail } from '../../support/mailpit';
import { expectSignedIn, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
import { expectSignedIn, login, open, register, t, textOf } from '../../support/web';
test.describe('KTO-01 Kriterium 1 (web): Registrierung bis „E-Mail bestaetigt“ und Anmeldung', () => {
test('KTO-01 Kriterium 1: Registrieren, Link in der Mail oeffnen, bestaetigen, anmelden', async ({ page }) => {
@ -21,7 +21,7 @@ test.describe('KTO-01 Kriterium 1 (web): Registrierung bis „E-Mail bestaetigt
// Der Link aus der Mail fuehrt auf die App; erst der Klick loest die Pruefung aus (Spec E3).
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await page.getByRole('button', { name: t.confirmSubmit }).click();
await expect(textOf(page, t.confirmSuccess)).toBeVisible();
await page.context().clearCookies();

View file

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { GoTrueClient, randomEmail, randomPassword, waitMailInterval } from '../../support/gotrue';
import { expectMailCount, waitForMail } from '../../support/mailpit';
import { freshBrowserContext, fillField, login, open, pressFirstButton, register, t, textOf } from '../../support/web';
import { freshBrowserContext, fillField, login, open, register, t, textOf } from '../../support/web';
const gotrue = new GoTrueClient();

View file

@ -3,7 +3,7 @@ import { subjects } from '../../support/auth-config';
import { expireConfirmationLink, expireRecoveryLink } from '../../support/db';
import { GoTrueClient, randomEmail, randomPassword } from '../../support/gotrue';
import { expectMailCount, extractTokenHash, waitForMail } from '../../support/mailpit';
import { freshBrowserContext, fillField, open, pressFirstButton, t, textOf } from '../../support/web';
import { freshBrowserContext, fillField, open, t, textOf } from '../../support/web';
const gotrue = new GoTrueClient();
@ -29,7 +29,7 @@ test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
// Der Link fuehrt auf die Link-Seite; erst der Klick loest die Pruefung aus.
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await page.getByRole('button', { name: t.recoverySubmit }).click();
await expect(page).toHaveURL(/\/reset-password$/);
await fillField(page.getByRole('textbox', { name: 'New password' }), newPassword);
@ -48,7 +48,7 @@ test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
expect((await gotrue.verify(tokenHash, 'recovery')).status).toBe(200); // Link verbraucht
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await page.getByRole('button', { name: t.recoverySubmit }).click();
await expect(textOf(page, t.linkInvalid)).toBeVisible();
});
@ -60,7 +60,7 @@ test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
await expireRecoveryLink(email);
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await page.getByRole('button', { name: t.recoverySubmit }).click();
await expect(textOf(page, t.linkInvalid)).toBeVisible();
await expect(page.getByRole('button', { name: 'Request a new link' })).toBeVisible();
});
@ -72,7 +72,7 @@ test.describe('KTO-01 Kriterium 8 (web): Passwort zuruecksetzen', () => {
await expireConfirmationLink(email);
await open(page, link.pathname + link.search);
await pressFirstButton(page);
await page.getByRole('button', { name: t.confirmSubmit }).click();
await expect(textOf(page, t.linkInvalid)).toBeVisible();
});