Files
noo/test/widgets/noo/lists_test.dart
T
ayushyaandClaude Sonnet 5 f9653bc0f2 Fix share copy-link, destination-picker filters, and type-filter UX
- SharesController's fetchShares() built NextcloudShare objects through
  its own thin inline parser instead of _shareFromJson, so url/token/
  permissions/expireDate were always null - a public link share could
  never show "Copy link" in the Shares tab. Reuses _shareFromJson.
- Upload/Move/Copy's destination picker hid External storage in its
  filter sheet (showStorageScope: false) unlike Files' own controls
  row, despite the design doc already calling for full parity.
- Files' and Photos' type filter (All/Files/Folders, All/Photos/Videos)
  now render as one consistent style in both filter sheets: every icon
  always shown, label only on the selected segment
  (NooSegmentedControl's new labelOnlySelected), replacing Files' old
  checkmark list and Photos' own always-labelled track.
- Fixes Photos' type filter only visually updating after closing and
  reopening the filter sheet - it lived on PhotosView's own State, so
  Listenable.merge([photos, files]) never rebuilt the sheet when it
  changed; a StatefulBuilder now gives it that trigger.
- Replaces Settings' trailing section jump rail with per-section
  collapsible cards (NooGroupedList's new collapsible param, expanded
  by default) - one less parallel way to navigate a long screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:49:24 -04:00

476 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:noo/models/selection_action.dart';
import 'package:noo/widgets/noo/core/noo_progress_bar.dart';
import 'package:noo/widgets/noo/core/noo_toggle.dart';
import 'package:noo/widgets/noo/lists/noo_banner.dart';
import 'package:noo/widgets/noo/lists/noo_grouped_list.dart';
import 'package:noo/widgets/noo/lists/noo_selection_bar.dart';
import 'package:noo/widgets/noo/lists/noo_settings_row.dart';
import 'package:noo/widgets/noo/lists/noo_summary_card.dart';
import 'package:noo/widgets/noo/lists/noo_tab_order_row.dart';
import 'noo_test_utils.dart';
/// [n] actions labelled A, B, C, ... each incrementing an entry in [taps]
/// keyed by its own label when tapped. [n] must be at most
/// `SelectionActionKind.values.length` - each generated action needs its
/// own distinct kind.
List<SelectionAction> _actions(int n, Map<String, int> taps) =>
List.generate(n, (i) {
final label = String.fromCharCode(65 + i);
taps[label] = 0;
return SelectionAction(
kind: SelectionActionKind.values[i],
icon: LucideIcons.star,
label: label,
onTap: () => taps[label] = taps[label]! + 1,
);
});
void main() {
setUpNooTests();
group('NooBanner', () {
testNooWidgets('renders text and fires action', (tester, theme, c) async {
var taps = 0;
await pumpNoo(
tester,
NooBanner(
actionLabel: 'Empty trash',
onAction: () => taps++,
child: const Text('Items are deleted after 30 days'),
),
theme: theme,
);
expect(find.byIcon(LucideIcons.info), findsOneWidget);
expect(decorationOf(tester, find.text('Empty trash')).color, c.surface);
expect(
tester.widget<Text>(find.text('Empty trash')).style!.color,
c.danger,
);
await tester.tap(find.text('Empty trash'));
expect(taps, 1);
});
testNooWidgets('non-danger action uses accent text', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
const NooBanner(
actionLabel: 'Undo',
actionIsDanger: false,
child: Text('x'),
),
theme: theme,
);
expect(tester.widget<Text>(find.text('Undo')).style!.color, c.accentText);
});
});
group('NooGroupedList', () {
testNooWidgets('label, aside, 1px line gaps and footer', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
child: NooGroupedList(
label: 'Accounts',
aside: '2',
footer: const Text('Footer note'),
children: [
Container(key: const Key('r1'), height: 52, color: c.surface),
Container(key: const Key('r2'), height: 52, color: c.surface),
],
),
),
theme: theme,
);
expect(find.text('Accounts'), findsOneWidget);
expect(find.text('2'), findsOneWidget);
expect(find.text('Footer note'), findsOneWidget);
expect(tester.widget<Text>(find.text('Accounts')).style!.color, c.fg2);
final gap =
tester.getTopLeft(find.byKey(const Key('r2'))).dy -
tester.getBottomLeft(find.byKey(const Key('r1'))).dy;
expect(gap, 1);
// Rows paint their own surface; the gaps show the list's line fill.
expect(decorationOf(tester, find.byKey(const Key('r1'))).color, c.line);
});
testNooWidgets('collapsible starts expanded, tapping the label hides '
'and re-shows the card', (tester, theme, c) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
child: NooGroupedList(
label: 'Appearance',
collapsible: true,
children: [
Container(key: const Key('r1'), height: 52, color: c.surface),
],
),
),
theme: theme,
);
expect(find.byIcon(LucideIcons.chevronDown), findsOneWidget);
expect(tester.getSize(find.byKey(const Key('r1'))).height, 52);
AnimatedCrossFade crossFade() =>
tester.widget<AnimatedCrossFade>(find.byType(AnimatedCrossFade));
expect(crossFade().crossFadeState, CrossFadeState.showFirst);
await tester.tap(find.text('Appearance'));
await tester.pump();
expect(crossFade().crossFadeState, CrossFadeState.showSecond);
expect(
tester.widget<AnimatedRotation>(find.byType(AnimatedRotation)).turns,
0,
);
await tester.tap(find.text('Appearance'));
await tester.pump();
expect(crossFade().crossFadeState, CrossFadeState.showFirst);
expect(
tester.widget<AnimatedRotation>(find.byType(AnimatedRotation)).turns,
0.5,
);
});
testNooWidgets('non-collapsible has no chevron and ignores label taps', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
child: NooGroupedList(
label: 'Accounts',
children: [
Container(key: const Key('r1'), height: 52, color: c.surface),
],
),
),
theme: theme,
);
expect(find.byIcon(LucideIcons.chevronDown), findsNothing);
await tester.tap(find.text('Accounts'));
await tester.pumpAndSettle();
expect(tester.getSize(find.byKey(const Key('r1'))).height, 52);
});
});
group('NooSelectionBar', () {
testNooWidgets('mobile: all actions show inline when they fit', (
tester,
theme,
c,
) async {
final taps = <String, int>{};
await pumpNoo(
tester,
SizedBox(
width: 360,
child: NooSelectionBar(
count: 2,
actions: _actions(2, taps),
onClose: () {},
isDesktop: false,
),
),
theme: theme,
);
expect(find.text('2 selected'), findsOneWidget);
expect(find.byIcon(LucideIcons.star), findsNWidgets(2));
expect(find.byIcon(LucideIcons.ellipsisVertical), findsNothing);
await tester.tap(find.byIcon(LucideIcons.star).first);
expect(taps['A'], 1);
});
testNooWidgets(
'mobile: actions beyond the inline count collapse behind More',
(tester, theme, c) async {
final taps = <String, int>{};
await pumpNoo(
tester,
// Wide enough that the missing-webfont fallback used in tests
// (Google Fonts network fetch is off, see noo_test_utils.dart)
// doesn't itself force an overflow unrelated to what's tested here.
SizedBox(
width: 500,
child: NooSelectionBar(
count: 5,
actions: _actions(5, taps),
onClose: () {},
isDesktop: false,
),
),
theme: theme,
);
// Only the first 3 actions render inline - no horizontally-
// scrolling row that could hide the rest with no visible cue.
expect(find.byIcon(LucideIcons.star), findsNWidgets(3));
expect(find.byIcon(LucideIcons.ellipsisVertical), findsOneWidget);
await tester.tap(find.byIcon(LucideIcons.ellipsisVertical));
await tester.pumpAndSettle();
// The sheet lists exactly the overflowed actions (D, E) - the
// already-inline ones (A, B, C) aren't duplicated in it.
expect(find.text('D'), findsOneWidget);
expect(find.text('E'), findsOneWidget);
expect(find.text('A'), findsNothing);
await tester.tap(find.text('D'));
await tester.pumpAndSettle();
expect(taps['D'], 1);
},
);
testNooWidgets(
'desktop: actions beyond the inline count collapse behind More',
(tester, theme, c) async {
final taps = <String, int>{};
await pumpNoo(
tester,
SizedBox(
width: 900,
child: NooSelectionBar(
count: 6,
actions: _actions(6, taps),
onClose: () {},
isDesktop: true,
),
),
theme: theme,
);
// The first 4 actions render as labelled pills; the rest are behind
// "More".
for (final label in ['A', 'B', 'C', 'D']) {
expect(find.text(label), findsOneWidget);
}
expect(find.text('E'), findsNothing);
expect(find.text('More'), findsOneWidget);
await tester.tap(find.text('More'));
await tester.pumpAndSettle();
expect(find.text('E'), findsOneWidget);
expect(find.text('F'), findsOneWidget);
},
);
testNooWidgets('close button fires onClose', (tester, theme, c) async {
var closed = false;
final taps = <String, int>{};
await pumpNoo(
tester,
SizedBox(
width: 360,
child: NooSelectionBar(
count: 1,
actions: _actions(1, taps),
onClose: () => closed = true,
isDesktop: false,
),
),
theme: theme,
);
await tester.tap(find.byIcon(LucideIcons.x));
expect(closed, isTrue);
});
});
group('NooSettingsRow', () {
testNooWidgets('value row shows chevron and fires onTap', (
tester,
theme,
c,
) async {
var taps = 0;
await pumpNoo(
tester,
SizedBox(
width: 360,
child: NooGroupedList(
children: [
NooSettingsRow(
icon: LucideIcons.palette,
label: const Text('Theme'),
value: 'System',
onTap: () => taps++,
),
],
),
),
theme: theme,
);
expect(find.text('System'), findsOneWidget);
expect(find.byIcon(LucideIcons.chevronRight), findsOneWidget);
expect(tester.getSize(find.byType(NooSettingsRow)).height, 52);
await tester.tap(find.text('Theme'));
await tester.pumpAndSettle();
expect(taps, 1);
});
testWidgets('subtitle row is 60px, trailing wins over value', (
tester,
) async {
await pumpNoo(
tester,
const SizedBox(
width: 600,
child: NooGroupedList(
children: [
NooSettingsRow(
label: Text('Sync on Wi-Fi only'),
subtitle: Text('Saves mobile data'),
value: 'ignored',
trailing: NooToggle(checked: true),
),
],
),
),
);
expect(find.text('ignored'), findsNothing);
expect(find.byType(NooToggle), findsOneWidget);
expect(tester.getSize(find.byType(NooSettingsRow)).height, 60);
});
testWidgets('keeps its 52px height inside a tall bounded parent', (
tester,
) async {
await pumpNoo(
tester,
const SizedBox(
width: 360,
height: 400,
child: Align(
alignment: Alignment.topCenter,
child: NooSettingsRow(label: Text('Theme'), value: 'System'),
),
),
);
expect(tester.getSize(find.byType(NooSettingsRow)).height, 52);
});
testNooWidgets('danger row uses danger colour', (tester, theme, c) async {
await pumpNoo(
tester,
const SizedBox(
width: 360,
child: NooSettingsRow(
icon: LucideIcons.logOut,
label: Text('Log out'),
danger: true,
),
),
theme: theme,
);
final style = DefaultTextStyle.of(
tester.element(find.text('Log out')),
).style;
expect(style.color, c.danger);
expect(
tester.widget<Icon>(find.byIcon(LucideIcons.logOut)).color,
c.danger,
);
});
});
group('NooSummaryCard', () {
testNooWidgets('stat, caption, progress, meta, action', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
const SizedBox(
width: 360,
child: NooSummaryCard(
stat: '2.4 GB',
caption: 'Offline',
progress: 0.4,
meta: 'Synced 2 min ago',
action: Text('Sync now'),
),
),
theme: theme,
);
for (final t in ['2.4 GB', 'Offline', 'Synced 2 min ago', 'Sync now']) {
expect(find.text(t), findsOneWidget);
}
expect(find.byType(NooProgressBar), findsOneWidget);
expect(decorationOf(tester, find.text('2.4 GB')).color, c.surface);
expect(tester.widget<Text>(find.text('2.4 GB')).style!.color, c.fg1);
});
testNooWidgets('danger tone', (tester, theme, c) async {
await pumpNoo(
tester,
const SizedBox(
width: 360,
child: NooSummaryCard(
stat: '3',
caption: 'Errors',
tone: NooSummaryCardTone.danger,
),
),
theme: theme,
);
expect(find.byType(NooProgressBar), findsNothing);
expect(decorationOf(tester, find.text('3')).color, c.dangerSoft);
expect(tester.widget<Text>(find.text('3')).style!.color, c.danger);
expect(tester.widget<Text>(find.text('Errors')).style!.color, c.danger);
});
});
group('NooTabOrderRow', () {
testNooWidgets('pin state drives icon/fill, toggle fires', (
tester,
theme,
c,
) async {
var pinned = true;
await pumpNoo(
tester,
SizedBox(
width: 360,
child: StatefulBuilder(
builder: (context, setState) => NooTabOrderRow(
icon: LucideIcons.folder,
label: 'Files',
pinned: pinned,
onTogglePin: () => setState(() => pinned = !pinned),
),
),
),
theme: theme,
);
expect(find.text('Files'), findsOneWidget);
expect(
decorationOf(tester, find.byIcon(LucideIcons.pin)).color,
c.accentSoft,
);
await tester.tap(find.byIcon(LucideIcons.pin));
await tester.pump();
expect(pinned, isFalse);
expect(find.byIcon(LucideIcons.pin), findsNothing);
expect(
decorationOf(tester, find.byIcon(LucideIcons.pinOff)).color,
c.surface2,
);
});
});
}