todo-app/test/images/image_processing_test.dart
Krzysztof kuhy Rudnicki 9f2ffdb8a9
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Attach any file to a note, of any size, not only images
📎 now picks any file (Android: Camera / Gallery / File; desktop: an
unfiltered dialog) and the desktop page accepts any dropped file. Photo and
screenshot formats up to 64 MB are still re-encoded and shown inline;
everything else is stored byte for byte on dufs beside the images and linked
as [name](url), shown as a row with its size that opens in the platform's
app (Android) or a new browser tab (desktop). BACKLOG.md rewrites file links
to ~/data/cloud/todo-images/ like image links.

There is no size cap, so no layer holds a file in memory: uploads and
downloads stream through DufsImageClient, writes land in a .tmp renamed when
whole (an interrupted download no longer leaves a truncated file that looks
done), the desktop page POSTs the browser's own Blob with fetch, and Android's
"File" uses file_picker, because file_selector's Android side reads the whole
file into memory on the UI thread. Verified with a 1 GB file end to end on
desktop (peak RSS +36 MB) and on an API 36 emulator.

The wrapper serves attachments from the origin that holds the GitHub token
and the notes, so only script-free types open inline (nosniff), the rest
download; writes carrying another site's Origin are refused (this also
closes cross-site overwrites of the backup files); and attachment routes run
concurrently so a long upload or a playing video cannot stall the page.

Also: cupertino_icons 2.0.0 (unused, but the freshness gate blocks the
pubspec otherwise), open_file 4.0.0 and file_picker 13.1.0 pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:17:06 +02:00

96 lines
3.3 KiB
Dart

import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:todo/images/image_processing.dart';
/// A JPEG built in-test (the binary gate forbids committed fixtures), with
/// GPS coordinates and an EXIF rotation, like a phone photo.
Uint8List phonePhoto({int width = 4000, int height = 3000}) {
final photo = img.Image(width: width, height: height)
..clear(img.ColorRgb8(200, 30, 30));
photo.exif.imageIfd.orientation = 6;
photo.exif.gpsIfd['GPSLatitude'] = img.IfdValueRational(52, 1);
return img.encodeJpg(photo);
}
void main() {
test('the fixture really carries GPS and a rotation', () {
final exif = img.decodeJpgExif(phonePhoto(width: 40, height: 30))!;
expect(exif.gpsIfd.isEmpty, isFalse);
expect(exif.imageIfd.orientation, 6);
});
test('shrinks, applies the rotation and drops all EXIF', () async {
final out = (await prepareImageInBackground(phonePhoto()))!;
final back = img.decodeJpg(out)!;
// Landscape pixels + "rotate 90" => upright portrait, long side capped.
expect(back.width, 1920);
expect(back.height, kMaxImageSide);
expect(img.decodeJpgExif(out)?.isEmpty ?? true, isTrue);
});
test('caps a landscape image on its width', () {
final wide = img.encodeJpg(img.Image(width: 3000, height: 1000));
final back = img.decodeJpg(prepareImage(wide)!)!;
expect(back.width, kMaxImageSide);
});
test('leaves a small image at its size', () {
final small = img.encodePng(img.Image(width: 300, height: 200));
final back = img.decodeJpg(prepareImage(small)!)!;
expect((back.width, back.height), (300, 200));
});
test('flattens transparency onto white', () {
final clear = img.Image(width: 8, height: 8, numChannels: 4);
final back = img.decodeJpg(prepareImage(img.encodePng(clear))!)!;
final pixel = back.getPixel(4, 4);
expect([pixel.r, pixel.g, pixel.b], everyElement(greaterThan(250)));
});
test('non-images come back null, including ones the decoder throws on', () {
expect(prepareImage(Uint8List.fromList([1, 2, 3])), isNull);
expect(prepareImage(Uint8List.fromList('hello world'.codeUnits)), isNull);
expect(prepareImage(Uint8List(0)), isNull);
});
test('looksLikeImage knows the photo and screenshot formats', () {
final formats = {
'jpeg': img.encodeJpg(img.Image(width: 2, height: 2)),
'png': img.encodePng(img.Image(width: 2, height: 2)),
'gif': img.encodeGif(img.Image(width: 2, height: 2)),
'bmp': img.encodeBmp(img.Image(width: 2, height: 2)),
'tiff': img.encodeTiff(img.Image(width: 2, height: 2)),
'webp': Uint8List.fromList([
...'RIFF'.codeUnits,
0,
0,
0,
0,
...'WEBP'.codeUnits,
]),
'tiff big-endian': Uint8List.fromList([0x4D, 0x4D, 0x00, 0x2A]),
};
formats.forEach((name, bytes) {
expect(
looksLikeImage(bytes.take(kImageSniffBytes).toList()),
isTrue,
reason: name,
);
});
});
test('looksLikeImage refuses everything else', () {
for (final head in [
'%PDF-1.7'.codeUnits,
'hello world'.codeUnits,
[...'RIFF'.codeUnits, 0, 0, 0, 0, ...'AVI '.codeUnits],
[0xFF, 0xD8],
<int>[],
]) {
expect(looksLikeImage(head), isFalse, reason: '$head');
}
});
}