Files
clide/test/widgets/more_widgets_test.dart
T
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00

300 lines
12 KiB
Dart

/// More widget tests covering the next batch of zero-coverage files
/// under `lib/widgets/src/`: ClideMarkdown, ClideCodeBlock,
/// ClideAccordion, ClideScrollbar, ClidePtyView.
library;
import 'package:clide/src/terminal/terminal.dart';
import 'package:clide/widgets/src/clide_accordion.dart';
import 'package:clide/widgets/src/clide_code_block.dart';
import 'package:clide/widgets/src/clide_markdown.dart';
import 'package:clide/widgets/src/clide_pty_view.dart';
import 'package:clide/widgets/src/clide_scrollbar.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/kernel_fixture.dart';
import '../helpers/widget_harness.dart';
void main() {
group('ClideMarkdown', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
testWidgets('renders headings, paragraphs, lists, code blocks, blockquotes', (tester) async {
const src = '''# Heading One
A paragraph with **bold** and *italic* and `code` and a [link](https://example.com).
## Heading Two
- bullet one
- bullet two with **bold**
- bullet three
1. ordered one
2. ordered two
> A blockquote line.
Final paragraph.
''';
await tester.pumpWidget(harness(f, const SingleChildScrollView(child: ClideMarkdown(src))));
await tester.pumpAndSettle();
expect(find.byType(ClideMarkdown), findsOneWidget);
// Some headings/text should be present somewhere in the tree.
expect(find.byType(RichText), findsWidgets);
});
testWidgets('renders a fenced code block via ClideCodeBlock', (tester) async {
const src = '''Here is some Dart:
```dart
void main() {
print('hi');
}
```
After.
''';
await tester.pumpWidget(harness(f, const SingleChildScrollView(child: ClideMarkdown(src))));
await tester.pumpAndSettle();
expect(find.byType(ClideCodeBlock), findsOneWidget);
});
testWidgets('renders horizontal rules', (tester) async {
const src = 'before\n\n---\n\nafter';
await tester.pumpWidget(harness(f, const ClideMarkdown(src)));
await tester.pumpAndSettle();
expect(find.byType(ClideMarkdown), findsOneWidget);
});
testWidgets('record-id link fires onRecordTap callback', (tester) async {
var tapped = '';
const src = 'See [D-1](#anchor) for details.';
await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id)));
await tester.pumpAndSettle();
// The record-link path matches D-1 / Q-N / R-N / T-N and produces
// a tappable span. We can't easily simulate a TextSpan tap from
// the high-level finders, so this just exercises the rendering
// path.
expect(find.byType(ClideMarkdown), findsOneWidget);
expect(tapped, isEmpty); // not tapped yet — no crash is the point
});
testWidgets('record-id link tap actually invokes onRecordTap', (tester) async {
var tapped = '';
const src = '[D-1](#anchor)';
await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id)));
await tester.pumpAndSettle();
// The link renders as a ClideTappable embedded in a WidgetSpan.
await tester.tap(find.text('D-1'));
await tester.pumpAndSettle();
expect(tapped, 'D-1');
});
testWidgets('bare T-NNN ref in prose is tappable and fires onRecordTap (T-279)', (tester) async {
var tapped = '';
const src = 'See T-281 for the follow-up.';
await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id)));
await tester.pumpAndSettle();
// The bare ref is pulled out into a standalone tappable Text span.
await tester.tap(find.text('T-281'));
await tester.pumpAndSettle();
expect(tapped, 'T-281');
});
testWidgets('bare D/Q/R refs all linkify (T-279)', (tester) async {
var tapped = '';
// One pump (re-pumping harness() reuses the first Overlay entry), three refs.
await tester.pumpWidget(harness(f, ClideMarkdown('refs D-77 and Q-5 and R-2 here', onRecordTap: (x) => tapped = x)));
await tester.pumpAndSettle();
for (final id in ['D-77', 'Q-5', 'R-2']) {
await tester.tap(find.text(id));
await tester.pumpAndSettle();
expect(tapped, id, reason: '$id should be a clickable ref');
}
});
testWidgets('a bare word like T-shirt is not linkified (T-279)', (tester) async {
var tapped = '';
const src = 'buy a T-shirt today';
await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id)));
await tester.pumpAndSettle();
// No digits after the dash → no match → no standalone link span; the
// phrase stays inside the paragraph RichText.
expect(find.text('T-shirt'), findsNothing);
expect(tapped, isEmpty);
});
testWidgets('a ref inside an inline code span stays plain (T-279)', (tester) async {
var tapped = '';
const src = 'run `pql ticket show T-281` to see it';
await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id)));
await tester.pumpAndSettle();
// The code span renders verbatim within the paragraph, not as a
// standalone tappable link.
expect(find.text('T-281'), findsNothing);
expect(tapped, isEmpty);
});
testWidgets('without onRecordTap a bare ref renders as plain text (T-279)', (tester) async {
const src = 'See T-281 here';
await tester.pumpWidget(harness(f, const ClideMarkdown(src)));
await tester.pumpAndSettle();
// No callback → no link span carved out.
expect(find.text('T-281'), findsNothing);
expect(find.byType(ClideMarkdown), findsOneWidget);
});
testWidgets('h3 / h4 / h5 / h6 headings render with the right padding tier', (tester) async {
const src = '### h3\n\n#### h4\n\n##### h5\n\n###### h6\n';
await tester.pumpWidget(harness(f, const ClideMarkdown(src)));
await tester.pumpAndSettle();
expect(find.byType(ClideMarkdown), findsOneWidget);
// Each heading contributes a Padding parent — at minimum the document
// must render without throwing and include RichText spans for each.
expect(find.byType(RichText), findsWidgets);
});
testWidgets('renders pipe-style tables (thead + tbody)', (tester) async {
const src = '| col a | col b |\n|-------|-------|\n| a1 | b1 |\n| a2 | b2 |\n';
await tester.pumpWidget(harness(f, const ClideMarkdown(src)));
await tester.pumpAndSettle();
// The Flutter `Table` widget appears for every rendered markdown table.
expect(find.byType(Table), findsOneWidget);
});
testWidgets('renders ~~strikethrough~~ as a del span', (tester) async {
const src = 'this is ~~gone~~ now';
await tester.pumpWidget(harness(f, const ClideMarkdown(src)));
await tester.pumpAndSettle();
expect(find.byType(ClideMarkdown), findsOneWidget);
});
testWidgets('unknown block tags fall through to the default branch without throwing', (tester) async {
// Raw HTML the markdown parser leaves as a passthrough element with an
// unrecognized tag — exercises the `default:` arm of `_buildBlock`.
const src = '<aside>side note</aside>';
await tester.pumpWidget(harness(f, const ClideMarkdown(src)));
await tester.pumpAndSettle();
expect(find.byType(ClideMarkdown), findsOneWidget);
});
});
group('ClideCodeBlock', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
testWidgets('renders plain source when no language is given', (tester) async {
await tester.pumpWidget(harness(f, const ClideCodeBlock(source: 'plain text\nmultiple lines')));
await tester.pumpAndSettle();
expect(find.byType(ClideCodeBlock), findsOneWidget);
expect(find.byType(RichText), findsOneWidget);
});
testWidgets('handles a language whose grammar is unavailable (falls back to plain text)', (tester) async {
await tester.pumpWidget(harness(f, const ClideCodeBlock(source: 'fn main() {}', language: 'rust')));
await tester.pumpAndSettle();
// TreeSitterService can't load the grammar in tests → falls back
// to plain rendering.
expect(find.byType(ClideCodeBlock), findsOneWidget);
});
testWidgets('didUpdateWidget triggers a re-highlight when source changes', (tester) async {
await tester.pumpWidget(harness(f, const ClideCodeBlock(source: 'a', language: 'dart')));
await tester.pumpAndSettle();
await tester.pumpWidget(harness(f, const ClideCodeBlock(source: 'b', language: 'dart')));
await tester.pumpAndSettle();
expect(find.byType(ClideCodeBlock), findsOneWidget);
});
});
group('ClideAccordion', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
testWidgets('collapsed accordion does not render children; tap toggles', (tester) async {
var toggled = 0;
await tester.pumpWidget(
harness(
f,
ClideAccordion(label: 'Group', count: 2, expanded: false, onToggle: () => toggled++, children: const [Text('child-one'), Text('child-two')]),
),
);
await tester.pumpAndSettle();
expect(find.text('Group · 2'), findsOneWidget);
expect(find.text('child-one'), findsNothing);
await tester.tap(find.text('Group · 2'));
expect(toggled, 1);
});
testWidgets('expanded accordion shows children; leading widget renders', (tester) async {
await tester.pumpWidget(
harness(f, ClideAccordion(label: 'Open', count: 1, expanded: true, onToggle: () {}, leading: const Text('LEAD'), children: const [Text('only-child')])),
);
await tester.pumpAndSettle();
expect(find.text('only-child'), findsOneWidget);
expect(find.text('LEAD'), findsOneWidget);
});
});
group('ClideScrollbar', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
testWidgets('wraps a Scrollable child and exposes ScrollbarTheme', (tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
harness(
f,
SizedBox(
height: 100,
child: ClideScrollbar(
controller: controller,
child: SingleChildScrollView(controller: controller, child: const SizedBox(height: 1000)),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(ClideScrollbar), findsOneWidget);
});
test('ScrollbarTheme.updateShouldNotify detects colour changes', () {
const a = ScrollbarTheme(slider: Color(0xFF000000), sliderHover: Color(0xFF111111), track: Color(0xFF222222), child: SizedBox.shrink());
const b = ScrollbarTheme(slider: Color(0xFFFFFFFF), sliderHover: Color(0xFF111111), track: Color(0xFF222222), child: SizedBox.shrink());
expect(a.updateShouldNotify(b), isTrue);
expect(a.updateShouldNotify(a), isFalse);
});
});
group('ClidePtyView', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
tearDown(() async => f.dispose());
testWidgets('renders Semantics live region wrapping a TerminalView', (tester) async {
final terminal = Terminal(maxLines: 100, onOutput: (_) {});
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 400,
height: 200,
child: ClidePtyView(terminal: terminal, label: 'test-pane'),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(ClidePtyView), findsOneWidget);
// The TerminalView inside renders the actual viewport.
expect(find.byType(TerminalView), findsOneWidget);
});
});
}