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>
222 lines
6.4 KiB
Dart
222 lines
6.4 KiB
Dart
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:ocideck/models/settings.dart';
|
|
import 'package:ocideck/models/slide.dart';
|
|
import 'package:ocideck/widgets/slides/slide_preview.dart';
|
|
|
|
Future<File> _writeSolidPng(String dir, String name, Color color) async {
|
|
final recorder = ui.PictureRecorder();
|
|
final canvas = Canvas(recorder);
|
|
canvas.drawRect(const Rect.fromLTWH(0, 0, 8, 8), Paint()..color = color);
|
|
final picture = recorder.endRecording();
|
|
final img = await picture.toImage(8, 8);
|
|
final data = await img.toByteData(format: ui.ImageByteFormat.png);
|
|
final file = File('$dir/$name');
|
|
file.writeAsBytesSync(data!.buffer.asUint8List());
|
|
return file;
|
|
}
|
|
|
|
Future<({int width, int height, Uint8List bytes})> _capture(
|
|
WidgetTester tester,
|
|
GlobalKey key,
|
|
Widget child,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: Center(
|
|
child: RepaintBoundary(
|
|
key: key,
|
|
child: SizedBox(width: 800, height: 450, child: child),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
// Let the file images decode and paint before capturing a single frame.
|
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
|
await tester.pump();
|
|
|
|
final boundary =
|
|
key.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
|
final image = await boundary.toImage(pixelRatio: 1.0);
|
|
final bytes = (await image.toByteData(
|
|
format: ui.ImageByteFormat.rawRgba,
|
|
))!.buffer.asUint8List();
|
|
return (width: image.width, height: image.height, bytes: bytes);
|
|
}
|
|
|
|
void main() {
|
|
testWidgets('a missing small logo does not overflow its bounds', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: Scaffold(
|
|
body: SizedBox(
|
|
width: 800,
|
|
child: SlidePreviewWidget(
|
|
slide: Slide.create(SlideType.bullets),
|
|
themeProfile: const ThemeProfile(
|
|
logoPath: '/path/that/does/not/exist.png',
|
|
logoSize: 36,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('twoImages paints both the left and right images', (
|
|
tester,
|
|
) async {
|
|
await tester.runAsync(() async {
|
|
final dir = Directory.systemTemp.createTempSync('ocideck_two');
|
|
final red = await _writeSolidPng(
|
|
dir.path,
|
|
'red.png',
|
|
const Color(0xFFFF0000),
|
|
);
|
|
final blue = await _writeSolidPng(
|
|
dir.path,
|
|
'blue.png',
|
|
const Color(0xFF0000FF),
|
|
);
|
|
|
|
final slide = Slide(
|
|
id: 'x',
|
|
type: SlideType.twoImages,
|
|
imagePath: red.path,
|
|
imagePath2: blue.path,
|
|
);
|
|
|
|
final key = GlobalKey();
|
|
final cap = await _capture(tester, key, SlidePreviewWidget(slide: slide));
|
|
|
|
({int r, int g, int b}) pixelAt(double fx, double fy) {
|
|
final x = (cap.width * fx).round().clamp(0, cap.width - 1);
|
|
final y = (cap.height * fy).round().clamp(0, cap.height - 1);
|
|
final i = (y * cap.width + x) * 4;
|
|
return (r: cap.bytes[i], g: cap.bytes[i + 1], b: cap.bytes[i + 2]);
|
|
}
|
|
|
|
final left = pixelAt(0.25, 0.5);
|
|
final right = pixelAt(0.75, 0.5);
|
|
|
|
expect(
|
|
left.r > 200 && left.b < 80,
|
|
isTrue,
|
|
reason: 'Left panel should be the red image but was $left',
|
|
);
|
|
expect(
|
|
right.b > 200 && right.r < 80,
|
|
isTrue,
|
|
reason: 'Right panel should be the blue image but was $right',
|
|
);
|
|
|
|
dir.deleteSync(recursive: true);
|
|
});
|
|
});
|
|
|
|
testWidgets('zoomed (scaled) image still paints in the frame', (
|
|
tester,
|
|
) async {
|
|
await tester.runAsync(() async {
|
|
final dir = Directory.systemTemp.createTempSync('ocideck_zoom');
|
|
final red = await _writeSolidPng(
|
|
dir.path,
|
|
'red.png',
|
|
const Color(0xFFFF0000),
|
|
);
|
|
|
|
// imageSize 150 = zoomed in past contain; the picture must still render
|
|
// (regression: the old Transform.scale rendered blank in toImage).
|
|
final slide = Slide(
|
|
id: 'z',
|
|
type: SlideType.image,
|
|
imagePath: red.path,
|
|
imageSize: 150,
|
|
);
|
|
|
|
final key = GlobalKey();
|
|
final cap = await _capture(tester, key, SlidePreviewWidget(slide: slide));
|
|
|
|
final cx = cap.width ~/ 2;
|
|
final cy = cap.height ~/ 2;
|
|
final i = (cy * cap.width + cx) * 4;
|
|
final r = cap.bytes[i];
|
|
final g = cap.bytes[i + 1];
|
|
final b = cap.bytes[i + 2];
|
|
|
|
expect(
|
|
r > 200 && g < 80 && b < 80,
|
|
isTrue,
|
|
reason: 'Center of a zoomed image should be red but was ($r,$g,$b)',
|
|
);
|
|
|
|
dir.deleteSync(recursive: true);
|
|
});
|
|
});
|
|
|
|
testWidgets('zoomed-out image with a title anchors to the top', (
|
|
tester,
|
|
) async {
|
|
await tester.runAsync(() async {
|
|
final dir = Directory.systemTemp.createTempSync('ocideck_top');
|
|
final red = await _writeSolidPng(
|
|
dir.path,
|
|
'red.png',
|
|
const Color(0xFFFF0000),
|
|
);
|
|
|
|
// Zoomed out (60%) with a title: the image should sit at the top so the
|
|
// bottom title banner does not overlap it.
|
|
final slide = Slide(
|
|
id: 't',
|
|
type: SlideType.image,
|
|
title: 'Onderschrift',
|
|
imagePath: red.path,
|
|
imageSize: 60,
|
|
);
|
|
|
|
final key = GlobalKey();
|
|
final cap = await _capture(tester, key, SlidePreviewWidget(slide: slide));
|
|
|
|
({int r, int g, int b}) pixelAt(double fx, double fy) {
|
|
final x = (cap.width * fx).round().clamp(0, cap.width - 1);
|
|
final y = (cap.height * fy).round().clamp(0, cap.height - 1);
|
|
final idx = (y * cap.width + x) * 4;
|
|
return (
|
|
r: cap.bytes[idx],
|
|
g: cap.bytes[idx + 1],
|
|
b: cap.bytes[idx + 2],
|
|
);
|
|
}
|
|
|
|
final top = pixelAt(0.5, 0.08);
|
|
final bottom = pixelAt(0.5, 0.92);
|
|
|
|
expect(
|
|
top.r > 200 && top.g < 80 && top.b < 80,
|
|
isTrue,
|
|
reason: 'Top of a top-anchored image should be red but was $top',
|
|
);
|
|
expect(
|
|
bottom.r > 200 && bottom.g < 80 && bottom.b < 80,
|
|
isFalse,
|
|
reason: 'Bottom should be free for the title, not the image ($bottom)',
|
|
);
|
|
|
|
dir.deleteSync(recursive: true);
|
|
});
|
|
});
|
|
}
|