Ocideck/test/chart_preview_test.dart
Brenno de Winter 67408c213c Improve chart rendering and resolve theme logo paths
Charts:
- Shrink axis label fonts and thin/space x-axis labels by actual pixel
  spacing so dense or long labels no longer overlap.
- Line tooltip shows only the point nearest the cursor instead of every
  series stacked vertically.
- Hovering a legend entry highlights its element: bar/line series fade the
  others (pie expands the matching slice), in app and presentation mode.
- Add optional min/max threshold lines per bar/line chart (ignored for pie),
  editable in the chart editor and drawn in both the live preview and the
  exported SVG.

Theme:
- Resolve relative logo paths in a ThemeProfile against the project path and
  home directory so deck logos load regardless of working directory.

Tests cover bound round-trip, editor fields, SVG bounds, legend-hover fading,
and bound-line rendering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:18:35 +02:00

340 lines
11 KiB
Dart

import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ocideck/models/chart.dart';
import 'package:ocideck/models/slide.dart';
import 'package:ocideck/widgets/slides/slide_preview.dart';
Widget _host(ChartSpec spec, {bool presentationMode = false}) {
return MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 800,
height: 450,
child: SlidePreviewWidget(
slide: Slide.create(
SlideType.chart,
).copyWith(customMarkdown: spec.toBlock()),
presentationMode: presentationMode,
),
),
),
),
);
}
void main() {
testWidgets('chart title stays above the plot area', (tester) async {
const spec = ChartSpec(
type: ChartType.bar,
title: 'Omzet per kwartaal',
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: '2026', data: [10, 14]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final titleBottom = tester.getBottomLeft(find.text(spec.title)).dy;
final plotTop = tester.getTopLeft(find.byType(BarChart)).dy;
expect(titleBottom, lessThan(plotTop));
expect(tester.takeException(), isNull);
});
testWidgets('pie renders one chart per series with labels as slices', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.pie,
title: 'Verdeling',
x: ['Team A', 'Team B'],
series: [
ChartSeries(name: 'Gereed', data: [70, 40], color: '#10B981'),
ChartSeries(name: 'Open', data: [30, 60], color: '#EF4444'),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
expect(find.byType(PieChart), findsNWidgets(2));
expect(find.text('Team A'), findsOneWidget);
expect(find.text('Team B'), findsOneWidget);
expect(find.text('Gereed'), findsOneWidget);
expect(find.text('Open'), findsOneWidget);
final pieRect = tester.getRect(find.byType(PieChart).first);
final titleRect = tester.getRect(find.text('Gereed'));
expect(titleRect.left, greaterThan(pieRect.center.dx));
expect(tester.takeException(), isNull);
});
testWidgets('bar chart uses most of the available vertical plot area', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.bar,
title: 'Compacte titel',
x: ['A', 'B', 'C'],
series: [
ChartSeries(name: 'Waarde', data: [10, 20, 15]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
expect(tester.getSize(find.byType(BarChart)).height, greaterThan(260));
expect(tester.takeException(), isNull);
});
testWidgets('chart surface fills the remaining slide height', (tester) async {
const spec = ChartSpec(
type: ChartType.bar,
title: 'Titel',
x: ['A', 'B'],
series: [
ChartSeries(name: 'Waarde', data: [10, 20]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final slide = tester.getRect(find.byType(SlidePreviewWidget));
final surface = tester.getRect(find.byKey(const ValueKey('chart-surface')));
expect(surface.height, greaterThan(slide.height * 0.72));
expect(slide.bottom - surface.bottom, lessThan(slide.height * 0.04));
expect(tester.takeException(), isNull);
});
testWidgets('bar and line hover tooltips show labels and values', (
tester,
) async {
const barSpec = ChartSpec(
type: ChartType.bar,
x: ['Januari'],
series: [
ChartSeries(name: 'Omzet', data: [42]),
],
);
await tester.pumpWidget(_host(barSpec));
final bar = tester.widget<BarChart>(find.byType(BarChart));
final barItem = bar.data.barTouchData.touchTooltipData.getTooltipItem(
bar.data.barGroups.single,
0,
bar.data.barGroups.single.barRods.single,
0,
);
expect(barItem?.text, 'Januari\nOmzet: 42');
const lineSpec = ChartSpec(
type: ChartType.line,
x: ['Februari'],
series: [
ChartSeries(name: 'Bezoekers', data: [17.5]),
],
);
await tester.pumpWidget(_host(lineSpec));
final line = tester.widget<LineChart>(find.byType(LineChart));
final spot = LineBarSpot(
line.data.lineBarsData.single,
0,
line.data.lineBarsData.single.spots.single,
);
final lineItems = line.data.lineTouchData.touchTooltipData.getTooltipItems([
spot,
]);
expect(lineItems.single?.text, 'Februari\nBezoekers: 17.5');
});
testWidgets('pie hover shows the underlying category value', (tester) async {
const spec = ChartSpec(
type: ChartType.pie,
x: ['Gereed', 'Open'],
series: [
ChartSeries(name: 'Status', data: [70, 30]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final pie = tester.widget<PieChart>(find.byType(PieChart));
final section = pie.data.sections.first;
pie.data.pieTouchData.touchCallback!(
const FlPointerHoverEvent(PointerHoverEvent()),
PieTouchResponse(
touchLocation: Offset.zero,
touchedSection: PieTouchedSection(section, 0, 0, section.radius),
),
);
await tester.pump();
expect(find.byKey(const ValueKey('pie-hover-tooltip')), findsOneWidget);
expect(find.text('Gereed: 70'), findsOneWidget);
});
testWidgets('bar chart draws the configured min/max bound lines', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.bar,
x: ['Q1'],
series: [
ChartSeries(name: 'Omzet', data: [10]),
],
minBound: 5,
maxBound: 20,
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final bar = tester.widget<BarChart>(find.byType(BarChart));
final ys = bar.data.extraLinesData.horizontalLines.map((l) => l.y).toList();
expect(ys, containsAll(<double>[5, 20]));
// The max bound widens the axis so the line stays inside the plot.
expect(bar.data.maxY, greaterThanOrEqualTo(20));
expect(tester.takeException(), isNull);
});
testWidgets('hovering a legend entry fades the other series', (tester) async {
const spec = ChartSpec(
type: ChartType.line,
x: ['Q1', 'Q2'],
series: [
ChartSeries(name: 'Alpha', data: [10, 12], color: '#2563EB'),
ChartSeries(name: 'Beta', data: [8, 9], color: '#EF4444'),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
var line = tester.widget<LineChart>(find.byType(LineChart));
expect(line.data.lineBarsData[0].color!.a, 1.0);
expect(line.data.lineBarsData[1].color!.a, 1.0);
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.text('Alpha')));
await tester.pumpAndSettle();
line = tester.widget<LineChart>(find.byType(LineChart));
expect(line.data.lineBarsData[0].color!.a, 1.0); // hovered stays solid
expect(line.data.lineBarsData[1].color!.a, lessThan(1.0)); // other fades
expect(tester.takeException(), isNull);
});
testWidgets('presentation mode enlarges chart labels', (tester) async {
const spec = ChartSpec(
type: ChartType.bar,
x: ['Categorie'],
series: [
ChartSeries(name: 'Waarde', data: [10]),
],
);
await tester.pumpWidget(_host(spec));
final normal = tester.widget<Text>(find.text('Categorie').first);
final normalSize = normal.style!.fontSize!;
expect(normalSize, lessThanOrEqualTo(12));
await tester.pumpWidget(_host(spec, presentationMode: true));
final presented = tester.widget<Text>(find.text('Categorie').first);
expect(presented.style!.fontSize!, greaterThan(normalSize));
});
testWidgets('dense axis labels are thinned and stay inside the slide', (
tester,
) async {
const labels = [
'Januari bijzonder lang',
'Februari bijzonder lang',
'Maart bijzonder lang',
'April bijzonder lang',
'Mei bijzonder lang',
'Juni bijzonder lang',
'Juli bijzonder lang',
'Augustus bijzonder lang',
'September bijzonder lang',
'Oktober bijzonder lang',
'November bijzonder lang',
'December bijzonder lang',
];
const spec = ChartSpec(
type: ChartType.line,
x: labels,
series: [
ChartSeries(
name: 'Waarde',
data: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final visibleLabels = [
for (final label in labels)
if (find.text(label).evaluate().isNotEmpty) label,
];
expect(visibleLabels.length, lessThanOrEqualTo(8));
final slideRect = tester.getRect(find.byType(SlidePreviewWidget));
for (final label in visibleLabels) {
final rect = tester.getRect(find.text(label).first);
expect(slideRect.contains(rect.topLeft), isTrue);
expect(slideRect.contains(rect.bottomRight), isTrue);
}
expect(tester.takeException(), isNull);
});
testWidgets(
'pie shows at most two series and keeps labels inside the slide',
(tester) async {
const spec = ChartSpec(
type: ChartType.pie,
title: 'Veel gegevens',
x: [
'Een uitzonderlijk lang eerste label',
'Een uitzonderlijk lang tweede label',
'Een uitzonderlijk lang derde label',
'Een uitzonderlijk lang vierde label',
'Een uitzonderlijk lang vijfde label',
'Een uitzonderlijk lang zesde label',
],
series: [
ChartSeries(name: 'Een', data: [1, 2, 3, 4, 5, 6]),
ChartSeries(name: 'Twee', data: [2, 3, 4, 5, 6, 7]),
ChartSeries(name: 'Drie', data: [3, 4, 5, 6, 7, 8]),
ChartSeries(name: 'Vier', data: [4, 5, 6, 7, 8, 9]),
ChartSeries(name: 'Vijf', data: [5, 6, 7, 8, 9, 10]),
ChartSeries(name: 'Zes', data: [6, 7, 8, 9, 10, 11]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
expect(find.byType(PieChart), findsNWidgets(2));
expect(find.text('Drie'), findsNothing);
final legendTop = tester.getTopLeft(find.text(spec.x.first)).dy;
for (final chart in tester.widgetList<PieChart>(find.byType(PieChart))) {
final box = tester.renderObject<RenderBox>(find.byWidget(chart));
final bottom = box.localToGlobal(Offset(0, box.size.height)).dy;
expect(bottom, lessThanOrEqualTo(legendTop));
}
final slideRect = tester.getRect(find.byType(SlidePreviewWidget));
for (final label in spec.x) {
final rect = tester.getRect(find.text(label));
expect(slideRect.contains(rect.topLeft), isTrue);
expect(slideRect.contains(rect.bottomRight), isTrue);
}
expect(tester.takeException(), isNull);
},
);
}