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

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