todo-app/test/ui_images/capture_images_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

138 lines
4.4 KiB
Dart

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/attachment_picking.dart';
import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/images/attachment_strip.dart';
import '../capture_screen_harness.dart';
import '../fake_image_store.dart';
void main() {
testWidgets('📎 on an empty draft creates the note under the file id', (
tester,
) async {
final store = FakeImageStore();
final repo = await pumpCapture(
tester,
imageStore: store,
attachmentPicking: (_) async => [
XFile('/picked/shot.png'),
XFile('/picked/Raport.pdf'),
],
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
);
await tester.tap(find.byTooltip('Attach'));
await tester.pump();
await tester.pump();
expect(store.attachedFiles, hasLength(2));
final notes = await repo.listNotes();
expect(notes.single.text, contains('![]('));
expect(notes.single.text, contains('[Raport.pdf]('));
expect(notes.single.text, contains('/${notes.single.id}/'));
expect(find.byType(AttachmentStrip), findsOneWidget);
});
testWidgets('📎 on a started draft files under that note', (tester) async {
final store = FakeImageStore();
final repo = await pumpCapture(
tester,
imageStore: store,
attachmentPicking: (_) async => [XFile('/picked/a.pdf')],
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
);
await tester.enterText(find.byType(TextField), 'idea');
await tester.pump();
final id = (await repo.listNotes()).single.id;
await tester.tap(find.byTooltip('Attach'));
await tester.pump();
await tester.pump();
final text = (await repo.listNotes()).single.text;
expect(text, startsWith('idea\n[a.pdf]('));
expect(text, contains('/$id/'));
});
testWidgets('launch and resume reconcile with every note', (tester) async {
final store = FakeImageStore();
await pumpCapture(
tester,
imageStore: store,
seed: [
Note(
id: 'a',
text: 'one',
priority: Priority.medium,
status: Status.done,
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
),
],
);
await tester.pump();
expect(store.reconciles, isNotEmpty);
expect(store.reconciles.last, ['one']);
final before = store.reconciles.length;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(store.reconciles.length, before + 1);
});
testWidgets('a manual sync reconciles images after it', (tester) async {
final store = FakeImageStore();
await pumpCapture(
tester,
imageStore: store,
prefs: configuredPrefs,
httpClient: recordingMock([]),
);
await tester.pump();
final before = store.reconciles.length;
await tester.tap(find.byTooltip('Sync'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(store.reconciles.length, greaterThan(before));
});
testWidgets('a drop attaches any file to the draft', (tester) async {
final store = FakeImageStore();
final repo = await pumpCapture(
tester,
imageStore: store,
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
);
tester.widget<ImageInputListener>(find.byType(ImageInputListener)).onFiles([
XFile('/dropped/clip.mp4'),
]);
await tester.pump();
await tester.pump();
expect(store.attachedFiles.single.name, 'clip.mp4');
expect((await repo.listNotes()).single.text, contains('[clip.mp4]('));
});
testWidgets('a paste attaches to the draft', (tester) async {
final store = FakeImageStore();
final repo = await pumpCapture(
tester,
imageStore: store,
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
);
tester.widget<ImageInputListener>(find.byType(ImageInputListener)).onImages(
[
Uint8List.fromList([1]),
],
1,
);
await tester.pump();
await tester.pump();
expect(store.attached, hasLength(1));
expect(
(await repo.listNotes()).single.id,
store.attached.keys.single.noteId,
);
expect(find.text('Not an image'), findsOneWidget);
});
}