feat: implement search highlighting across terminal tiles and expandable sections

This commit is contained in:
Udhay-Adithya
2025-09-28 12:49:40 +05:30
parent b910854433
commit dc7aa246d7
8 changed files with 814 additions and 66 deletions

View File

@@ -120,6 +120,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
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<TerminalPage> {
return JsLogTile(
entry: e,
showTimestamp: _showTimestamps,
searchQuery: searchQuery,
requestName:
requestName.isNotEmpty ? requestName : null,
);
@@ -147,6 +149,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
return NetworkLogTile(
entry: e,
showTimestamp: _showTimestamps,
searchQuery: searchQuery,
requestName:
requestName.isNotEmpty ? requestName : null,
);
@@ -154,6 +157,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
return SystemLogTile(
entry: e,
showTimestamp: _showTimestamps,
searchQuery: searchQuery,
);
}
},

View File

@@ -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<ExpandableSection> createState() => _ExpandableSectionState();
}
class _ExpandableSectionState extends State<ExpandableSection> {
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<ExpandableSection> {
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<ExpandableSection> {
],
);
}
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 = <TextSpan>[];
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));
}
}

View File

@@ -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 = <TextSpan>[];
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<InlineSpan> 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 = <InlineSpan>[];
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;
}

View File

@@ -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,
const JsLogTile({
super.key,
required this.entry,
this.showTimestamp = false,
this.requestName});
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,
const NetworkLogTile({
super.key,
required this.entry,
this.showTimestamp = false,
this.requestName});
this.requestName,
this.searchQuery,
});
final TerminalEntry entry;
final bool showTimestamp;
final String? requestName;
final String? searchQuery;
@override
State<NetworkLogTile> createState() => _NetworkLogTileState();
}
class _NetworkLogTileState extends State<NetworkLogTile> {
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<String, String>? 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<NetworkLogTile> {
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<NetworkLogTile> {
),
),
),
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 tiles = <Widget>[
ExpandableSection(
title: 'Network',
child: _kvBody({
final q = searchQuery?.trim();
bool contains(String? text) =>
q != null &&
q.isNotEmpty &&
text != null &&
text.toLowerCase().contains(q.toLowerCase());
bool mapContains(Map<String, String>? 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',
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 = <Widget>[
ExpandableSection(
title: 'Network',
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<String, String>? map) {
Widget _mapBody(Map<String, String>? 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<String, String> map) {
Widget _kvBody(Map<String, String> 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';
}

View File

@@ -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);
});
}

View File

@@ -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<RichText>(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);
});
}

View File

@@ -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<SelectableText>(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<SelectableText>(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<SelectableText>(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<SelectableText>(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<SelectableText>(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<SelectableText>(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<RichText>(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<RichText>(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<RichText>(find.byType(RichText));
expect(richText.text.style, baseStyle);
});
}

View File

@@ -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<SelectableText>(find.byType(SelectableText).first);
tester.widget<SelectableText>(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<SelectableText>(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<RichText>(find.byType(RichText));
final hasExampleText =
richTexts.any((rt) => rt.text.toPlainText().contains('example'));
expect(hasExampleText, isTrue);
});
}