diff --git a/.github/workflows/data-transfer-flutter-tests.yml b/.github/workflows/data-transfer-flutter-tests.yml new file mode 100644 index 0000000..838036f --- /dev/null +++ b/.github/workflows/data-transfer-flutter-tests.yml @@ -0,0 +1,36 @@ +name: data_transfer_flutter tests + +on: + push: + branches: [main] + paths: ['data_transfer_flutter/**'] + pull_request: + branches: [main] + paths: ['data_transfer_flutter/**'] + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: data_transfer_flutter + + steps: + - uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: Install dependencies + run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Run tests with coverage + run: flutter test --coverage + + - name: Enforce 100% line coverage + run: scripts/check_coverage.sh coverage/lcov.info 100 diff --git a/data_transfer_flutter/.gitignore b/data_transfer_flutter/.gitignore new file mode 100644 index 0000000..08e2419 --- /dev/null +++ b/data_transfer_flutter/.gitignore @@ -0,0 +1,6 @@ +.dart_tool/ +.packages +build/ +pubspec.lock +coverage/ +*.iml diff --git a/data_transfer_flutter/.pre-commit-config.yaml b/data_transfer_flutter/.pre-commit-config.yaml new file mode 100644 index 0000000..5a8c74f --- /dev/null +++ b/data_transfer_flutter/.pre-commit-config.yaml @@ -0,0 +1,66 @@ +# Pre-commit Configuration for data_transfer_flutter +# Install: pre-commit install && pre-commit install --hook-type pre-push +# Run: pre-commit run --all-files + +# NOTE: this subproject lives inside the ~/src/utils monorepo, whose git root is +# one level up. pre-commit always runs hooks from the git root, so every path +# below is written relative to THAT, not to this directory, and the top-level +# `files` key keeps these hooks off the sibling subprojects. CI invokes this +# config exactly the same way: +# pre-commit run --config data_transfer_flutter/.pre-commit-config.yaml --all-files +files: ^data_transfer_flutter/ + +default_stages: [pre-commit] +fail_fast: false + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + - id: check-merge-conflict + - id: mixed-line-ending + args: [--fix=lf] + + - repo: local + hooks: + - id: file-length-cap + name: file length <= 250 lines + entry: bash scripts/check_file_length.sh + language: system + pass_filenames: true + - id: subproject-scope + name: subproject scope regex still matches + entry: scripts/check_subproject_scope.sh data_transfer_flutter + language: script + # always_run so the top-level `files` filter cannot skip the one hook + # whose job is to notice that filter has stopped matching anything. + always_run: true + pass_filenames: false + - id: no-crdt-sync-override + name: no local dependency_overrides on a utils git dependency + entry: scripts/check_no_crdt_sync_override.sh data_transfer_flutter/pubspec.yaml + language: script + pass_filenames: false + - id: dart-format + name: dart format --set-exit-if-changed + entry: bash -c 'cd data_transfer_flutter && dart format --set-exit-if-changed lib test' + language: system + pass_filenames: false + - id: flutter-analyze + name: flutter analyze + entry: bash -c 'cd data_transfer_flutter && flutter analyze' + language: system + pass_filenames: false + - id: flutter-test-coverage + name: flutter test --coverage with 100% line-coverage gate + entry: >- + bash -c 'cd data_transfer_flutter && flutter test --coverage && + scripts/check_coverage.sh coverage/lcov.info 100' + language: system + pass_filenames: false + require_serial: true + stages: [pre-push] diff --git a/data_transfer_flutter/README.md b/data_transfer_flutter/README.md new file mode 100644 index 0000000..c62ac54 --- /dev/null +++ b/data_transfer_flutter/README.md @@ -0,0 +1,37 @@ +# data_transfer_flutter + +Shared "export to a JSON file / import from a JSON file" plumbing for kuhy's +Flutter apps. Apps keep their own export CONTENT; this owns the file plumbing +and the envelope. + +## Install + +```yaml +dependencies: + data_transfer_flutter: + git: + url: https://github.com/kuhyx/utils + ref: data_transfer_flutter-v0.1.0 + path: data_transfer_flutter +``` + +## Use + +```dart +final saved = await exportJson(fileName: 'x.json', json: envelope); +final map = await pickJson(); // null when cancelled +``` + +- `exportJson` / `exportJsonText` save to **device storage**: Android and iOS + use the system "Save as" document picker (`file_picker` `saveFile`, i.e. + ACTION_CREATE_DOCUMENT), desktop a save dialog (`file_selector`), web a + browser download. Returns false when cancelled. +- `shareJson` is an optional second action (share sheet). It is never the + only way out: a stock phone's share sheet has no local save target. +- `pickJson` / `pickJsonText` use the system open-document picker. +- `buildEnvelope` / `validateEnvelope` / `parseEnvelope` write and check + `{format, version, exported_at, ...}`; mismatches throw a typed + `EnvelopeException` (always a `FormatException`). + +Tests fake `DataTransfer.platform` (a `DataTransferPlatform`); restore it in +`tearDown`. diff --git a/data_transfer_flutter/analysis_options.yaml b/data_transfer_flutter/analysis_options.yaml new file mode 100644 index 0000000..286e24c --- /dev/null +++ b/data_transfer_flutter/analysis_options.yaml @@ -0,0 +1,38 @@ +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # Promote key lints to errors so they fail CI, not just warn. + missing_required_param: error + unnecessary_null_comparison: error + dead_code: error + invalid_annotation_target: error + exclude: + - build/** + - "**/*.g.dart" + - "**/*.freezed.dart" + - "test/**" + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + +linter: + rules: + # very_good_analysis enables most rules; add extras it doesn't include. + always_use_package_imports: true + avoid_print: true + avoid_relative_lib_imports: true + cancel_subscriptions: true + close_sinks: true + comment_references: false # conflicts with typical Flutter API docs style + directives_ordering: true + lines_longer_than_80_chars: true + public_member_api_docs: true + unawaited_futures: true diff --git a/data_transfer_flutter/lib/data_transfer_flutter.dart b/data_transfer_flutter/lib/data_transfer_flutter.dart new file mode 100644 index 0000000..1f96ac2 --- /dev/null +++ b/data_transfer_flutter/lib/data_transfer_flutter.dart @@ -0,0 +1,7 @@ +/// Shared JSON export/import plumbing: save, share, pick and a versioned +/// envelope. +library; + +export 'src/envelope.dart'; +export 'src/platform.dart'; +export 'src/transfer.dart'; diff --git a/data_transfer_flutter/lib/src/envelope.dart b/data_transfer_flutter/lib/src/envelope.dart new file mode 100644 index 0000000..ad0938a --- /dev/null +++ b/data_transfer_flutter/lib/src/envelope.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; + +/// Why a file is not an acceptable export envelope. +/// +/// Always a [FormatException], so callers that only care about "bad file" +/// can keep catching that. +sealed class EnvelopeException implements FormatException { + const EnvelopeException(this.message); + + @override + final String message; + + @override + dynamic get source => null; + + @override + int? get offset => null; + + @override + String toString() => 'EnvelopeException: $message'; +} + +/// The text is not JSON at all. +final class EnvelopeInvalidJson extends EnvelopeException { + /// Creates the error. + const EnvelopeInvalidJson() : super('not valid JSON'); +} + +/// The JSON is valid but its top level is not an object. +final class EnvelopeNotObject extends EnvelopeException { + /// Creates the error. + const EnvelopeNotObject() : super('export is not a JSON object'); +} + +/// The `format` marker is missing or names another kind of file. +final class EnvelopeFormatMismatch extends EnvelopeException { + /// Creates the error for a file marked [actual] when [expected] was needed. + EnvelopeFormatMismatch({required this.expected, required this.actual}) + : super('not a $expected file (found "$actual")'); + + /// The format the caller accepts. + final String expected; + + /// The format the file declared, `null` when it declared none. + final Object? actual; +} + +/// The `version` is missing or differs from the one the caller reads. +final class EnvelopeVersionMismatch extends EnvelopeException { + /// Creates the error for a file at version [actual] when [expected] was + /// needed. + EnvelopeVersionMismatch({required this.expected, required this.actual}) + : super('unsupported version $actual (expected $expected)'); + + /// The version the caller reads. + final int expected; + + /// The version the file declared, `null` when it declared none. + final Object? actual; +} + +/// Builds `{format, version, exported_at, ...body}`, in that key order. +/// +/// [now] is converted to UTC. Throws [ArgumentError] when [body] reuses an +/// envelope key, which would silently corrupt the marker. +Map buildEnvelope({ + required String format, + required int version, + required DateTime now, + Map body = const {}, +}) { + const reserved = {'format', 'version', 'exported_at'}; + final clash = body.keys.where(reserved.contains).toList(); + if (clash.isNotEmpty) { + throw ArgumentError.value(body, 'body', 'reuses envelope keys: $clash'); + } + return { + 'format': format, + 'version': version, + 'exported_at': now.toUtc().toIso8601String(), + ...body, + }; +} + +/// Returns [decoded] when it is an object marked [format] at [version]. +/// +/// Throws an [EnvelopeException] subtype otherwise. +Map validateEnvelope( + Object? decoded, { + required String format, + required int version, +}) { + if (decoded is! Map) throw const EnvelopeNotObject(); + final actualFormat = decoded['format']; + if (actualFormat != format) { + throw EnvelopeFormatMismatch(expected: format, actual: actualFormat); + } + final actualVersion = decoded['version']; + if (actualVersion != version) { + throw EnvelopeVersionMismatch(expected: version, actual: actualVersion); + } + return decoded; +} + +/// Decodes [text] and runs [validateEnvelope] on it. +Map parseEnvelope( + String text, { + required String format, + required int version, +}) { + final Object? decoded; + try { + decoded = jsonDecode(text); + } on FormatException { + throw const EnvelopeInvalidJson(); + } + return validateEnvelope(decoded, format: format, version: version); +} diff --git a/data_transfer_flutter/lib/src/json_type_group.dart b/data_transfer_flutter/lib/src/json_type_group.dart new file mode 100644 index 0000000..004f765 --- /dev/null +++ b/data_transfer_flutter/lib/src/json_type_group.dart @@ -0,0 +1,11 @@ +import 'package:file_selector/file_selector.dart'; + +/// The type group the open and save dialogs restrict to. +const jsonTypeGroup = XTypeGroup( + label: 'JSON', + extensions: ['json'], + // Needed by the macOS/iOS pickers and harmless elsewhere; a group with no + // recognised identifier silently matches nothing on those hosts. + uniformTypeIdentifiers: ['public.json'], + mimeTypes: ['application/json'], +); diff --git a/data_transfer_flutter/lib/src/platform.dart b/data_transfer_flutter/lib/src/platform.dart new file mode 100644 index 0000000..2fd986d --- /dev/null +++ b/data_transfer_flutter/lib/src/platform.dart @@ -0,0 +1,24 @@ +/// What differs per host: where the bytes of an export go and come from. +/// +/// The seam tests fake. Real implementations live in `platform_io.dart` +/// (Android, iOS, desktop) and `platform_web.dart`, chosen by +/// `platform_default.dart` so no `dart:io` import reaches a web build. +abstract interface class DataTransferPlatform { + /// Saves [text] as [fileName] where the user can find it again. + /// + /// Android/iOS: the system "Save as" document picker (device storage). + /// Desktop: a save dialog. Web: a browser download. Returns false when the + /// user cancelled. + Future saveText({required String fileName, required String text}); + + /// Offers [text] as [fileName] to the share sheet, where the host has one. + /// + /// A second action only: a share sheet can route the data to cloud apps, so + /// it never replaces [saveText]. Hosts without a sheet fall back to + /// [saveText]. + Future shareText({required String fileName, required String text}); + + /// Opens the system open-document picker and returns the file's text, or + /// null when the user cancelled. + Future pickText(); +} diff --git a/data_transfer_flutter/lib/src/platform_default.dart b/data_transfer_flutter/lib/src/platform_default.dart new file mode 100644 index 0000000..5370253 --- /dev/null +++ b/data_transfer_flutter/lib/src/platform_default.dart @@ -0,0 +1,3 @@ +export 'platform_io.dart' + if (dart.library.js_interop) 'platform_web.dart' + show createDefaultPlatform; diff --git a/data_transfer_flutter/lib/src/platform_io.dart b/data_transfer_flutter/lib/src/platform_io.dart new file mode 100644 index 0000000..32fa4b1 --- /dev/null +++ b/data_transfer_flutter/lib/src/platform_io.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:data_transfer_flutter/src/json_type_group.dart'; +import 'package:data_transfer_flutter/src/platform.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +/// Writes [bytes] through the system "Save as" picker; false on cancel. +typedef DocumentSaver = + Future Function({required String fileName, required Uint8List bytes}); + +/// Offers [text] as a file named [fileName] to the share sheet. +typedef SheetSharer = + Future Function({required String fileName, required String text}); + +/// The platform for hosts that have `dart:io`. +DataTransferPlatform createDefaultPlatform() => IoDataTransferPlatform(); + +/// Android, iOS and desktop. +/// +/// Mobile saves through the Storage Access Framework / document picker +/// (`file_picker`'s `saveFile`), because the share sheet on a stock phone has +/// no local save target and an export that can only be shared is an upload. +/// Desktop uses `file_selector`'s save dialog. Opening is `file_selector`'s +/// `openFile` everywhere: ACTION_OPEN_DOCUMENT on Android. +class IoDataTransferPlatform implements DataTransferPlatform { + /// Creates the platform; every argument is a test seam. + IoDataTransferPlatform({ + bool? isMobile, + DocumentSaver? documentSaver, + SheetSharer? sheetSharer, + }) : _isMobile = isMobile ?? (Platform.isAndroid || Platform.isIOS), + _documentSaver = documentSaver ?? _saveViaDocumentPicker, + _sheetSharer = sheetSharer ?? _shareViaSheet; + + final bool _isMobile; + final DocumentSaver _documentSaver; + final SheetSharer _sheetSharer; + + @override + Future saveText({ + required String fileName, + required String text, + }) async { + if (_isMobile) { + return await _documentSaver( + fileName: fileName, + bytes: Uint8List.fromList(utf8.encode(text)), + ); + } + final location = await getSaveLocation( + suggestedName: fileName, + acceptedTypeGroups: const [jsonTypeGroup], + ); + if (location == null) return false; + await File(location.path).writeAsString(text); + return true; + } + + @override + Future shareText({required String fileName, required String text}) => + _isMobile + ? _sheetSharer(fileName: fileName, text: text) + : saveText(fileName: fileName, text: text); + + @override + Future pickText() async { + final file = await openFile(acceptedTypeGroups: const [jsonTypeGroup]); + return await file?.readAsString(); + } +} + +// coverage:ignore-start +// Real plugin calls: unreachable on the Linux test host (a platform channel +// answers nothing there) and faked at the typedef seams above instead. +Future _saveViaDocumentPicker({ + required String fileName, + required Uint8List bytes, +}) async { + final saved = await FilePicker.saveFile( + fileName: fileName, + bytes: bytes, + mimeType: 'application/json', + ); + return saved != null; +} + +Future _shareViaSheet({ + required String fileName, + required String text, +}) async { + final dir = await getTemporaryDirectory(); + final file = File(p.join(dir.path, fileName)); + await file.writeAsString(text); + await SharePlus.instance.share( + ShareParams(files: [XFile(file.path, mimeType: 'application/json')]), + ); + return true; +} +// coverage:ignore-end diff --git a/data_transfer_flutter/lib/src/platform_web.dart b/data_transfer_flutter/lib/src/platform_web.dart new file mode 100644 index 0000000..dff5312 --- /dev/null +++ b/data_transfer_flutter/lib/src/platform_web.dart @@ -0,0 +1,39 @@ +import 'dart:convert'; + +import 'package:data_transfer_flutter/src/json_type_group.dart'; +import 'package:data_transfer_flutter/src/platform.dart'; +import 'package:file_selector/file_selector.dart'; + +/// The browser's platform: downloads and the open-file dialog. +DataTransferPlatform createDefaultPlatform() => const WebDataTransferPlatform(); + +/// Browser implementation. No share sheet; download covers both actions. +class WebDataTransferPlatform implements DataTransferPlatform { + /// Creates the platform. + const WebDataTransferPlatform(); + + @override + Future saveText({ + required String fileName, + required String text, + }) async { + // `saveTo` on web clicks a synthetic anchor: the path is ignored and the + // browser's download UI decides where the file lands. + await XFile.fromData( + utf8.encode(text), + mimeType: 'application/json', + name: fileName, + ).saveTo(''); + return true; + } + + @override + Future shareText({required String fileName, required String text}) => + saveText(fileName: fileName, text: text); + + @override + Future pickText() async { + final file = await openFile(acceptedTypeGroups: const [jsonTypeGroup]); + return await file?.readAsString(); + } +} diff --git a/data_transfer_flutter/lib/src/transfer.dart b/data_transfer_flutter/lib/src/transfer.dart new file mode 100644 index 0000000..c7b010f --- /dev/null +++ b/data_transfer_flutter/lib/src/transfer.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:data_transfer_flutter/src/envelope.dart'; +import 'package:data_transfer_flutter/src/platform.dart'; +import 'package:data_transfer_flutter/src/platform_default.dart'; + +/// Holds the platform the top-level functions use; tests replace it. +abstract final class DataTransfer { + /// The active platform. Defaults to the host's real implementation. + static DataTransferPlatform platform = createDefaultPlatform(); +} + +const _pretty = JsonEncoder.withIndent(' '); + +/// Saves [json] as [fileName] to device storage (see +/// [DataTransferPlatform.saveText]). False when the user cancelled. +Future exportJson({ + required String fileName, + required Map json, +}) => exportJsonText(fileName: fileName, text: _pretty.convert(json)); + +/// [exportJson] for an already-encoded JSON string. +Future exportJsonText({required String fileName, required String text}) => + DataTransfer.platform.saveText(fileName: fileName, text: text); + +/// Optional second action: offers [json] to the share sheet. +Future shareJson({ + required String fileName, + required Map json, +}) => DataTransfer.platform.shareText( + fileName: fileName, + text: _pretty.convert(json), +); + +/// Lets the user pick a JSON file and decodes it; null when cancelled. +/// +/// Throws [EnvelopeInvalidJson] / [EnvelopeNotObject] for a file that is not +/// a JSON object. +Future?> pickJson() async { + final text = await pickJsonText(); + if (text == null) return null; + final Object? decoded; + try { + decoded = jsonDecode(text); + } on FormatException { + throw const EnvelopeInvalidJson(); + } + if (decoded is! Map) throw const EnvelopeNotObject(); + return decoded; +} + +/// Lets the user pick a file and returns its raw text; null when cancelled. +Future pickJsonText() => DataTransfer.platform.pickText(); diff --git a/data_transfer_flutter/pubspec.yaml b/data_transfer_flutter/pubspec.yaml new file mode 100644 index 0000000..af1fd9c --- /dev/null +++ b/data_transfer_flutter/pubspec.yaml @@ -0,0 +1,28 @@ +name: data_transfer_flutter +description: >- + Shared "export to a JSON file / import from a JSON file" plumbing for kuhy's + Flutter apps: system Save-as on Android/iOS (never cloud-only), a save + dialog on desktop, a download on web, plus a versioned export envelope. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ^3.12.2 + +dependencies: + file_picker: 13.3.1 + file_selector: 1.1.0 + flutter: + sdk: flutter + path: 1.9.1 + path_provider: 2.1.6 + share_plus: 13.3.1 + +dev_dependencies: + file_selector_platform_interface: 2.7.0 + flutter_test: + sdk: flutter + plugin_platform_interface: 2.1.8 + very_good_analysis: ^11.0.0 + +flutter: diff --git a/data_transfer_flutter/scripts/check_coverage.sh b/data_transfer_flutter/scripts/check_coverage.sh new file mode 100755 index 0000000..219fa5f --- /dev/null +++ b/data_transfer_flutter/scripts/check_coverage.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# ============================================================================ +# Fail if lcov line coverage drops below a threshold. +# +# Thin delegate to the shared gate in ~/src/utils/scripts, which owns the parsing +# and the threshold comparison. This file used to be a byte-identical copy of +# that logic; three such copies existed, which is how one package's idea of +# "covered" drifts from every other package's. +# +# Usage: check_coverage.sh +# ============================================================================ + +set -euo pipefail + +# Repo-relative, resolved from this script's own location: an absolute +# /home/... path exits 127 on an Actions runner. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly SHARED_GATE="${SCRIPT_DIR}/../../scripts/check_coverage.sh" + +main() { + if [[ ! -f "$SHARED_GATE" ]]; then + echo "Error: shared coverage gate not found at $SHARED_GATE" >&2 + echo " It lives in the utils repo at scripts/check_coverage.sh." >&2 + exit 1 + fi + + exec bash "$SHARED_GATE" "$@" +} + +main "$@" diff --git a/data_transfer_flutter/test/envelope_test.dart b/data_transfer_flutter/test/envelope_test.dart new file mode 100644 index 0000000..5d42c5c --- /dev/null +++ b/data_transfer_flutter/test/envelope_test.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; + +import 'package:data_transfer_flutter/data_transfer_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final now = DateTime.utc(2026, 11, 1, 12, 30); + + group('buildEnvelope', () { + test('puts format, version and a UTC timestamp first', () { + final envelope = buildEnvelope( + format: 'demo_export', + version: 2, + now: now, + body: { + 'items': [1, 2], + }, + ); + + expect(envelope.keys, ['format', 'version', 'exported_at', 'items']); + expect(envelope['format'], 'demo_export'); + expect(envelope['version'], 2); + expect(envelope['exported_at'], '2026-11-01T12:30:00.000Z'); + expect(envelope['items'], [1, 2]); + }); + + test('converts a local clock to UTC', () { + final local = DateTime.parse('2026-11-01T12:30:00+02:00'); + + final envelope = buildEnvelope(format: 'f', version: 1, now: local); + + expect(envelope['exported_at'], '2026-11-01T10:30:00.000Z'); + }); + + test('refuses a body that shadows an envelope key', () { + expect( + () => buildEnvelope( + format: 'f', + version: 1, + now: now, + body: {'version': 9}, + ), + throwsArgumentError, + ); + }); + }); + + group('validateEnvelope', () { + final good = { + 'format': 'demo_export', + 'version': 1, + 'exported_at': 'x', + 'rest': true, + }; + + test('returns the same map when format and version match', () { + final out = validateEnvelope(good, format: 'demo_export', version: 1); + + expect(out, same(good)); + }); + + test('rejects a non-object as not an object', () { + for (final value in [null, 3, 'text', []]) { + expect( + () => validateEnvelope(value, format: 'demo_export', version: 1), + throwsA(isA()), + ); + } + }); + + test('rejects a foreign format and reports both names', () { + expect( + () => validateEnvelope( + {...good, 'format': 'other'}, + format: 'demo_export', + version: 1, + ), + throwsA( + isA() + .having((e) => e.expected, 'expected', 'demo_export') + .having((e) => e.actual, 'actual', 'other') + .having((e) => e.message, 'message', contains('demo_export')), + ), + ); + }); + + test('rejects a missing format', () { + expect( + () => + validateEnvelope({'version': 1}, format: 'demo_export', version: 1), + throwsA(isA()), + ); + }); + + test('rejects another version and reports both numbers', () { + expect( + () => validateEnvelope( + {...good, 'version': 7}, + format: 'demo_export', + version: 1, + ), + throwsA( + isA() + .having((e) => e.expected, 'expected', 1) + .having((e) => e.actual, 'actual', 7) + .having((e) => e.message, 'message', contains('7')), + ), + ); + }); + + test('rejects a missing version', () { + expect( + () => validateEnvelope( + {'format': 'demo_export'}, + format: 'demo_export', + version: 1, + ), + throwsA(isA()), + ); + }); + + test('every error is a FormatException', () { + expect( + () => validateEnvelope(null, format: 'f', version: 1), + throwsFormatException, + ); + expect( + () => validateEnvelope({'format': 'g'}, format: 'f', version: 1), + throwsFormatException, + ); + expect( + () => validateEnvelope( + {'format': 'f', 'version': 2}, + format: 'f', + version: 1, + ), + throwsFormatException, + ); + }); + }); + + group('EnvelopeException', () { + test('behaves as a FormatException with no source position', () { + const FormatException error = EnvelopeInvalidJson(); + + expect(error.message, 'not valid JSON'); + expect(error.source, isNull); + expect(error.offset, isNull); + expect(error.toString(), contains('not valid JSON')); + }); + }); + + group('parseEnvelope', () { + test('round-trips what buildEnvelope produced', () { + final text = jsonEncode( + buildEnvelope(format: 'f', version: 1, now: now, body: {'n': 1}), + ); + + final parsed = parseEnvelope(text, format: 'f', version: 1); + + expect(parsed['n'], 1); + }); + + test('reports malformed JSON as an invalid-JSON error', () { + expect( + () => parseEnvelope('{oops', format: 'f', version: 1), + throwsA(isA()), + ); + }); + + test('runs the format and version checks on valid JSON', () { + expect( + () => parseEnvelope( + '{"format":"g","version":1}', + format: 'f', + version: 1, + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/data_transfer_flutter/test/io_platform_test.dart b/data_transfer_flutter/test/io_platform_test.dart new file mode 100644 index 0000000..fd8e9ee --- /dev/null +++ b/data_transfer_flutter/test/io_platform_test.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:data_transfer_flutter/src/platform_io.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _FakeSelector extends FileSelectorPlatform + with MockPlatformInterfaceMixin { + FileSaveLocation? saveLocation; + XFile? fileToOpen; + final saveRequests = []; + + @override + Future getSaveLocation({ + List? acceptedTypeGroups, + SaveDialogOptions options = const SaveDialogOptions(), + }) async { + saveRequests.add(options.suggestedName); + return saveLocation; + } + + @override + Future openFile({ + List? acceptedTypeGroups, + String? initialDirectory, + String? confirmButtonText, + }) async => fileToOpen; +} + +void main() { + late _FakeSelector selector; + late Directory temp; + + setUp(() async { + TestWidgetsFlutterBinding.ensureInitialized(); + selector = _FakeSelector(); + FileSelectorPlatform.instance = selector; + temp = await Directory.systemTemp.createTemp('data_transfer_io'); + }); + + tearDown(() => temp.delete(recursive: true)); + + group('desktop', () { + final desktop = IoDataTransferPlatform(isMobile: false); + + test('save writes to the chosen path and suggests the name', () async { + final path = '${temp.path}/out.json'; + selector.saveLocation = FileSaveLocation(path); + + final saved = await desktop.saveText(fileName: 'x.json', text: '{}'); + + expect(saved, isTrue); + expect(File(path).readAsStringSync(), '{}'); + expect(selector.saveRequests, ['x.json']); + }); + + test('a cancelled save writes nothing and returns false', () async { + final saved = await desktop.saveText(fileName: 'x.json', text: '{}'); + + expect(saved, isFalse); + expect(temp.listSync(), isEmpty); + }); + + test('share falls back to the save dialog', () async { + final path = '${temp.path}/shared.json'; + selector.saveLocation = FileSaveLocation(path); + + final shared = await desktop.shareText(fileName: 'x.json', text: '[]'); + + expect(shared, isTrue); + expect(File(path).readAsStringSync(), '[]'); + }); + + test('pick reads the chosen file, null when cancelled', () async { + expect(await desktop.pickText(), isNull); + + selector.fileToOpen = XFile.fromData( + Uint8List.fromList('{"a":1}'.codeUnits), + name: 'in.json', + ); + + expect(await desktop.pickText(), '{"a":1}'); + }); + }); + + group('mobile', () { + test( + 'save hands UTF-8 bytes to the document saver, not file_selector', + () async { + String? name; + List? captured; + final mobile = IoDataTransferPlatform( + isMobile: true, + documentSaver: ({required fileName, required bytes}) async { + name = fileName; + captured = bytes; + return true; + }, + ); + + final saved = await mobile.saveText(fileName: 'm.json', text: 'zażółć'); + + expect(saved, isTrue); + expect(name, 'm.json'); + expect(utf8.decode(captured!), 'zażółć'); + expect(selector.saveRequests, isEmpty); + }, + ); + + test('a cancelled document picker reports false', () async { + final mobile = IoDataTransferPlatform( + isMobile: true, + documentSaver: ({required fileName, required bytes}) async => false, + ); + + expect(await mobile.saveText(fileName: 'm.json', text: '{}'), isFalse); + }); + + test('share goes through the share sheet seam', () async { + (String, String)? seen; + final mobile = IoDataTransferPlatform( + isMobile: true, + sheetSharer: ({required fileName, required text}) async { + seen = (fileName, text); + return true; + }, + ); + + expect(await mobile.shareText(fileName: 's.json', text: '1'), isTrue); + expect(seen, ('s.json', '1')); + }); + }); +} diff --git a/data_transfer_flutter/test/support/fake_platform.dart b/data_transfer_flutter/test/support/fake_platform.dart new file mode 100644 index 0000000..0fd10a7 --- /dev/null +++ b/data_transfer_flutter/test/support/fake_platform.dart @@ -0,0 +1,37 @@ +import 'package:data_transfer_flutter/data_transfer_flutter.dart'; + +/// Records what the facade asked the platform to do. +class FakePlatform implements DataTransferPlatform { + /// What every save/share returns; false stands for a cancelled dialog. + bool accept = true; + + /// What [pickText] returns; null stands for a cancelled picker. + String? picked; + + /// `(fileName, text)` of every [saveText] call. + final saves = <(String, String)>[]; + + /// `(fileName, text)` of every [shareText] call. + final shares = <(String, String)>[]; + + @override + Future saveText({ + required String fileName, + required String text, + }) async { + saves.add((fileName, text)); + return accept; + } + + @override + Future shareText({ + required String fileName, + required String text, + }) async { + shares.add((fileName, text)); + return accept; + } + + @override + Future pickText() async => picked; +} diff --git a/data_transfer_flutter/test/transfer_test.dart b/data_transfer_flutter/test/transfer_test.dart new file mode 100644 index 0000000..d3399a0 --- /dev/null +++ b/data_transfer_flutter/test/transfer_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:data_transfer_flutter/data_transfer_flutter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/fake_platform.dart'; + +void main() { + late FakePlatform fake; + late DataTransferPlatform original; + + setUp(() { + original = DataTransfer.platform; + fake = FakePlatform(); + DataTransfer.platform = fake; + }); + + tearDown(() => DataTransfer.platform = original); + + group('exportJson', () { + test('saves the map as indented JSON under the given name', () async { + final saved = await exportJson(fileName: 'a.json', json: {'k': 1}); + + expect(saved, isTrue); + expect(fake.saves.single.$1, 'a.json'); + expect( + fake.saves.single.$2, + const JsonEncoder.withIndent(' ').convert({'k': 1}), + ); + expect(fake.shares, isEmpty); + }); + + test('returns false when the dialog was cancelled', () async { + fake.accept = false; + + expect(await exportJson(fileName: 'a.json', json: {}), isFalse); + }); + }); + + group('exportJsonText', () { + test('passes an already-encoded string through untouched', () async { + await exportJsonText(fileName: 'b.json', text: '{"x":1}'); + + expect(fake.saves.single, ('b.json', '{"x":1}')); + }); + }); + + group('shareJson', () { + test('uses the share action, not save', () async { + final shared = await shareJson(fileName: 'c.json', json: {'k': 2}); + + expect(shared, isTrue); + expect(fake.shares.single.$1, 'c.json'); + expect(jsonDecode(fake.shares.single.$2), {'k': 2}); + expect(fake.saves, isEmpty); + }); + }); + + group('pickJson', () { + test('decodes the picked file', () async { + fake.picked = '{"a":[1,2]}'; + + expect(await pickJson(), { + 'a': [1, 2], + }); + }); + + test('returns null when the picker was cancelled', () async { + expect(await pickJson(), isNull); + expect(await pickJsonText(), isNull); + }); + + test('rejects a file that is not JSON', () async { + fake.picked = '{oops'; + + await expectLater(pickJson, throwsA(isA())); + }); + + test('rejects JSON that is not an object', () async { + fake.picked = '[1]'; + + await expectLater(pickJson, throwsA(isA())); + }); + }); + + group('pickJsonText', () { + test('returns the raw text without decoding it', () async { + fake.picked = 'not even json'; + + expect(await pickJsonText(), 'not even json'); + }); + }); +}