import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:re_editor/re_editor.dart'; import 'package:sm_mobile/core/app_controller.dart'; import 'package:sm_mobile/core/failure.dart'; import 'package:sm_mobile/l10n/strings.dart'; import 'package:sm_mobile/services/editor/sources.dart'; import 'package:sm_mobile/services/editor/text_codec.dart'; import 'package:sm_mobile/ui/editor/editor_page.dart'; import '../support.dart'; /// A file kept in memory, with the same conflict rule as real sources. class MemorySource implements FileSource { @override final String name; Uint8List bytes; int version = 0, loadedVersion = 0, saves = 0; MemorySource(this.name, this.bytes); @override String get location => 'memory:$name'; @override String get kind => 'device'; @override String get key => location; @override bool get canSave => true; @override String? get readOnlyReason => null; @override Future load({int maxBytes = maxEditableBytes}) async { if (bytes.length > maxBytes) throw const Failure('fileTooLarge'); loadedVersion = version; return bytes; } @override Future save( Uint8List next, { bool force = false, bool inPlace = false, }) async { if (!force && version != loadedVersion) { throw const Failure('documentChanged'); } bytes = next; version++; loadedVersion = version; saves++; } @override Map toJson() => {'kind': 'new', 'name': name}; } void main() { late AppController c; setUp(() async { c = AppController(MemoryStore()); }); Future open(WidgetTester tester, MemorySource source) async { tester.view.physicalSize = const Size(1000, 1600); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.runAsync(() async { await c.initialize(); await c.acceptLicense(); if (!c.vault.unlocked) { await c.openVault('editor test password', create: true); } }); await tester.pumpWidget( MaterialApp( locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: EditorPage(c, source), ), ); await tester.runAsync(() async { for (var i = 0; i < 3000; i++) { await tester.pump(const Duration(milliseconds: 10)); if (find.byType(CodeEditor).evaluate().isNotEmpty || find.byIcon(Icons.error_outline).evaluate().isNotEmpty || find.byIcon(Icons.memory).evaluate().isNotEmpty) { break; } await Future.delayed(const Duration(milliseconds: 10)); } }); await tester.pump(); } CodeLineEditingController editor(WidgetTester tester) => tester.widget(find.byType(CodeEditor)).controller!; Future save(WidgetTester tester) async { await tester.tap(find.byIcon(Icons.save)); await tester.runAsync(() async { for (var i = 0; i < 300; i++) { await tester.pump(const Duration(milliseconds: 10)); if (find.byType(CircularProgressIndicator).evaluate().isEmpty) break; await Future.delayed(const Duration(milliseconds: 10)); } }); await tester.pump(); } Future close(WidgetTester tester) async { // Stop the cursor blink (a real timer in re_editor) and let snack bars // and debounce timers run out. FocusManager.instance.primaryFocus?.unfocus(); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 600)), ); await tester.pump(const Duration(seconds: 10)); await tester.pumpWidget(const SizedBox()); await tester.pump(const Duration(seconds: 10)); c.dispose(); } final samples = { 'notes.txt': ('Hello\nworld\n', 'Text'), 'index.html': ( '\n

Hi

\n', 'HTML', ), 'index.php': ('\n\n', 'XML'), 'site.css': ('body {\n color: red;\n}\n', 'CSS'), 'app.js': ('const a = 1;\nconsole.log(a);\n', 'JavaScript'), 'tool.py': ('def main():\n print("x")\n', 'Python'), 'deploy.sh': ('#!/bin/sh\necho ok\n', 'Shell (Bash/sh/zsh)'), 'nginx.conf': ('server {\r\n listen 80;\r\n}\r\n', 'NGINX'), 'web.service': ( '[Unit]\nDescription=Web\n\n[Service]\nExecStart=/bin/true\n', 'systemd unit', ), 'motd': ('Welcome\nto the server', 'Text'), }; for (final MapEntry(key: name, value: (text, syntax)) in samples.entries) { testWidgets('open, edit and save $name', (tester) async { final source = MemorySource(name, Uint8List.fromList(utf8.encode(text))); await open(tester, source); expect(find.text(syntax), findsOneWidget, reason: 'syntax shown'); final e = editor(tester); expect(e.text, text.replaceAll('\r\n', '\n').isEmpty ? '' : e.text); // Insert at the start of line 1. e.selection = const CodeLineSelection.collapsed(index: 0, offset: 0); e.replaceSelection('# edit\n'); await tester.pump(); expect(find.textContaining('● $name'), findsOneWidget, reason: 'dirty'); await save(tester); final crlf = text.contains('\r\n'); final expected = (crlf ? '# edit\r\n' : '# edit\n') + text; expect(utf8.decode(source.bytes), expected); expect(source.saves, 1); expect(find.textContaining('● '), findsNothing); await close(tester); }); } testWidgets('Windows-1250 and UTF-16 stay in their encoding', (tester) async { final source = MemorySource( 'legacy.txt', encodeText('Žluťoučký kůň\r\n', TextEncoding.windows1250), ); await open(tester, source); // Unknown 8-bit encoding: the user is asked, nothing is guessed. expect(find.text('Unknown text encoding'), findsOneWidget); await tester.tap(find.text('Windows-1250')); await tester.runAsync(() async { for (var i = 0; i < 100; i++) { await tester.pump(const Duration(milliseconds: 10)); if (find.byType(CodeEditor).evaluate().isNotEmpty) break; } }); await tester.pump(); final e = editor(tester); e.selection = CodeLineSelection.collapsed( index: 0, offset: e.codeLines[0].length, ); e.replaceSelection('!'); await tester.pump(); await save(tester); expect( source.bytes, encodeText('Žluťoučký kůň!\r\n', TextEncoding.windows1250), ); await close(tester); final wide = MemorySource( 'w.txt', encodeText('a\nb\n', TextEncoding.utf16be), ); c = AppController(MemoryStore()); await open(tester, wide); expect(find.text('UTF-16 BE'), findsOneWidget); final w = editor(tester); w.selection = const CodeLineSelection.collapsed(index: 0, offset: 1); w.replaceSelection('Z'); await tester.pump(); await save(tester); expect(wide.bytes, encodeText('aZ\nb\n', TextEncoding.utf16be)); await close(tester); }); testWidgets('a change elsewhere is a conflict, not a silent overwrite', ( tester, ) async { final source = MemorySource( 'a.txt', Uint8List.fromList(utf8.encode('one\n')), ); await open(tester, source); source.version++; // changed by another app final e = editor(tester); e.selection = const CodeLineSelection.collapsed(index: 0, offset: 0); e.replaceSelection('x'); await tester.pump(); await save(tester); expect(find.text('The file on the device has changed'), findsOneWidget); expect(source.saves, 0); await tester.tap(find.text('Overwrite')); await tester.runAsync(() async { for (var i = 0; i < 100 && source.saves == 0; i++) { await tester.pump(const Duration(milliseconds: 10)); await Future.delayed(const Duration(milliseconds: 10)); } }); expect(utf8.decode(source.bytes), 'xone\n'); await close(tester); }); testWidgets('binary files are not opened as text without asking', ( tester, ) async { final source = MemorySource( 'blob', // A PNG header followed by mixed binary data. Uint8List.fromList([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // for (var i = 0; i < 512; i++) (i * 37) % 256, ]), ); await open(tester, source); expect(find.text('This looks like a binary file.'), findsOneWidget); expect(find.byType(CodeEditor), findsNothing); await close(tester); }); testWidgets('unsaved text becomes a draft when the Vault locks', ( tester, ) async { final source = MemorySource( 'd.txt', Uint8List.fromList(utf8.encode('x\n')), ); await open(tester, source); final e = editor(tester); e.selection = const CodeLineSelection.collapsed(index: 0, offset: 1); e.replaceSelection('yz'); await tester.pump(); final draft = c.draftSources.single(); expect(draft!.json['text'], 'xyz\n'); expect(draft.json['encoding'], 'utf8'); await close(tester); }); for (final kb in [1, 100, 1024, 5 * 1024, 10 * 1024]) { testWidgets('$kb KB file opens, edits and saves intact', (tester) async { final line = 'line with some text, čeština a € ${'x' * 40}\n'; final buffer = StringBuffer(); while (buffer.length < kb * 1024) { buffer.write(line); } final bytes = Uint8List.fromList(utf8.encode(buffer.toString())); final source = MemorySource('big.log', bytes); final watch = Stopwatch()..start(); await open(tester, source); final opened = watch.elapsedMilliseconds; final e = editor(tester); expect(e.lineCount, greaterThan(kb * 1024 ~/ line.length)); e.selection = const CodeLineSelection.collapsed(index: 0, offset: 0); e.replaceSelection('>'); await tester.pump(); watch.reset(); await save(tester); final saved = watch.elapsedMilliseconds; expect(source.bytes.length, bytes.length + 1); expect(source.bytes.sublist(1), bytes); // ignore: avoid_print print('editor $kb KB: open $opened ms, save $saved ms'); await close(tester); }, timeout: const Timeout(Duration(minutes: 3))); } }