chain-studio/test/chain_segments_test.dart
flemming-it ed680c507a
Some checks failed
Security / Security check (push) Failing after 1s
refactor(ui): one canonical segment control (ChainSegments) everywhere
The same single-select choice pattern appeared as four widgets:
audit's hover pills, the store's SegmentedButton, the store filter
dialog's ChoiceChips, and the approvals TabBar (usertest finding
#14 / night-log decision 'pill segment as canon'). The audit
pattern is promoted to a shared ChainSegments widget (optional
icons, hover, selected border, button+selected semantics) and all
four sites use it; approvals switches lists via IndexedStack so
both stay alive and switching does not refetch.

Guard per the no-bugfix-without-a-guard rule: widget tests for
selection + semantics, plus a canon sweep that bans
TabBar/TabBarView/TabController/SegmentedButton/ChoiceChip from
lib/ (comments exempt). Deliberately out of scope: the flow
editor's Graph/Text/Run tabs live in the separate editor package.
Studio 0.75.0; guide images regenerated, dark + light verified.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-19 02:58:54 +02:00

101 lines
3.3 KiB
Dart

// ChainSegments — the canonical single-select segment control
// (night-log decision "pill segment as canon"; usertest finding
// #14: the same choice pattern appeared as four different
// widgets). Guards both the widget behaviour and the canon
// itself: no page may reintroduce TabBar / SegmentedButton /
// ChoiceChip for single-select segments.
import 'dart:io';
import 'package:flutter/material.dart';
import 'dart:ui' show Tristate;
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/widgets/chain_segments.dart';
Widget _host(Widget child) =>
MaterialApp(home: Scaffold(body: Center(child: child)));
void main() {
testWidgets('tapping a segment reports the new value once', (tester) async {
final changes = <String>[];
await tester.pumpWidget(
_host(
ChainSegments<String>(
items: const [
ChainSegmentItem('all', 'Alle'),
ChainSegmentItem('flow', 'Flow'),
ChainSegmentItem('step', 'Schritt'),
],
value: 'all',
onChanged: changes.add,
),
),
);
await tester.tap(find.text('Flow'));
await tester.pump();
expect(changes, ['flow']);
// Tapping the already-selected value must NOT re-fire — call
// sites use the callback to trigger refetches.
await tester.tap(find.text('Alle'));
await tester.pump();
expect(changes, ['flow']);
});
testWidgets('segments expose button + selected semantics', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(
_host(
ChainSegments<int>(
items: const [
ChainSegmentItem(0, 'Ausstehend'),
ChainSegmentItem(1, 'Verlauf'),
],
value: 0,
onChanged: (_) {},
),
),
);
final pending = tester.getSemantics(find.text('Ausstehend'));
expect(pending.flagsCollection.isSelected, Tristate.isTrue);
final history = tester.getSemantics(find.text('Verlauf'));
expect(history.flagsCollection.isSelected, Tristate.isFalse);
handle.dispose();
});
test('canon guard: no page reintroduces TabBar/SegmentedButton/ChoiceChip',
() {
// The night-log decision made the pill segment the ONE way to
// render a single-select segment. This sweep keeps the three
// retired Material patterns out of lib/ for good (comments
// don't count; the widget file itself is exempt).
final offenders = <String>[];
final banned = RegExp(
r'\b(TabBar|TabBarView|TabController|SegmentedButton|ChoiceChip)\b',
);
final files = Directory('lib')
.listSync(recursive: true)
.whereType<File>()
.where((f) => f.path.endsWith('.dart'))
.where((f) => !f.path.contains('chain_segments.dart'));
for (final f in files) {
final lines = f.readAsLinesSync();
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
final code = line.contains('//')
? line.substring(0, line.indexOf('//'))
: line;
if (banned.hasMatch(code)) {
offenders.add('${f.path}:${i + 1}: ${line.trim()}');
}
}
}
expect(
offenders,
isEmpty,
reason:
'Single-select segments use ChainSegments (canon). '
'Found retired patterns:\n${offenders.join('\n')}',
);
});
}