diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 3021d67..09f7b7d 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1184,6 +1184,11 @@ } }, "doctorSummaryDeclared": "deklariert", + "doctorLinkStore": "Store öffnen", + "doctorLinkApprovals": "Freigaben öffnen", + "doctorLinkAudit": "Protokoll öffnen", + "doctorLinkConfig": "Konfiguration ansehen", + "doctorLinkReleaseNotes": "Versionshinweise im Browser öffnen", "doctorModulesPanelSummary": "{n, plural, =1{1 Modul} other{{n} Module}} · {m, plural, =1{1 Fähigkeit} other{{m} Fähigkeiten}}", "@doctorModulesPanelSummary": { "placeholders": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3b15097..8280776 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1208,6 +1208,11 @@ } }, "doctorSummaryDeclared": "declared", + "doctorLinkStore": "Open the store", + "doctorLinkApprovals": "Open approvals", + "doctorLinkAudit": "Open the audit log", + "doctorLinkConfig": "View the configuration", + "doctorLinkReleaseNotes": "Open the release notes in the browser", "doctorModulesPanelSummary": "{n, plural, =1{1 module} other{{n} modules}} · {m, plural, =1{1 capability} other{{m} capabilities}}", "@doctorModulesPanelSummary": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 07d265b..d2a8e09 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3662,6 +3662,36 @@ abstract class AppLocalizations { /// **'declared'** String get doctorSummaryDeclared; + /// No description provided for @doctorLinkStore. + /// + /// In en, this message translates to: + /// **'Open the store'** + String get doctorLinkStore; + + /// No description provided for @doctorLinkApprovals. + /// + /// In en, this message translates to: + /// **'Open approvals'** + String get doctorLinkApprovals; + + /// No description provided for @doctorLinkAudit. + /// + /// In en, this message translates to: + /// **'Open the audit log'** + String get doctorLinkAudit; + + /// No description provided for @doctorLinkConfig. + /// + /// In en, this message translates to: + /// **'View the configuration'** + String get doctorLinkConfig; + + /// No description provided for @doctorLinkReleaseNotes. + /// + /// In en, this message translates to: + /// **'Open the release notes in the browser'** + String get doctorLinkReleaseNotes; + /// No description provided for @doctorModulesPanelSummary. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 87f06e3..4a56518 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2132,6 +2132,21 @@ class AppLocalizationsDe extends AppLocalizations { @override String get doctorSummaryDeclared => 'deklariert'; + @override + String get doctorLinkStore => 'Store öffnen'; + + @override + String get doctorLinkApprovals => 'Freigaben öffnen'; + + @override + String get doctorLinkAudit => 'Protokoll öffnen'; + + @override + String get doctorLinkConfig => 'Konfiguration ansehen'; + + @override + String get doctorLinkReleaseNotes => 'Versionshinweise im Browser öffnen'; + @override String doctorModulesPanelSummary(int n, int m) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2a2fa2a..f20b7e0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2136,6 +2136,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get doctorSummaryDeclared => 'declared'; + @override + String get doctorLinkStore => 'Open the store'; + + @override + String get doctorLinkApprovals => 'Open approvals'; + + @override + String get doctorLinkAudit => 'Open the audit log'; + + @override + String get doctorLinkConfig => 'View the configuration'; + + @override + String get doctorLinkReleaseNotes => 'Open the release notes in the browser'; + @override String doctorModulesPanelSummary(int n, int m) { String _temp0 = intl.Intl.pluralLogic( diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 2079e36..32c6ae8 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -7,6 +7,7 @@ import '../data/hub.dart'; import '../data/hub_auth_token.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; +import '../main.dart' show StudioShellState; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -75,23 +76,49 @@ class _DoctorPageState extends State { final showUpdate = s.update.updateAvailable || (!s.update.manifestReachable && s.update.localVersion.isNotEmpty); + // Findings that have a dedicated page link straight to it — + // "approvals waiting for review" must be one tap away from + // the approvals inbox, not a dead-end statement. + final shell = StudioShellState.of(context); + final openStore = shell == null + ? null + : () => shell.navigateTo('store'); + final openApprovals = shell == null + ? null + : () => shell.navigateTo('approvals'); + final openAudit = shell == null + ? null + : () => shell.navigateTo('audit'); return ListView( padding: const EdgeInsets.all(ChainSpace.xl), children: [ if (showUpdate) _UpdateBanner(status: s.update), if (showUpdate) const SizedBox(height: ChainSpace.lg), - _SummaryStrip(snapshot: s), + _SummaryStrip( + snapshot: s, + onOpenStore: openStore, + onOpenApprovals: openApprovals, + onOpenAudit: openAudit, + ), const SizedBox(height: ChainSpace.xl), _Section( title: AppLocalizations.of(context)!.doctorEventLogSection, - child: _EventLogPanel(snapshot: s, onRefresh: _refresh), + child: _EventLogPanel( + snapshot: s, + onRefresh: _refresh, + onOpenAudit: openAudit, + ), ), const SizedBox(height: ChainSpace.lg), _Section( title: AppLocalizations.of( context, )!.doctorModulesApprovalsSection, - child: _ModulesPanel(snapshot: s), + child: DoctorModulesPanel( + snapshot: s, + onOpenStore: openStore, + onOpenApprovals: openApprovals, + ), ), const SizedBox(height: ChainSpace.lg), _Section( @@ -150,22 +177,32 @@ class _Section extends StatelessWidget { class _SummaryStrip extends StatelessWidget { final DoctorSnapshot snapshot; + final VoidCallback? onOpenStore; + final VoidCallback? onOpenApprovals; + final VoidCallback? onOpenAudit; - const _SummaryStrip({required this.snapshot}); + const _SummaryStrip({ + required this.snapshot, + this.onOpenStore, + this.onOpenApprovals, + this.onOpenAudit, + }); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Row( children: [ - _StatTile( + DoctorStatTile( label: l.doctorSummaryModules, value: snapshot.moduleCount.toString(), subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount), icon: Icons.extension_outlined, + onTap: onOpenStore, + linkLabel: l.doctorLinkStore, ), const SizedBox(width: ChainSpace.md), - _StatTile( + DoctorStatTile( label: l.doctorSummaryApprovals, value: snapshot.pendingApprovals.toString(), subtitle: l.doctorSummaryPending, @@ -173,9 +210,11 @@ class _SummaryStrip extends StatelessWidget { tone: snapshot.pendingApprovals > 0 ? ChainPillTone.warning : ChainPillTone.neutral, + onTap: onOpenApprovals, + linkLabel: l.doctorLinkApprovals, ), const SizedBox(width: ChainSpace.md), - _StatTile( + DoctorStatTile( label: l.doctorSummaryAudit, value: snapshot.chainHealthy ? '✓' : '⚠', subtitle: l.doctorSummaryChain( @@ -186,9 +225,11 @@ class _SummaryStrip extends StatelessWidget { tone: snapshot.chainHealthy ? ChainPillTone.success : ChainPillTone.danger, + onTap: onOpenAudit, + linkLabel: l.doctorLinkAudit, ), const SizedBox(width: ChainSpace.md), - _StatTile( + DoctorStatTile( label: l.doctorSummaryServices, value: snapshot.services.length.toString(), subtitle: l.doctorSummaryDeclared, @@ -199,19 +240,32 @@ class _SummaryStrip extends StatelessWidget { } } -class _StatTile extends StatelessWidget { +/// One summary tile of the doctor page. Public + callback-driven so +/// the deep-link widget test can pump it directly: when [onTap] is +/// set, the whole tile becomes a button (chevron affordance, +/// tooltip + semantics from [linkLabel]) that jumps to the page +/// where the finding can be acted on. +class DoctorStatTile extends StatelessWidget { final String label; final String value; final String subtitle; final IconData icon; final ChainPillTone tone; + final VoidCallback? onTap; - const _StatTile({ + /// Human-readable tap target ("Open approvals"). Required when + /// [onTap] is set; doubles as tooltip and semantics label. + final String? linkLabel; + + const DoctorStatTile({ + super.key, required this.label, required this.value, required this.subtitle, required this.icon, this.tone = ChainPillTone.neutral, + this.onTap, + this.linkLabel, }); @override @@ -223,49 +277,72 @@ class _StatTile extends StatelessWidget { ChainPillTone.danger => theme.colorScheme.error, _ => theme.colorScheme.primary, }; - return Expanded( - child: ChainCard( - accentLeft: accentColor, - padding: const EdgeInsets.all(ChainSpace.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant), - const SizedBox(width: ChainSpace.xs), - Flexible( - child: Text( - label.toUpperCase(), - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.5, - fontSize: 10, - ), + Widget card = ChainCard( + accentLeft: accentColor, + padding: const EdgeInsets.all(ChainSpace.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: ChainSpace.xs), + Flexible( + child: Text( + label.toUpperCase(), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.5, + fontSize: 10, ), ), + ), + if (onTap != null) ...[ + const Spacer(), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), ], + ], + ), + const SizedBox(height: ChainSpace.sm), + Text( + value, + style: theme.textTheme.displaySmall?.copyWith( + color: accentColor, + fontWeight: FontWeight.w700, ), - const SizedBox(height: ChainSpace.sm), - Text( - value, - style: theme.textTheme.displaySmall?.copyWith( - color: accentColor, - fontWeight: FontWeight.w700, - ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - const SizedBox(height: 2), - Text( - subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), + ), + ], ), ); + if (onTap != null) { + // ChainCard is a plain Container, so the InkWell needs its own + // transparent Material to paint the hover/ripple feedback. + card = Tooltip( + message: linkLabel ?? '', + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(ChainRadius.md), + child: Semantics(button: true, label: linkLabel, child: card), + ), + ), + ); + } + return Expanded(child: card); } } @@ -277,13 +354,60 @@ class _EventLogPanel extends StatelessWidget { /// after import / restore without restarting the daemon. final VoidCallback onRefresh; - const _EventLogPanel({required this.snapshot, required this.onRefresh}); + /// Jumps to the audit page — the finding's detail view (per-event + /// list, forensic exports) lives there. + final VoidCallback? onOpenAudit; + + const _EventLogPanel({ + required this.snapshot, + required this.onRefresh, + this.onOpenAudit, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final healthy = snapshot.chainHealthy; final l = AppLocalizations.of(context)!; + Widget headline = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + healthy ? l.doctorChainIntact : l.doctorChainTampered, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + healthy + ? l.doctorChainIntactDetail(snapshot.eventChainTotal) + : l.doctorChainTamperedDetail( + snapshot.eventChainVerified, + snapshot.eventChainTotal, + snapshot.eventChainTamperedAt ?? '', + ), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + if (onOpenAudit != null) { + headline = Tooltip( + message: l.doctorLinkAudit, + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onOpenAudit, + borderRadius: BorderRadius.circular(ChainRadius.sm), + child: Semantics(button: true, label: l.doctorLinkAudit, + child: headline), + ), + ), + ); + } return ChainCard( child: Row( children: [ @@ -293,32 +417,7 @@ class _EventLogPanel extends StatelessWidget { color: healthy ? ChainColors.success : theme.colorScheme.error, ), const SizedBox(width: ChainSpace.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - healthy ? l.doctorChainIntact : l.doctorChainTampered, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - healthy - ? l.doctorChainIntactDetail(snapshot.eventChainTotal) - : l.doctorChainTamperedDetail( - snapshot.eventChainVerified, - snapshot.eventChainTotal, - snapshot.eventChainTamperedAt ?? '', - ), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + Expanded(child: headline), OutlinedButton.icon( onPressed: onRefresh, icon: const Icon(Icons.fact_check_outlined, size: 16), @@ -834,10 +933,43 @@ String _sourceKindLabel(String kind) { } } -class _ModulesPanel extends StatelessWidget { +/// The modules + approvals findings card. Public + callback-driven +/// so the deep-link widget test can pump it without a hub: each row +/// that has a dedicated page is a tap target ("approvals waiting +/// for review" jumps to the approvals inbox, the modules line to +/// the store). +class DoctorModulesPanel extends StatelessWidget { final DoctorSnapshot snapshot; + final VoidCallback? onOpenStore; + final VoidCallback? onOpenApprovals; - const _ModulesPanel({required this.snapshot}); + const DoctorModulesPanel({ + super.key, + required this.snapshot, + this.onOpenStore, + this.onOpenApprovals, + }); + + Widget _linkRow( + BuildContext context, { + required Widget child, + required VoidCallback? onTap, + required String linkLabel, + }) { + if (onTap == null) return child; + return Tooltip( + message: linkLabel, + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(ChainRadius.sm), + child: Semantics(button: true, label: linkLabel, child: child), + ), + ), + ); + } @override Widget build(BuildContext context) { @@ -847,69 +979,95 @@ class _ModulesPanel extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Icon( - Icons.extension_outlined, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: ChainSpace.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.doctorModulesPanelSummary( - snapshot.moduleCount, - snapshot.capabilityCount, - ), - style: theme.textTheme.bodyMedium, - ), - if (snapshot.capabilitiesBySource.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - _sourceBreakdown(snapshot.capabilitiesBySource), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ], + _linkRow( + context, + onTap: onOpenStore, + linkLabel: l.doctorLinkStore, + child: Row( + children: [ + Icon( + Icons.extension_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant, ), - ), - ChainPill( - label: snapshot.moduleCount > 0 - ? l.doctorPillLoaded - : l.doctorPillEmpty, - tone: snapshot.moduleCount > 0 - ? ChainPillTone.success - : ChainPillTone.neutral, - ), - ], + const SizedBox(width: ChainSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.doctorModulesPanelSummary( + snapshot.moduleCount, + snapshot.capabilityCount, + ), + style: theme.textTheme.bodyMedium, + ), + if (snapshot.capabilitiesBySource.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + _sourceBreakdown(snapshot.capabilitiesBySource), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ChainPill( + label: snapshot.moduleCount > 0 + ? l.doctorPillLoaded + : l.doctorPillEmpty, + tone: snapshot.moduleCount > 0 + ? ChainPillTone.success + : ChainPillTone.neutral, + ), + if (onOpenStore != null) ...[ + const SizedBox(width: ChainSpace.xs), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), ), const Divider(height: ChainSpace.xl), - Row( - children: [ - Icon( - Icons.inbox_outlined, - size: 18, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(width: ChainSpace.sm), - Text( - snapshot.pendingApprovals == 0 - ? l.doctorApprovalsNone - : l.doctorApprovalsCount(snapshot.pendingApprovals), - style: theme.textTheme.bodyMedium, - ), - const Spacer(), - if (snapshot.pendingApprovals > 0) - ChainPill( - label: l.doctorApprovalsAttentionPill, - tone: ChainPillTone.warning, + _linkRow( + context, + onTap: onOpenApprovals, + linkLabel: l.doctorLinkApprovals, + child: Row( + children: [ + Icon( + Icons.inbox_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant, ), - ], + const SizedBox(width: ChainSpace.sm), + Text( + snapshot.pendingApprovals == 0 + ? l.doctorApprovalsNone + : l.doctorApprovalsCount(snapshot.pendingApprovals), + style: theme.textTheme.bodyMedium, + ), + const Spacer(), + if (snapshot.pendingApprovals > 0) + ChainPill( + label: l.doctorApprovalsAttentionPill, + tone: ChainPillTone.warning, + ), + if (onOpenApprovals != null) ...[ + const SizedBox(width: ChainSpace.xs), + Icon( + Icons.chevron_right, + size: 16, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ], + ), ), ], ), @@ -960,6 +1118,21 @@ class _ServicesPanel extends StatelessWidget { color: theme.colorScheme.onSurfaceVariant, ), ), + // The hint names config.yaml — put the file one tap away + // instead of making the operator hunt for it. + if (snapshot.paths.configPath.isNotEmpty) + OutlinedButton.icon( + onPressed: () => showFaiConfigViewer( + context, + path: snapshot.paths.configPath, + title: l.doctorPathConfig, + ), + icon: const Icon(Icons.settings_outlined, size: 14), + label: Text(l.doctorLinkConfig), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), ], ), ); @@ -1085,11 +1258,32 @@ class _UpdateBannerState extends State<_UpdateBanner> { ), if (available && status.releaseNotesUrl != null) ...[ const SizedBox(height: 4), - Text( - l.doctorReleaseNotes(status.releaseNotesUrl!), - style: ChainTheme.mono( - size: 11, - color: theme.colorScheme.primary, + // A URL the operator cannot click is a finding + // without a link — open it in the browser. + Tooltip( + message: l.doctorLinkReleaseNotes, + waitDuration: const Duration(milliseconds: 400), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => SystemActions.openInOs( + status.releaseNotesUrl!, + ), + child: Semantics( + button: true, + label: l.doctorLinkReleaseNotes, + child: Text( + l.doctorReleaseNotes(status.releaseNotesUrl!), + style: ChainTheme.mono( + size: 11, + color: theme.colorScheme.primary, + ).copyWith( + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ), + ), + ), + ), ), ), ], diff --git a/test/doctor_deeplinks_test.dart b/test/doctor_deeplinks_test.dart new file mode 100644 index 0000000..1b44597 --- /dev/null +++ b/test/doctor_deeplinks_test.dart @@ -0,0 +1,122 @@ +// Doctor deep links — findings with a dedicated page must be one +// tap away from it (usertest follow-up: "approvals waiting for +// review" was a dead-end statement). The tiles and panel rows are +// public callback-driven widgets so these tests pump each variant +// without a live hub. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/doctor.dart'; + +const _snapshot = DoctorSnapshot( + moduleCount: 2, + capabilityCount: 3, + pendingApprovals: 1, + eventChainTotal: 5, + eventChainVerified: 5, + eventChainTamperedAt: null, + services: [], + update: UpdateStatus( + channel: 'stable', + localVersion: '0.22.0', + latestVersion: '0.22.0', + updateAvailable: false, + manifestReachable: true, + ), + paths: DaemonPathsSnapshot( + logPath: '', + dbPath: '', + modulesDir: '', + flowsDir: '', + configPath: '', + pidPath: '', + ), +); + +Widget _host(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + home: Scaffold(body: child), +); + +void main() { + testWidgets('a linked stat tile is a labelled button that fires its target', ( + tester, + ) async { + var opened = false; + await tester.pumpWidget( + _host( + Row( + children: [ + DoctorStatTile( + label: 'Freigaben', + value: '1', + subtitle: 'warten auf Prüfung', + icon: Icons.inbox_outlined, + onTap: () => opened = true, + linkLabel: 'Freigaben öffnen', + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + // Affordance: chevron + semantics button with the link label. + expect(find.byIcon(Icons.chevron_right), findsOneWidget); + expect( + find.bySemanticsLabel(RegExp('Freigaben öffnen')), + findsOneWidget, + ); + await tester.tap(find.text('1')); + expect(opened, isTrue); + }); + + testWidgets('an unlinked stat tile stays a plain card', (tester) async { + await tester.pumpWidget( + _host( + const Row( + children: [ + DoctorStatTile( + label: 'Dienste', + value: '0', + subtitle: 'deklariert', + icon: Icons.dns_outlined, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.chevron_right), findsNothing); + expect(find.byType(InkWell), findsNothing); + }); + + testWidgets( + 'the approvals row links to approvals, the modules row to the store', + (tester) async { + var storeOpened = false; + var approvalsOpened = false; + await tester.pumpWidget( + _host( + DoctorModulesPanel( + snapshot: _snapshot, + onOpenStore: () => storeOpened = true, + onOpenApprovals: () => approvalsOpened = true, + ), + ), + ); + await tester.pumpAndSettle(); + // The pending-approvals finding must be tappable ("1 Freigabe + // wartet auf Prüfung" -> approvals inbox). + await tester.tap(find.textContaining('wartet auf Prüfung')); + expect(approvalsOpened, isTrue); + expect(storeOpened, isFalse); + await tester.tap(find.textContaining('2 Module')); + expect(storeOpened, isTrue); + }, + ); +}