Ocideck/test/chart_preview_test.dart
Brenno de Winter dd54d36a60 Move radar scale to a side legend and add point tooltips
- The radar/spider scale no longer clutters the figure: the evenly spaced
  tick values now sit in a slim legend beside the chart, both in the live
  preview and in the SVG/HTML export.
- Hovering a radar point shows a tooltip (axis, series, value) like the other
  charts; the invisible scale-anchor dataset is ignored.
- Refresh the documentation (README, user guide, file format, changelog) for
  all recent work: code-slide theming with custom hex colours, the spider/radar
  chart type, chart min/max, legend hover, and the chart tooltip behaviour.
- Drop two redundant non-null assertions in the chart preview tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 14:04:47 +02:00

539 lines
17 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('line tooltip uses true distance and shows every nearby dot', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.line,
x: ['Q1'],
series: [
ChartSeries(name: 'Alpha', data: [10], color: '#2563EB'),
ChartSeries(name: 'Beta', data: [10], color: '#EF4444'),
ChartSeries(name: 'Gamma', data: [10], color: '#10B981'),
],
);
await tester.pumpWidget(_host(spec));
final line = tester.widget<LineChart>(find.byType(LineChart));
final touch = line.data.lineTouchData;
// Proximity is Euclidean (x AND y), not the x-only default.
expect(touch.distanceCalculator(Offset.zero, const Offset(3, 4)), 5);
expect(touch.touchSpotThreshold, greaterThan(0));
final spots = [
for (var i = 0; i < 3; i++)
LineBarSpot(
line.data.lineBarsData[i],
i,
line.data.lineBarsData[i].spots.single,
),
];
final items = touch.touchTooltipData.getTooltipItems(spots);
// All overlapping dots are shown (none filtered out).
expect(items.length, 3);
expect(items.whereType<LineTooltipItem>().length, 3);
expect(items[0]?.text, 'Q1\nAlpha: 10');
expect(items[2]?.text, 'Q1\nGamma: 10');
// A crowded stack uses a smaller font than a single tooltip.
final single = touch.touchTooltipData.getTooltipItems([spots.first]);
expect(
items[0]!.textStyle.fontSize!,
lessThan(single.single!.textStyle.fontSize!),
);
});
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('radar chart renders a polygon per series with axis labels', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['Snelheid', 'Kracht', 'Uithouding'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4, 5], color: '#2563EB'),
ChartSeries(name: 'Beta', data: [5, 2, 3], color: '#EF4444'),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
// Two visible series plus one invisible scale anchor.
expect(radar.data.dataSets.length, 3);
expect(radar.data.dataSets.first.dataEntries.map((e) => e.value), [3, 4, 5]);
expect(radar.data.dataSets.last.fillColor, Colors.transparent);
// The spoke labels are supplied through getTitle (canvas-painted).
expect(radar.data.getTitle!(0, 0).text, 'Snelheid');
expect(radar.data.getTitle!(2, 0).text, 'Uithouding');
// The series legend is shown as real text widgets.
expect(find.text('Alpha'), findsOneWidget);
expect(find.text('Beta'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('radar honours an explicit min/max scale with even ticks', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['A', 'B', 'C', 'D'],
series: [
ChartSeries(name: 'Score', data: [2, 4, 3, 5]),
],
minBound: 0,
maxBound: 10,
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
expect(radar.data.isMinValueAtCenter, isTrue);
// The hidden anchor pins the scale to [0, 10].
final anchor = radar.data.dataSets.last.dataEntries.map((e) => e.value);
expect(anchor.reduce((a, b) => a < b ? a : b), 0);
expect(anchor.reduce((a, b) => a > b ? a : b), 10);
// The scale is shown in the side legend (0..10), not painted in the chart.
expect(radar.data.ticksTextStyle?.color, Colors.transparent);
expect(find.text('0'), findsWidgets);
expect(find.text('10'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('radar shows a tooltip for the hovered point', (tester) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['Snelheid', 'Kracht', 'Uithouding'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4, 5]),
ChartSeries(name: 'Beta', data: [5, 2, 3]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
final touch = radar.data.radarTouchData;
expect(touch.enabled, isTrue);
// Simulate hovering the second axis of the Beta series.
final dataSet = radar.data.dataSets[1];
final entry = dataSet.dataEntries[1];
touch.touchCallback!(
const FlPointerHoverEvent(PointerHoverEvent()),
RadarTouchResponse(
touchLocation: const Offset(20, 20),
touchedSpot: RadarTouchedSpot(
dataSet,
1,
entry,
1,
const FlSpot(1, 2),
const Offset(20, 20),
),
),
);
await tester.pump();
expect(find.text('Kracht\nBeta: 2'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('radar ignores touches on the invisible scale anchor', (
tester,
) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['A', 'B', 'C'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4, 5]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
final radar = tester.widget<RadarChart>(find.byType(RadarChart));
final anchorIndex = radar.data.dataSets.length - 1; // the anchor dataset
final anchor = radar.data.dataSets[anchorIndex];
radar.data.radarTouchData.touchCallback!(
const FlPointerHoverEvent(PointerHoverEvent()),
RadarTouchResponse(
touchLocation: Offset.zero,
touchedSpot: RadarTouchedSpot(
anchor,
anchorIndex,
anchor.dataEntries.first,
0,
const FlSpot(0, 0),
Offset.zero,
),
),
);
await tester.pump();
// No tooltip for the anchor: only the legend value "5" exists.
expect(find.textContaining(': '), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('radar chart asks for at least three labels', (tester) async {
const spec = ChartSpec(
type: ChartType.radar,
x: ['Een', 'Twee'],
series: [
ChartSeries(name: 'Alpha', data: [3, 4]),
],
);
await tester.pumpWidget(_host(spec));
await tester.pump();
expect(find.byType(RadarChart), findsNothing);
expect(
find.text('Een spider-diagram heeft minstens drie labels nodig'),
findsOneWidget,
);
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);
},
);
}