- 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>
539 lines
17 KiB
Dart
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);
|
|
},
|
|
);
|
|
}
|