diff --git a/lib/screens/terminal/terminal_page.dart b/lib/screens/terminal/terminal_page.dart index e1595061..65488013 100644 --- a/lib/screens/terminal/terminal_page.dart +++ b/lib/screens/terminal/terminal_page.dart @@ -120,6 +120,7 @@ class _TerminalPageState extends ConsumerState { separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (ctx, i) { final e = filtered[filtered.length - 1 - i]; + final searchQuery = _searchCtrl.text.trim(); String requestName = ''; if (e.source == TerminalSource.js && e.requestId != null) { @@ -140,6 +141,7 @@ class _TerminalPageState extends ConsumerState { return JsLogTile( entry: e, showTimestamp: _showTimestamps, + searchQuery: searchQuery, requestName: requestName.isNotEmpty ? requestName : null, ); @@ -147,6 +149,7 @@ class _TerminalPageState extends ConsumerState { return NetworkLogTile( entry: e, showTimestamp: _showTimestamps, + searchQuery: searchQuery, requestName: requestName.isNotEmpty ? requestName : null, ); @@ -154,6 +157,7 @@ class _TerminalPageState extends ConsumerState { return SystemLogTile( entry: e, showTimestamp: _showTimestamps, + searchQuery: searchQuery, ); } }, diff --git a/lib/widgets/expandable_section.dart b/lib/widgets/expandable_section.dart index c8022e94..08517c9f 100644 --- a/lib/widgets/expandable_section.dart +++ b/lib/widgets/expandable_section.dart @@ -1,18 +1,36 @@ import 'package:flutter/material.dart'; class ExpandableSection extends StatefulWidget { - const ExpandableSection( - {super.key, required this.title, required this.child}); + const ExpandableSection({ + super.key, + required this.title, + required this.child, + this.initiallyOpen = false, + this.forceOpen, + this.highlightQuery, + }); final String title; final Widget child; + final bool initiallyOpen; + final bool? forceOpen; + final String? highlightQuery; @override State createState() => _ExpandableSectionState(); } class _ExpandableSectionState extends State { - bool _open = false; + late bool _open = widget.initiallyOpen; + + @override + void didUpdateWidget(covariant ExpandableSection oldWidget) { + super.didUpdateWidget(oldWidget); + // If forceOpen toggles from null to a value or changes, reflect it + if (widget.forceOpen != null && widget.forceOpen != oldWidget.forceOpen) { + _open = widget.forceOpen!; + } + } @override Widget build(BuildContext context) { @@ -22,25 +40,26 @@ class _ExpandableSectionState extends State { InkWell( enableFeedback: false, borderRadius: BorderRadius.circular(3), - onTap: () => setState(() => _open = !_open), + onTap: () { + if (widget.forceOpen == null) { + setState(() => _open = !_open); + } + }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: Row( children: [ - Expanded( - child: Text( - widget.title, - style: Theme.of(context).textTheme.titleSmall, - ), - ), + Expanded(child: _buildTitle(context)), Icon( - _open ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + (widget.forceOpen ?? _open) + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, ), ], ), ), ), - if (_open) + if (widget.forceOpen ?? _open) Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), child: widget.child, @@ -48,4 +67,34 @@ class _ExpandableSectionState extends State { ], ); } + + Widget _buildTitle(BuildContext context) { + final q = widget.highlightQuery?.trim(); + final style = Theme.of(context).textTheme.titleSmall; + if (q == null || q.isEmpty) { + return Text(widget.title, style: style); + } + final lower = widget.title.toLowerCase(); + final lowerQ = q.toLowerCase(); + final spans = []; + int start = 0; + int idx; + final hlStyle = style?.copyWith( + background: Paint() + ..color = Theme.of(context) + .colorScheme + .tertiaryContainer + .withValues(alpha: 0.8), + fontWeight: FontWeight.w600, + ); + while ((idx = lower.indexOf(lowerQ, start)) != -1) { + if (idx > start) spans.add(TextSpan(text: widget.title.substring(start, idx), style: style)); + spans.add(TextSpan(text: widget.title.substring(idx, idx + lowerQ.length), style: hlStyle)); + start = idx + lowerQ.length; + } + if (start < widget.title.length) { + spans.add(TextSpan(text: widget.title.substring(start), style: style)); + } + return RichText(text: TextSpan(children: spans, style: style)); + } } diff --git a/lib/widgets/highlight_text.dart b/lib/widgets/highlight_text.dart new file mode 100644 index 00000000..73b58b67 --- /dev/null +++ b/lib/widgets/highlight_text.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +/// Widget to highlight occurrences of [query] inside [text]. +/// Case-insensitive; all matches highlighted. +class HighlightedSelectableText extends StatelessWidget { + const HighlightedSelectableText({ + super.key, + required this.text, + this.query, + this.style, + }); + final String text; + final String? query; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final q = query?.trim(); + if (q == null || q.isEmpty) { + return SelectableText(text, style: style); + } + final lower = text.toLowerCase(); + final lowerQ = q.toLowerCase(); + final spans = []; + int start = 0; + int idx; + final base = style ?? DefaultTextStyle.of(context).style; + final bgColor = Theme.of(context).colorScheme.secondaryContainer; + final highlightStyle = base.copyWith( + fontWeight: FontWeight.w600, + background: Paint() + ..color = bgColor.withValues(alpha: 0.85) + ..style = PaintingStyle.fill, + color: base.color, + ); + while ((idx = lower.indexOf(lowerQ, start)) != -1) { + if (idx > start) { + spans.add(TextSpan(text: text.substring(start, idx), style: base)); + } + spans.add(TextSpan( + text: text.substring(idx, idx + lowerQ.length), + style: highlightStyle, + )); + start = idx + lowerQ.length; + } + if (start < text.length) { + spans.add(TextSpan(text: text.substring(start), style: base)); + } + return SelectableText.rich(TextSpan(children: spans)); + } +} + +/// Helper to produce highlighted spans for inline RichText content. +List buildHighlightedSpans( + String text, + BuildContext context, + String? query, { + TextStyle? baseStyle, +}) { + final q = query?.trim(); + if (q == null || q.isEmpty) { + return [TextSpan(text: text, style: baseStyle)]; + } + final lower = text.toLowerCase(); + final lowerQ = q.toLowerCase(); + final spans = []; + int start = 0; + int idx; + final base = baseStyle ?? DefaultTextStyle.of(context).style; + final highlightStyle = base.copyWith( + fontWeight: FontWeight.w600, + background: Paint() + ..color = Theme.of(context) + .colorScheme + .secondaryContainer + .withValues(alpha: 0.85) + ..style = PaintingStyle.fill, + ); + while ((idx = lower.indexOf(lowerQ, start)) != -1) { + if (idx > start) { + spans.add(TextSpan(text: text.substring(start, idx), style: base)); + } + spans.add(TextSpan( + text: text.substring(idx, idx + lowerQ.length), + style: highlightStyle, + )); + start = idx + lowerQ.length; + } + if (start < text.length) { + spans.add(TextSpan(text: text.substring(start), style: base)); + } + return spans; +} \ No newline at end of file diff --git a/lib/widgets/terminal_tiles.dart b/lib/widgets/terminal_tiles.dart index 2943e2b6..cc58616f 100644 --- a/lib/widgets/terminal_tiles.dart +++ b/lib/widgets/terminal_tiles.dart @@ -5,12 +5,18 @@ import '../consts.dart'; import '../models/terminal/models.dart'; import '../utils/ui_utils.dart'; import 'expandable_section.dart'; +import 'highlight_text.dart'; class SystemLogTile extends StatelessWidget { - const SystemLogTile( - {super.key, required this.entry, this.showTimestamp = false}); + const SystemLogTile({ + super.key, + required this.entry, + this.showTimestamp = false, + this.searchQuery, + }); final TerminalEntry entry; final bool showTimestamp; + final String? searchQuery; @override Widget build(BuildContext context) { assert(entry.system != null, 'System tile requires SystemLogData'); @@ -55,10 +61,18 @@ class SystemLogTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('[${s.category}] ${s.message}', style: titleStyle), + HighlightedSelectableText( + text: '[${s.category}] ${s.message}', + style: titleStyle, + query: searchQuery, + ), if (s.stack != null && s.stack!.isNotEmpty) ...[ const SizedBox(height: 4), - SelectableText(s.stack!, style: subStyle), + HighlightedSelectableText( + text: s.stack!, + style: subStyle, + query: searchQuery, + ), ], ], ), @@ -70,14 +84,17 @@ class SystemLogTile extends StatelessWidget { } class JsLogTile extends StatelessWidget { - const JsLogTile( - {super.key, - required this.entry, - this.showTimestamp = false, - this.requestName}); + const JsLogTile({ + super.key, + required this.entry, + this.showTimestamp = false, + this.requestName, + this.searchQuery, + }); final TerminalEntry entry; final bool showTimestamp; final String? requestName; + final String? searchQuery; @override Widget build(BuildContext context) { assert(entry.js != null, 'JS tile requires JsLogData'); @@ -130,9 +147,10 @@ class JsLogTile extends StatelessWidget { ), ], Expanded( - child: SelectableText( - bodyText, + child: HighlightedSelectableText( + text: bodyText, style: Theme.of(context).textTheme.bodyMedium, + query: searchQuery, ), ), ], @@ -142,20 +160,55 @@ class JsLogTile extends StatelessWidget { } class NetworkLogTile extends StatefulWidget { - const NetworkLogTile( - {super.key, - required this.entry, - this.showTimestamp = false, - this.requestName}); + const NetworkLogTile({ + super.key, + required this.entry, + this.showTimestamp = false, + this.requestName, + this.searchQuery, + }); final TerminalEntry entry; final bool showTimestamp; final String? requestName; + final String? searchQuery; @override State createState() => _NetworkLogTileState(); } class _NetworkLogTileState extends State { bool _expanded = false; + String? _lastQuery; + + @override + void didUpdateWidget(covariant NetworkLogTile oldWidget) { + super.didUpdateWidget(oldWidget); + // Auto-expand if a new non-empty search query matches hidden detail content + final q = widget.searchQuery?.trim(); + if (q != null && q.isNotEmpty && q != _lastQuery) { + if (!_expanded && _networkDetailsContainQuery(q)) { + setState(() => _expanded = true); + } + } + _lastQuery = q; + } + + bool _networkDetailsContainQuery(String q) { + final n = widget.entry.network!; + final lowerQ = q.toLowerCase(); + bool inMap(Map? m) => + m != null && + m.entries.any((e) => + e.key.toLowerCase().contains(lowerQ) || + e.value.toLowerCase().contains(lowerQ)); + bool inText(String? t) => + t != null && t.toLowerCase().contains(lowerQ) && t.isNotEmpty; + return inMap(n.requestHeaders) || + inMap(n.responseHeaders) || + inText(n.requestBodyPreview) || + inText(n.responseBodyPreview) || + inText(n.errorMessage); + } + @override Widget build(BuildContext context) { final n = widget.entry.network!; @@ -195,12 +248,27 @@ class _NetworkLogTileState extends State { children: [ if (widget.requestName != null && widget.requestName!.isNotEmpty) ...[ - TextSpan(text: '[${widget.requestName}] '), + ...buildHighlightedSpans( + '[${widget.requestName}]', + context, + widget.searchQuery, + baseStyle: bodyStyle, + ), + const TextSpan(text: ' '), ], - TextSpan( - text: n.method.name.toUpperCase(), - style: methodStyle), - TextSpan(text: ' ${n.url}'), + ...buildHighlightedSpans( + n.method.name.toUpperCase(), + context, + widget.searchQuery, + baseStyle: methodStyle, + ), + const TextSpan(text: ' '), + ...buildHighlightedSpans( + n.url, + context, + widget.searchQuery, + baseStyle: bodyStyle, + ), ], ), maxLines: 1, @@ -219,43 +287,81 @@ class _NetworkLogTileState extends State { ), ), ), - if (_expanded) NetworkDetails(n: n), + if (_expanded) NetworkDetails(n: n, searchQuery: widget.searchQuery), ], ); } } class NetworkDetails extends StatelessWidget { - const NetworkDetails({super.key, required this.n}); + const NetworkDetails({super.key, required this.n, this.searchQuery}); final NetworkLogData n; + final String? searchQuery; @override Widget build(BuildContext context) { + final q = searchQuery?.trim(); + bool contains(String? text) => + q != null && + q.isNotEmpty && + text != null && + text.toLowerCase().contains(q.toLowerCase()); + bool mapContains(Map? m) => + q != null && + q.isNotEmpty && + m != null && + m.entries.any((e) => + e.key.toLowerCase().contains(q.toLowerCase()) || + e.value.toLowerCase().contains(q.toLowerCase())); + + final networkBodyMap = { + 'API Type': n.apiType.name, + 'Phase': n.phase.name, + if (n.isStreaming) 'Streaming': 'true', + if (n.sentAt != null) 'Sent': n.sentAt!.toIso8601String(), + if (n.completedAt != null) 'Completed': n.completedAt!.toIso8601String(), + if (n.duration != null) 'Duration': '${n.duration!.inMilliseconds} ms', + 'URL': n.url, + 'Method': n.method.name.toUpperCase(), + if (n.responseStatus != null) 'Status': '${n.responseStatus}', + if (n.errorMessage != null) 'Error': n.errorMessage!, + }; + final networkSectionHasMatch = q != null && + q.isNotEmpty && + networkBodyMap.entries.any((e) => + e.key.toLowerCase().contains(q.toLowerCase()) || + e.value.toLowerCase().contains(q.toLowerCase())); + final tiles = [ ExpandableSection( title: 'Network', - child: _kvBody({ - 'API Type': n.apiType.name, - 'Phase': n.phase.name, - if (n.isStreaming) 'Streaming': 'true', - if (n.sentAt != null) 'Sent': n.sentAt!.toIso8601String(), - if (n.completedAt != null) - 'Completed': n.completedAt!.toIso8601String(), - if (n.duration != null) - 'Duration': '${n.duration!.inMilliseconds} ms', - 'URL': n.url, - 'Method': n.method.name.toUpperCase(), - if (n.responseStatus != null) 'Status': '${n.responseStatus}', - if (n.errorMessage != null) 'Error': n.errorMessage!, - }), + forceOpen: networkSectionHasMatch ? true : null, + highlightQuery: searchQuery, + child: _kvBody(networkBodyMap, query: searchQuery), ), ExpandableSection( - title: 'Request Headers', child: _mapBody(n.requestHeaders)), + title: 'Request Headers', + forceOpen: mapContains(n.requestHeaders) ? true : null, + highlightQuery: searchQuery, + child: _mapBody(n.requestHeaders, query: searchQuery), + ), ExpandableSection( - title: 'Request Body', child: _textBody(n.requestBodyPreview)), + title: 'Request Body', + forceOpen: contains(n.requestBodyPreview) ? true : null, + highlightQuery: searchQuery, + child: _textBody(n.requestBodyPreview, query: searchQuery), + ), ExpandableSection( - title: 'Response Headers', child: _mapBody(n.responseHeaders)), + title: 'Response Headers', + forceOpen: mapContains(n.responseHeaders) ? true : null, + highlightQuery: searchQuery, + child: _mapBody(n.responseHeaders, query: searchQuery), + ), ExpandableSection( - title: 'Response Body', child: _textBody(n.responseBodyPreview)), + title: 'Response Body', + forceOpen: contains(n.responseBodyPreview) ? true : null, + highlightQuery: searchQuery, + child: _textBody(n.responseBodyPreview, query: searchQuery), + ), ]; return Container( width: double.infinity, @@ -271,21 +377,22 @@ class NetworkDetails extends StatelessWidget { ); } - Widget _textBody(String? text) { - return SelectableText(text == null || text.isEmpty ? '(empty)' : text); + Widget _textBody(String? text, {String? query}) { + final value = text == null || text.isEmpty ? '(empty)' : text; + return HighlightedSelectableText(text: value, query: query); } - Widget _mapBody(Map? map) { + Widget _mapBody(Map? map, {String? query}) { if (map == null || map.isEmpty) { - return const SelectableText('(none)'); + return const HighlightedSelectableText(text: '(none)'); } final lines = map.entries.map((e) => '${e.key}: ${e.value}').join('\n'); - return SelectableText(lines); + return HighlightedSelectableText(text: lines, query: query); } - Widget _kvBody(Map map) { + Widget _kvBody(Map map, {String? query}) { final lines = map.entries.map((e) => '${e.key}: ${e.value}').join('\n'); - return SelectableText(lines); + return HighlightedSelectableText(text: lines, query: query); } } @@ -294,5 +401,6 @@ String _formatTs(DateTime ts) { final h = ts.hour.toString().padLeft(2, '0'); final m = ts.minute.toString().padLeft(2, '0'); final s = ts.second.toString().padLeft(2, '0'); - return '$h:$m:$s'; + final ms = ts.millisecond.toString().padLeft(2, '0'); + return '$h:$m:$s.$ms'; } diff --git a/test/screens/terminal/terminal_page_test.dart b/test/screens/terminal/terminal_page_test.dart index fba1d608..5a734d4d 100644 --- a/test/screens/terminal/terminal_page_test.dart +++ b/test/screens/terminal/terminal_page_test.dart @@ -57,8 +57,13 @@ void main() { expect(find.byType(ListView), findsOneWidget); // There are separators, but count entries by specific content // JS tile renders its body as SelectableText containing args/context - final alphaText = find.byWidgetPredicate( - (w) => w is SelectableText && (w.data?.contains('alpha') ?? false)); + final alphaText = find.byWidgetPredicate((w) { + if (w is SelectableText) { + final text = w.data ?? w.textSpan?.toPlainText() ?? ''; + return text.contains('alpha'); + } + return false; + }); expect(alphaText, findsOneWidget); expect(find.textContaining('[ui] opened'), findsOneWidget); final networkTitle = find.byWidgetPredicate((w) => @@ -75,6 +80,13 @@ void main() { expect(find.textContaining('[ui] opened'), findsNothing); expect(networkTitle, findsNothing); + // Search for something in network data to test auto-expansion + await tester.enterText(searchField, 'apidash.dev'); + await tester.pumpAndSettle(); + expect(alphaText, findsNothing); + expect(networkTitle, findsOneWidget); + // Network tile should auto-expand when search matches URL + // Clear search await tester.enterText(searchField, ''); await tester.pumpAndSettle(); @@ -125,7 +137,7 @@ void main() { final netId = term.startNetwork( apiType: APIType.rest, method: HTTPVerb.get, - url: 'https://example.com', + url: 'https://api.apidash.dev', requestId: reqId, ); term.completeNetwork(netId, statusCode: 200); @@ -142,4 +154,49 @@ void main() { (w) => w is RichText && w.text.toPlainText().startsWith('[Untitled] ')); expect(netWithUntitled, findsOneWidget); }); + + testWidgets('search highlighting works across different tile types', + (tester) async { + final container = ProviderContainer(); + final term = container.read(terminalStateProvider.notifier); + + // Add entries with common search term "test" + term.logJs( + level: 'info', + args: ['test completed'], + context: 'postRequest', + contextRequestId: 'r1'); + term.logSystem(category: 'testing', message: 'Unit test passed'); + + final netId = term.startNetwork( + apiType: APIType.rest, + method: HTTPVerb.get, + url: 'https://api.apidash.dev/api', + requestId: 'r2', + ); + term.completeNetwork(netId, + statusCode: 200, responseBodyPreview: '{"test": "success"}'); + + await tester.pumpWidget(build(container)); + + // Search for "test" + final searchField = find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Search logs'); + await tester.enterText(searchField, 'test'); + await tester.pumpAndSettle(); + + // All three entries should be visible since they all contain "test" + expect(find.byWidgetPredicate((w) { + if (w is SelectableText) { + final text = w.data ?? w.textSpan?.toPlainText() ?? ''; + return text.contains('test'); + } + return false; + }), findsAtLeastNWidgets(1)); + expect(find.textContaining('[testing]'), findsOneWidget); + expect( + find.byWidgetPredicate((w) => + w is RichText && w.text.toPlainText().contains('api.apidash.dev')), + findsOneWidget); + }); } diff --git a/test/widgets/expandable_section_test.dart b/test/widgets/expandable_section_test.dart new file mode 100644 index 00000000..e27ffa39 --- /dev/null +++ b/test/widgets/expandable_section_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:apidash/widgets/expandable_section.dart'; + +void main() { + testWidgets('ExpandableSection starts collapsed by default', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ExpandableSection( + title: 'Test Section', + child: Text('Content'), + ), + ), + ), + ); + + expect(find.text('Test Section'), findsOneWidget); + expect(find.text('Content'), findsNothing); + expect(find.byIcon(Icons.keyboard_arrow_down), findsOneWidget); + }); + + testWidgets('ExpandableSection can be expanded by tapping', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ExpandableSection( + title: 'Test Section', + child: Text('Content'), + ), + ), + ), + ); + + // Tap to expand + await tester.tap(find.byType(InkWell)); + await tester.pumpAndSettle(); + + expect(find.text('Test Section'), findsOneWidget); + expect(find.text('Content'), findsOneWidget); + expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget); + }); + + testWidgets('ExpandableSection respects initiallyOpen parameter', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ExpandableSection( + title: 'Test Section', + initiallyOpen: true, + child: Text('Content'), + ), + ), + ), + ); + + expect(find.text('Test Section'), findsOneWidget); + expect(find.text('Content'), findsOneWidget); + expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget); + }); + + testWidgets('ExpandableSection respects forceOpen parameter', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ExpandableSection( + title: 'Test Section', + forceOpen: true, + child: Text('Content'), + ), + ), + ), + ); + + expect(find.text('Test Section'), findsOneWidget); + expect(find.text('Content'), findsOneWidget); + expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget); + + // Tapping should not close it when forceOpen is true + await tester.tap(find.byType(InkWell)); + await tester.pumpAndSettle(); + + expect(find.text('Content'), findsOneWidget); + expect(find.byIcon(Icons.keyboard_arrow_up), findsOneWidget); + }); + + testWidgets('ExpandableSection highlights title when highlightQuery matches', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: ExpandableSection( + title: 'Network Settings', + highlightQuery: 'network', + child: Text('Content'), + ), + ), + ), + ); + + // When highlighting is applied, the title is rendered as RichText instead of Text + final richTexts = tester.widgetList(find.byType(RichText)); + final titleRichText = richTexts.firstWhere( + (rt) => rt.text.toPlainText() == 'Network Settings', + orElse: () => throw Exception('RichText with title not found'), + ); + expect(titleRichText.text.toPlainText(), 'Network Settings'); + }); + + testWidgets('ExpandableSection updates when forceOpen changes', + (tester) async { + bool forceOpen = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => Column( + children: [ + ExpandableSection( + title: 'Test Section', + forceOpen: forceOpen ? true : null, + child: const Text('Content'), + ), + ElevatedButton( + onPressed: () => setState(() => forceOpen = !forceOpen), + child: const Text('Toggle Force Open'), + ), + ], + ), + ), + ), + ), + ); + + // Initially collapsed + expect(find.text('Content'), findsNothing); + + // Tap button to set forceOpen to true + await tester.tap(find.text('Toggle Force Open')); + await tester.pumpAndSettle(); + + // Should now be open + expect(find.text('Content'), findsOneWidget); + }); +} diff --git a/test/widgets/highlight_text_test.dart b/test/widgets/highlight_text_test.dart new file mode 100644 index 00000000..1a4306fe --- /dev/null +++ b/test/widgets/highlight_text_test.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:apidash/widgets/highlight_text.dart'; + +void main() { + testWidgets('HighlightedSelectableText renders plain text when no query', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: HighlightedSelectableText( + text: 'Hello World', + ), + ), + ), + ); + + expect(find.text('Hello World'), findsOneWidget); + expect(find.byType(SelectableText), findsOneWidget); + + final selectableText = + tester.widget(find.byType(SelectableText)); + expect(selectableText.data, 'Hello World'); + }); + + testWidgets('HighlightedSelectableText renders plain text when empty query', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: HighlightedSelectableText( + text: 'Hello World', + query: '', + ), + ), + ), + ); + + expect(find.text('Hello World'), findsOneWidget); + expect(find.byType(SelectableText), findsOneWidget); + + final selectableText = + tester.widget(find.byType(SelectableText)); + expect(selectableText.data, 'Hello World'); + }); + + testWidgets( + 'HighlightedSelectableText renders with highlights when query matches', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: HighlightedSelectableText( + text: 'Hello World', + query: 'World', + ), + ), + ), + ); + + // When highlighting is applied, it uses SelectableText.rich + expect(find.byType(SelectableText), findsOneWidget); + + final selectableText = + tester.widget(find.byType(SelectableText)); + // The text should still contain the original content + expect(selectableText.textSpan?.toPlainText(), 'Hello World'); + }); + + testWidgets('HighlightedSelectableText is case insensitive', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: HighlightedSelectableText( + text: 'Hello World', + query: 'HELLO', + ), + ), + ), + ); + + expect(find.byType(SelectableText), findsOneWidget); + + final selectableText = + tester.widget(find.byType(SelectableText)); + expect(selectableText.textSpan?.toPlainText(), 'Hello World'); + }); + + testWidgets('HighlightedSelectableText handles multiple matches', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: HighlightedSelectableText( + text: 'Hello Hello World', + query: 'Hello', + ), + ), + ), + ); + + expect(find.byType(SelectableText), findsOneWidget); + + final selectableText = + tester.widget(find.byType(SelectableText)); + expect(selectableText.textSpan?.toPlainText(), 'Hello Hello World'); + }); + + testWidgets('HighlightedSelectableText applies custom style', (tester) async { + const customStyle = TextStyle(fontSize: 20, color: Colors.red); + + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: HighlightedSelectableText( + text: 'Hello World', + style: customStyle, + ), + ), + ), + ); + + expect(find.byType(SelectableText), findsOneWidget); + + final selectableText = + tester.widget(find.byType(SelectableText)); + expect(selectableText.style, customStyle); + }); + + testWidgets('buildHighlightedSpans returns plain TextSpan when no query', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + final spans = buildHighlightedSpans('Hello World', context, null); + return RichText( + text: TextSpan(children: spans), + ); + }, + ), + ), + ), + ); + + expect(find.byType(RichText), findsOneWidget); + + final richText = tester.widget(find.byType(RichText)); + expect(richText.text.toPlainText(), 'Hello World'); + }); + + testWidgets( + 'buildHighlightedSpans returns highlighted spans when query matches', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + final spans = + buildHighlightedSpans('Hello World', context, 'World'); + return RichText( + text: TextSpan(children: spans), + ); + }, + ), + ), + ), + ); + + expect(find.byType(RichText), findsOneWidget); + + final richText = tester.widget(find.byType(RichText)); + expect(richText.text.toPlainText(), 'Hello World'); + + // Should have multiple spans (before match, match, after match) + final textSpan = richText.text as TextSpan; + expect(textSpan.children, isNotNull); + expect(textSpan.children!.length, greaterThan(1)); + }); + + testWidgets('buildHighlightedSpans applies custom base style', + (tester) async { + const baseStyle = TextStyle(fontSize: 18, color: Colors.blue); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + final spans = buildHighlightedSpans( + 'Hello World', + context, + 'World', + baseStyle: baseStyle, + ); + return RichText( + text: TextSpan(children: spans, style: baseStyle), + ); + }, + ), + ), + ), + ); + + expect(find.byType(RichText), findsOneWidget); + + final richText = tester.widget(find.byType(RichText)); + expect(richText.text.style, baseStyle); + }); +} diff --git a/test/widgets/terminal_tiles_test.dart b/test/widgets/terminal_tiles_test.dart index 9dfcf1c5..0648c8d3 100644 --- a/test/widgets/terminal_tiles_test.dart +++ b/test/widgets/terminal_tiles_test.dart @@ -18,12 +18,30 @@ void main() { await tester.pumpWidget( MaterialApp(home: Scaffold(body: SystemLogTile(entry: entry)))); expect(find.textContaining('[ui] Updated'), findsOneWidget); - // Stack is rendered using SelectableText + // Stack is rendered using HighlightedSelectableText which internally uses SelectableText final stackSelectable = - tester.widget(find.byType(SelectableText).first); + tester.widget(find.byType(SelectableText).last); expect(stackSelectable.data, 'trace'); }); + testWidgets('SystemLogTile renders with search highlighting', (tester) async { + final entry = TerminalEntry( + id: 's1', + source: TerminalSource.system, + level: TerminalLevel.info, + system: + SystemLogData(category: 'networking', message: 'Request completed'), + ); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SystemLogTile(entry: entry, searchQuery: 'network')))); + + // Should find the highlighted text + expect( + find.textContaining('[networking] Request completed'), findsOneWidget); + }); + testWidgets('JsLogTile composes body text with context and requestName', (tester) async { final entry = TerminalEntry( @@ -41,6 +59,31 @@ void main() { // Background color for warn renders but we only assert presence of text }); + testWidgets('JsLogTile highlights search query in body text', (tester) async { + final entry = TerminalEntry( + id: 'j1', + source: TerminalSource.js, + level: TerminalLevel.info, + js: JsLogData( + level: 'info', + args: ['API', 'response', 'success'], + context: 'postRequest'), + ); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: JsLogTile( + entry: entry, requestName: 'TestAPI', searchQuery: 'API')))); + + // Should find the text containing API in SelectableText widgets + // When highlighting is applied, the content is in textSpan + final selectables = + tester.widgetList(find.byType(SelectableText)).toList(); + final hasAPIText = selectables.any((s) => + (s.data != null && s.data!.contains('API')) || + (s.textSpan != null && s.textSpan!.toPlainText().contains('API'))); + expect(hasAPIText, isTrue); + }); + testWidgets('NetworkLogTile expands to show details and status/duration', (tester) async { final n = NetworkLogData( @@ -90,4 +133,39 @@ void main() { expect( selectables.any((w) => (w.data ?? '').contains('Status: 200')), isTrue); }); + + testWidgets('NetworkLogTile shows search query in URL', (tester) async { + final n = NetworkLogData( + phase: NetworkPhase.completed, + apiType: APIType.rest, + method: HTTPVerb.post, + url: 'https://api.example.com/data', + responseStatus: 201, + duration: const Duration(milliseconds: 200), + requestHeaders: const {'Content-Type': 'application/json'}, + responseHeaders: const {'Server': 'nginx'}, + requestBodyPreview: '{"name": "test"}', + responseBodyPreview: '{"id": 123}', + ); + final entry = TerminalEntry( + id: 'n1', + source: TerminalSource.network, + level: TerminalLevel.info, + network: n); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: NetworkLogTile( + entry: entry, + requestName: 'TestAPI', + searchQuery: 'example')))); + + await tester.pumpAndSettle(); + + // The RichText should contain the search query "example" in the URL + final richTexts = tester.widgetList(find.byType(RichText)); + final hasExampleText = + richTexts.any((rt) => rt.text.toPlainText().contains('example')); + expect(hasExampleText, isTrue); + }); }