Compare commits
4 Commits
v2026.10.1
...
기능추가1
| Author | SHA1 | Date |
|---|---|---|
|
|
1b3160939c | |
|
|
5366bb3a14 | |
|
|
ab4c82d0b0 | |
|
|
3d3ab5666f |
|
|
@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
||||||
import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
||||||
|
import 'package:jj_mkvmaker/ui/sync_preview_view.dart' show keepOptionsTogether;
|
||||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||||
import 'support/temp_dir.dart';
|
import 'support/temp_dir.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
@ -227,7 +228,8 @@ void main() {
|
||||||
await tap(find.text('srcA').first);
|
await tap(find.text('srcA').first);
|
||||||
await tap(find.text('dstB').last);
|
await tap(find.text('dstB').last);
|
||||||
await tap(find.text('좌 → 우'));
|
await tap(find.text('좌 → 우'));
|
||||||
await tap(find.text('원본 파일 지우기 (--remove-source-files)'));
|
// 옵션 이름은 줄바꿈되지 않게 묶여 그려진다 (rsync_narrow dfed3fa) - 화면에 그려지는 글 그대로 찾는다
|
||||||
|
await tap(find.text(keepOptionsTogether('원본 파일 지우기 (--remove-source-files)')));
|
||||||
await tap(find.text('빈 폴더 지움 · 원본 폴더도 지움'));
|
await tap(find.text('빈 폴더 지움 · 원본 폴더도 지움'));
|
||||||
await shot('rsync_move_confirm');
|
await shot('rsync_move_confirm');
|
||||||
await tap(find.widgetWithText(FilledButton, '이동'));
|
await tap(find.widgetWithText(FilledButton, '이동'));
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ void main() {
|
||||||
final steps = File(p.join(Directory.systemTemp.path, 'jj_shell_steps.txt'));
|
final steps = File(p.join(Directory.systemTemp.path, 'jj_shell_steps.txt'));
|
||||||
if (steps.existsSync()) steps.deleteSync();
|
if (steps.existsSync()) steps.deleteSync();
|
||||||
void mark(String s) => steps.writeAsStringSync('$s\n', mode: FileMode.append, flush: true);
|
void mark(String s) => steps.writeAsStringSync('$s\n', mode: FileMode.append, flush: true);
|
||||||
|
// 관리자 지시 (10/20): 끝까지 돌면 단계 기록을 지운다 (앱이 도중에 꺼졌을 때만 남아 어디까지 갔는지 보인다)
|
||||||
|
tearDownAll(() {
|
||||||
|
if (steps.existsSync()) steps.deleteSync();
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('DesktopShell', (tester) async {
|
testWidgets('DesktopShell', (tester) async {
|
||||||
mark('start');
|
mark('start');
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ void main() {
|
||||||
|
|
||||||
testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async {
|
testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async {
|
||||||
final dir = testTempDir('jj_adj_e2e_');
|
final dir = testTempDir('jj_adj_e2e_');
|
||||||
|
appTempInto(dir); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
|
||||||
final video = p.join(dir.path, '가로 영상.mp4');
|
final video = p.join(dir.path, '가로 영상.mp4');
|
||||||
final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe');
|
final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe');
|
||||||
final r = await Process.run(ffmpeg, [
|
final r = await Process.run(ffmpeg, [
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind;
|
import 'package:flutter/gestures.dart' show kBackMouseButton, kForwardMouseButton, kPrimaryButton, PointerDeviceKind;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_test.dart';
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
|
@ -24,6 +26,7 @@ void main() {
|
||||||
late Directory tmp;
|
late Directory tmp;
|
||||||
late String left, right;
|
late String left, right;
|
||||||
late AppController c;
|
late AppController c;
|
||||||
|
final shotKey = GlobalKey();
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
await i18n.apply('ko', save: false);
|
await i18n.apply('ko', save: false);
|
||||||
|
|
@ -70,7 +73,11 @@ void main() {
|
||||||
t.view.physicalSize = const Size(1600, 900);
|
t.view.physicalSize = const Size(1600, 900);
|
||||||
t.view.devicePixelRatio = 1;
|
t.view.devicePixelRatio = 1;
|
||||||
addTearDown(t.view.reset);
|
addTearDown(t.view.reset);
|
||||||
await t.pumpWidget(MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)));
|
await t.pumpWidget(MaterialApp(
|
||||||
|
theme: buildTheme(),
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
builder: (_, child) => RepaintBoundary(key: shotKey, child: child),
|
||||||
|
home: ExplorerPage(c: c)));
|
||||||
await until(t, () => find.text('doc.txt').evaluate().isNotEmpty && find.text('r1').evaluate().isNotEmpty);
|
await until(t, () => find.text('doc.txt').evaluate().isNotEmpty && find.text('r1').evaluate().isNotEmpty);
|
||||||
expect(find.text('doc.txt'), findsOneWidget);
|
expect(find.text('doc.txt'), findsOneWidget);
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +111,61 @@ void main() {
|
||||||
|
|
||||||
int feedback() => find.byIcon(Icons.drive_file_move_outline).evaluate().length;
|
int feedback() => find.byIcon(Icons.drive_file_move_outline).evaluate().length;
|
||||||
|
|
||||||
|
/// 관리자 지시 (10/20): 실패 때의 화면 캡처 (JJ_SHOT_DIR) 와 그때 열린 창 · 단추 · 글 목록
|
||||||
|
Future<void> dump(WidgetTester t, String what) async {
|
||||||
|
final texts = [for (final e in find.byType(Text).evaluate()) (e.widget as Text).data ?? (e.widget as Text).textSpan?.toPlainText() ?? ''];
|
||||||
|
final buttons = [
|
||||||
|
for (final e in find.byWidgetPredicate((w) => w is ButtonStyleButton || w is SnackBarAction).evaluate())
|
||||||
|
'${e.widget.runtimeType}: ${[for (final x in find.descendant(of: find.byWidget(e.widget), matching: find.byType(Text)).evaluate()) (x.widget as Text).data].join(' ')}'
|
||||||
|
];
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('== $what 실패 때: 창 ${find.byType(Dialog).evaluate().length} · 스낵바 ${find.byType(SnackBar).evaluate().length}\n단추: $buttons\n글: $texts');
|
||||||
|
final dir = Platform.environment['JJ_SHOT_DIR'];
|
||||||
|
final b = shotKey.currentContext?.findRenderObject();
|
||||||
|
if (dir == null || dir.isEmpty || b is! RenderRepaintBoundary) return;
|
||||||
|
final img = await t.runAsync(() => b.toImage());
|
||||||
|
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||||
|
File(p.join(dir, 'b6_input_fail_${what.hashCode.toRadixString(16)}.png'))
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 관리자 지시 (10/20): [f] 가 보이는 것만이 아니라 정말 눌리는 자리에 올 때까지 기다린 뒤 누른다.
|
||||||
|
/// 스낵바는 아래에서 올라오는 동안 (약 0.25초) 글은 이미 있지만 그 자리를 누르면 아무것도 맞지 않는다 - 예전에는 그때 눌러 [되돌리기] 가 빗나갔다.
|
||||||
|
/// 끝내 눌리지 않으면 캡처 · 목록을 남기고 실패
|
||||||
|
Future<void> tapReady(WidgetTester t, Finder f, String what, {int ms = 8000}) async {
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
int? seen;
|
||||||
|
bool within(Object? target, RenderObject box) {
|
||||||
|
for (RenderObject? x = target is RenderObject ? target : null; x != null; x = x.parent) {
|
||||||
|
if (identical(x, box)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < ms ~/ 50; i++) {
|
||||||
|
final e = f.evaluate();
|
||||||
|
if (e.length == 1) {
|
||||||
|
seen ??= sw.elapsedMilliseconds;
|
||||||
|
final box = e.single.renderObject;
|
||||||
|
if (box is RenderBox && box.attached && box.hasSize) {
|
||||||
|
final at = box.localToGlobal(box.size.center(Offset.zero));
|
||||||
|
final hit = HitTestResult();
|
||||||
|
t.binding.hitTestInView(hit, at, t.view.viewId);
|
||||||
|
if (hit.path.any((x) => within(x.target, box))) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('누를 수 있음: $what - 보인 뒤 ${sw.elapsedMilliseconds - seen}ms');
|
||||||
|
await t.tapAt(at);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await t.pump(const Duration(milliseconds: 50));
|
||||||
|
}
|
||||||
|
await dump(t, what);
|
||||||
|
fail('$what 을 누를 수 없음 (보인 때: ${seen == null ? '안 보임' : '${seen}ms'})');
|
||||||
|
}
|
||||||
|
|
||||||
testWidgets('82: 8px 문턱 · 놓을 곳 강조 · 같은 창은 빨간 테두리 · 묻기 창 [이동] · [되돌리기]', (t) async {
|
testWidgets('82: 8px 문턱 · 놓을 곳 강조 · 같은 창은 빨간 테두리 · 묻기 창 [이동] · [되돌리기]', (t) async {
|
||||||
await open(t);
|
await open(t);
|
||||||
final doc = t.getCenter(find.text('doc.txt'));
|
final doc = t.getCenter(find.text('doc.txt'));
|
||||||
|
|
@ -141,17 +203,15 @@ void main() {
|
||||||
await until(t, () => find.text('1개 항목을 놓았습니다').evaluate().isNotEmpty);
|
await until(t, () => find.text('1개 항목을 놓았습니다').evaluate().isNotEmpty);
|
||||||
expect(find.text('1개 항목을 놓았습니다'), findsOneWidget);
|
expect(find.text('1개 항목을 놓았습니다'), findsOneWidget);
|
||||||
expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget, reason: '처음 고른 단추는 [복사]');
|
expect(find.widgetWithText(FilledButton, '복사'), findsOneWidget, reason: '처음 고른 단추는 [복사]');
|
||||||
await t.tap(find.widgetWithText(OutlinedButton, '이동'));
|
await tapReady(t, find.widgetWithText(OutlinedButton, '이동'), '묻기 창 [이동]');
|
||||||
// 지금의 이동 확인 창
|
// 지금의 이동 확인 창
|
||||||
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
|
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '이동 확인 창 [이동]');
|
||||||
await t.tap(find.widgetWithText(FilledButton, '이동'));
|
|
||||||
await until(t, () => find.text('되돌리기').evaluate().isNotEmpty);
|
await until(t, () => find.text('되돌리기').evaluate().isNotEmpty);
|
||||||
expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue);
|
expect(File(p.join(right, 'doc.txt')).existsSync(), isTrue);
|
||||||
expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse);
|
expect(File(p.join(left, 'doc.txt')).existsSync(), isFalse);
|
||||||
// 4) [되돌리기] → 원래 자리로 (지금의 이동 확인 창)
|
// 4) [되돌리기] → 원래 자리로 (지금의 이동 확인 창)
|
||||||
await t.tap(find.text('되돌리기'));
|
await tapReady(t, find.text('되돌리기'), '[되돌리기]');
|
||||||
await until(t, () => find.widgetWithText(FilledButton, '이동').evaluate().isNotEmpty);
|
await tapReady(t, find.widgetWithText(FilledButton, '이동'), '되돌리기의 이동 확인 창 [이동]');
|
||||||
await t.tap(find.widgetWithText(FilledButton, '이동'));
|
|
||||||
await until(t, () => File(p.join(left, 'doc.txt')).existsSync() && !File(p.join(right, 'doc.txt')).existsSync());
|
await until(t, () => File(p.join(left, 'doc.txt')).existsSync() && !File(p.join(right, 'doc.txt')).existsSync());
|
||||||
expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello');
|
expect(File(p.join(left, 'doc.txt')).readAsStringSync(), 'hello');
|
||||||
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);
|
expect(File(p.join(right, 'doc.txt')).existsSync(), isFalse);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ void main() {
|
||||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||||
addTearDown(() => i18n.apply('ko', save: false));
|
addTearDown(() => i18n.apply('ko', save: false));
|
||||||
final tmp = testTempDir('jj_xpshot_');
|
final tmp = testTempDir('jj_xpshot_');
|
||||||
|
appTempInto(tmp); // 앱 임시 폴더 (jj_mkvmaker) 도 이 아래로 - 끝에 함께 지움 (관리자 지시 10/20)
|
||||||
final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
|
final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
|
||||||
final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
|
final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
|
||||||
for (final n in ['Show S01', 'Movies']) {
|
for (final n in ['Show S01', 'Movies']) {
|
||||||
|
|
@ -74,6 +75,13 @@ void main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
await settle(20);
|
await settle(20);
|
||||||
|
// 관리자 지시 (10/20): 목록은 따로 읽혀 늦게 올 수 있다 (회귀에서 en 412 가 1.2초 안에 못 받아 실패) - 정해진 시간이 아니라 그 줄이 올 때까지 (최대 약 10초)
|
||||||
|
final load = Stopwatch()..start();
|
||||||
|
for (var i = 0; i < 160 && find.text('Show S01').evaluate().isEmpty; i++) {
|
||||||
|
await settle(1);
|
||||||
|
}
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('목록 받음: $lang $w - 1.2초 뒤 ${load.elapsedMilliseconds}ms 더');
|
||||||
// Show S01 펼치기 · 즐겨찾기 펼치기
|
// Show S01 펼치기 · 즐겨찾기 펼치기
|
||||||
await t.tap(find.text('Show S01').first);
|
await t.tap(find.text('Show S01').first);
|
||||||
await settle();
|
await settle();
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.10.18+1
|
version: 2026.10.20+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.13.4
|
sdk: ^3.13.4
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||||
|
|
||||||
/// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) -
|
/// l (관리자 지시 10/17): FTP · FTPS (dart:io 로 직접). 실제 서버 시험은 tool/ftp_test_server.py (pyftpdlib) -
|
||||||
/// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다
|
/// 시험용 파이썬 (sftp 시험과 같은 venv) 에 pyftpdlib 가 없으면 건너뛴다
|
||||||
|
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
|
||||||
|
const ftpPw = 'ftp-Pw-7Rk2xQ9t';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final hasPy = File(sftpTestPython).existsSync();
|
final hasPy = File(sftpTestPython).existsSync();
|
||||||
|
|
||||||
|
|
@ -76,7 +79,8 @@ void main() {
|
||||||
late String caFp;
|
late String caFp;
|
||||||
|
|
||||||
Future<void> start(String mode, {bool chain = false}) async {
|
Future<void> start(String mode, {bool chain = false}) async {
|
||||||
final pr = await Process.start(sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode, if (chain) '--chain']);
|
final pr = await Process.start(
|
||||||
|
sftpTestPython, ['tool/ftp_test_server.py', '--root', root.path, '--mode', mode, '--password', ftpPw, if (chain) '--chain']);
|
||||||
proc = pr;
|
proc = pr;
|
||||||
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
|
final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream();
|
||||||
pr.stderr.drain<void>();
|
pr.stderr.drain<void>();
|
||||||
|
|
@ -111,7 +115,7 @@ void main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
FtpServer srv({String security = 'explicit', String cert = '', String password = 'pw'}) =>
|
FtpServer srv({String security = 'explicit', String cert = '', String password = ftpPw}) =>
|
||||||
FtpServer(id: 'f1', name: 'test', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert);
|
FtpServer(id: 'f1', name: 'test', host: '127.0.0.1', port: port, user: 'jj', password: password, security: security, certFingerprint: cert);
|
||||||
|
|
||||||
Future<Object?> err(Future<void> Function() f) async {
|
Future<Object?> err(Future<void> Function() f) async {
|
||||||
|
|
@ -247,13 +251,55 @@ void main() {
|
||||||
FtpRegistry.configure([srv(security: 'none')]);
|
FtpRegistry.configure([srv(security: 'none')]);
|
||||||
final src = p.join(tmp.path, 'local', 'scene_src'), want = p.join(tmp.path, 'local', 'scene_rsync'), got = p.join(root.path, 'scene');
|
final src = p.join(tmp.path, 'local', 'scene_src'), want = p.join(tmp.path, 'local', 'scene_rsync'), got = p.join(root.path, 'scene');
|
||||||
final bad = <String>[];
|
final bad = <String>[];
|
||||||
|
// 관리자 지시 (10/20): 가끔 실패해 원인을 보려고, 실패하면 옵션 · 걸린 시간 · 포트 · 예외 · 다른 점과
|
||||||
|
// 보내는 쪽 · 맞추기 전 앱 쪽 · rsync 쪽 · 앱 쪽 목록 (파일은 크기 · 바뀐 시각 ms) 을 한 번에 보인다
|
||||||
|
String listing(String dir) {
|
||||||
|
final root = Directory(dir);
|
||||||
|
if (!root.existsSync()) return ' (없음)';
|
||||||
|
final rows = <String>[];
|
||||||
|
for (final e in root.listSync(recursive: true)) {
|
||||||
|
final rel = p.relative(e.path, from: dir).replaceAll(r'\', '/');
|
||||||
|
if (e is Directory) {
|
||||||
|
rows.add('$rel/');
|
||||||
|
} else {
|
||||||
|
final s = e.statSync();
|
||||||
|
rows.add('$rel ${s.size}B ${s.modified.toIso8601String()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (rows..sort()).map((r) => ' $r').join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
for (final opts in sceneOptions) {
|
for (final opts in sceneOptions) {
|
||||||
|
final t0 = sw.elapsedMilliseconds;
|
||||||
writeScene(src, want);
|
writeScene(src, want);
|
||||||
final w = await realRsyncTree(rsync, src, want, opts);
|
final w = await realRsyncTree(rsync, src, want, opts);
|
||||||
|
final wantList = listing(want);
|
||||||
writeScene(src, got);
|
writeScene(src, got);
|
||||||
await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
|
final beforeList = listing(got);
|
||||||
|
Object? error;
|
||||||
|
try {
|
||||||
|
await FileOps().mirror(src, 'ftp://f1/scene', delete: optionsDelete(opts), filter: RsyncFilter.parse(opts));
|
||||||
|
} catch (e, st) {
|
||||||
|
error = '$e\n$st';
|
||||||
|
}
|
||||||
final d = treeDiff(w, treeOf(got));
|
final d = treeDiff(w, treeOf(got));
|
||||||
if (d.isNotEmpty) bad.add('$opts\n ${d.join('\n ')}');
|
if (d.isNotEmpty || error != null) {
|
||||||
|
bad.add([
|
||||||
|
'옵션: $opts (${sw.elapsedMilliseconds - t0}ms, 시험 시작부터 ${t0}ms, 포트 $port, ${DateTime.now().toIso8601String()})',
|
||||||
|
if (error != null) ' 예외: $error',
|
||||||
|
' 다른 점:',
|
||||||
|
...d.map((x) => ' $x'),
|
||||||
|
' 보내는 쪽:',
|
||||||
|
listing(src),
|
||||||
|
' 맞추기 전 앱 쪽:',
|
||||||
|
beforeList,
|
||||||
|
' rsync 쪽:',
|
||||||
|
wantList,
|
||||||
|
' 앱 쪽:',
|
||||||
|
listing(got),
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
expect(bad, isEmpty, reason: bad.join('\n'));
|
expect(bad, isEmpty, reason: bad.join('\n'));
|
||||||
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
|
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
|
||||||
|
|
@ -285,7 +331,10 @@ void main() {
|
||||||
final bridge = await RemoteStreamBridge.get();
|
final bridge = await RemoteStreamBridge.get();
|
||||||
final u1 = bridge.url('ftp://f1/영상/1화.mkv'), u2 = bridge.url('ftp://f1/영상/2화.mkv');
|
final u1 = bridge.url('ftp://f1/영상/1화.mkv'), u2 = bridge.url('ftp://f1/영상/2화.mkv');
|
||||||
expect(u1, startsWith('http://127.0.0.1:'));
|
expect(u1, startsWith('http://127.0.0.1:'));
|
||||||
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
|
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
|
||||||
|
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
|
||||||
|
expect(u1, isNot(contains(ftpPw)), reason: '주소에 비밀번호 없음');
|
||||||
|
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
|
||||||
Future<(int, String)> get(String url, [String? range]) async {
|
Future<(int, String)> get(String url, [String? range]) async {
|
||||||
final http = HttpClient();
|
final http = HttpClient();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import 'support/sftp_server.dart';
|
||||||
|
|
||||||
/// sftp_next: SFTP 동영상을 받지 않고 이 기기 안의 다리 (127.0.0.1) 로 스트리밍 - 같은 폴더의 다음 동영상 · 그 자막.
|
/// sftp_next: SFTP 동영상을 받지 않고 이 기기 안의 다리 (127.0.0.1) 로 스트리밍 - 같은 폴더의 다음 동영상 · 그 자막.
|
||||||
/// 실제 서버 시험은 tool/sftp_test_server.py (paramiko) - 파이썬이 없으면 건너뛴다 (sftp_test 와 같음)
|
/// 실제 서버 시험은 tool/sftp_test_server.py (paramiko) - 파이썬이 없으면 건너뛴다 (sftp_test 와 같음)
|
||||||
|
/// 시험 서버 비밀번호 - 무작위 주소에 우연히 들어가지 않게 길고 드물게 (관리자 지시 10/20, 28 이 찾음)
|
||||||
|
const sftpPw = 'sftp-Pw-3Lm8vZ2w';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final hasServer = sftpTestServerAvailable;
|
final hasServer = sftpTestServerAvailable;
|
||||||
late Directory tmp;
|
late Directory tmp;
|
||||||
|
|
@ -26,10 +29,10 @@ void main() {
|
||||||
if (!hasServer) return;
|
if (!hasServer) return;
|
||||||
tmp = Directory.systemTemp.createTempSync('jj_sftpnext_');
|
tmp = Directory.systemTemp.createTempSync('jj_sftpnext_');
|
||||||
root = Directory(p.join(tmp.path, 'root'))..createSync();
|
root = Directory(p.join(tmp.path, 'root'))..createSync();
|
||||||
final s = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'));
|
final s = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: sftpPw);
|
||||||
server = s;
|
server = s;
|
||||||
SftpRegistry.configure([
|
SftpRegistry.configure([
|
||||||
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: 'pw', hostKeyFingerprint: s.fingerprint),
|
SftpServer(id: 's1', name: 'test', host: '127.0.0.1', port: s.port, user: 'jj', password: sftpPw, hostKeyFingerprint: s.fingerprint),
|
||||||
]);
|
]);
|
||||||
Directory(p.join(root.path, '영상')).createSync();
|
Directory(p.join(root.path, '영상')).createSync();
|
||||||
File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789');
|
File(p.join(root.path, '영상', '1화.mkv')).writeAsStringSync('0123456789');
|
||||||
|
|
@ -93,7 +96,10 @@ void main() {
|
||||||
expect(u1, endsWith('/${Uri.encodeComponent('1화.mkv')}'), reason: 'mpv 가 확장자로 형식을 알게');
|
expect(u1, endsWith('/${Uri.encodeComponent('1화.mkv')}'), reason: 'mpv 가 확장자로 형식을 알게');
|
||||||
expect(u1, isNot(equals(u2)));
|
expect(u1, isNot(equals(u2)));
|
||||||
expect(bridge.url('sftp://s1/영상/1화.mkv'), u1, reason: '같은 경로는 같은 주소');
|
expect(bridge.url('sftp://s1/영상/1화.mkv'), u1, reason: '같은 경로는 같은 주소');
|
||||||
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
|
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
|
||||||
|
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
|
||||||
|
expect(u1, isNot(contains(sftpPw)), reason: '주소에 비밀번호 없음');
|
||||||
|
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
|
||||||
|
|
||||||
var (code, h, body) = await get(u1);
|
var (code, h, body) = await get(u1);
|
||||||
expect((code, body), (200, '0123456789'));
|
expect((code, body), (200, '0123456789'));
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,8 @@ void main() {
|
||||||
SmbRegistry.configure(const []);
|
SmbRegistry.configure(const []);
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'pw');
|
// 길고 드문 비밀번호 - 다리 주소의 무작위 표에 우연히 들어가지 않게 (관리자 지시 10/20, 28 이 찾음)
|
||||||
|
const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'smb-Pw-5Qh9kT4r');
|
||||||
|
|
||||||
test('smb:// 로 폴더 · 쓰기 (1MB 조각) · 읽기 · 목록 · 이름 바꾸기 (덮어쓰기) · 지우기 · 화면 이름', () async {
|
test('smb:// 로 폴더 · 쓰기 (1MB 조각) · 읽기 · 목록 · 이름 바꾸기 (덮어쓰기) · 지우기 · 화면 이름', () async {
|
||||||
SmbRegistry.configure([server]);
|
SmbRegistry.configure([server]);
|
||||||
|
|
@ -196,10 +197,10 @@ void main() {
|
||||||
final secrets = MemorySecretStore();
|
final secrets = MemorySecretStore();
|
||||||
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
|
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
|
||||||
await store.save(AppSettings()..smbServers = [server]);
|
await store.save(AppSettings()..smbServers = [server]);
|
||||||
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('"pw"')));
|
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('smb-Pw-5Qh9kT4r')));
|
||||||
expect(secrets.values['smb.pw.m1'], 'pw');
|
expect(secrets.values['smb.pw.m1'], 'smb-Pw-5Qh9kT4r');
|
||||||
final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single;
|
final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single;
|
||||||
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'pw'));
|
expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'smb-Pw-5Qh9kT4r'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('경로 입력의 smb:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () {
|
test('경로 입력의 smb:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () {
|
||||||
|
|
@ -329,7 +330,10 @@ void main() {
|
||||||
final bridge = await RemoteStreamBridge.get();
|
final bridge = await RemoteStreamBridge.get();
|
||||||
final u1 = bridge.url('smb://m1/영상/1화.mkv'), u2 = bridge.url('smb://m1/영상/2화.mkv');
|
final u1 = bridge.url('smb://m1/영상/1화.mkv'), u2 = bridge.url('smb://m1/영상/2화.mkv');
|
||||||
expect(u1, startsWith('http://127.0.0.1:'));
|
expect(u1, startsWith('http://127.0.0.1:'));
|
||||||
expect(u1, isNot(contains('pw')), reason: '주소에 비밀번호 없음');
|
// 관리자 지시 (10/20, 28 이 찾음): 시험 비밀번호가 두 글자 "pw" 라 다리 주소의 무작위 표 (예 …-4pwvUR-…) 에
|
||||||
|
// 우연히 들어가 "주소에 비밀번호 없음" 이 가끔 실패했다 (비밀번호가 샌 것이 아님) - 길고 드문 비밀번호로 보고, 주소의 사용자 정보도 따로
|
||||||
|
expect(u1, isNot(contains(server.password)), reason: '주소에 비밀번호 없음');
|
||||||
|
expect(Uri.parse(u1).userInfo, isEmpty, reason: '주소에 사용자 정보 없음');
|
||||||
Future<(int, String)> get(String url, [String? range]) async {
|
Future<(int, String)> get(String url, [String? range]) async {
|
||||||
final http = HttpClient();
|
final http = HttpClient();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@
|
||||||
# 새 버전 확인도 "최신과 같음" 이 된다. 시험판은 flutter build apk --build-number 9xx 로 빌드한다. 정식 판 APK 는 -Release.
|
# 새 버전 확인도 "최신과 같음" 이 된다. 시험판은 flutter build apk --build-number 9xx 로 빌드한다. 정식 판 APK 는 -Release.
|
||||||
# - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적:
|
# - 순번 900 이상의 v 태그 (시험판 Pre-release, 예: v2026.10.18_977) 는 정식 판으로 보지 않는다 (관리자 지시 10/19, 28 지적:
|
||||||
# 시험판에 태그를 단 뒤 같은 APK 를 다시 검사하면 "정식 판 번호와 같음" 으로 거짓 실패). 정식 판 (-Release) 인데 순번이 900 이상이면 실패.
|
# 시험판에 태그를 단 뒤 같은 APK 를 다시 검사하면 "정식 판 번호와 같음" 으로 거짓 실패). 정식 판 (-Release) 인데 순번이 900 이상이면 실패.
|
||||||
|
# - versionCode 의 모양 (관리자 지시 10/20, 15 제안): versionCode = 판 이름 날짜 (2026.10.18 → 261018) × 1000 + 순번 (000~999).
|
||||||
|
# 987 을 --build-number 261018987 로 빌드해 versionCode 522036987 (날짜가 두 번 더해짐) 이 나왔는데 통과했다 - 사용자 기기에 깔리면
|
||||||
|
# 그 뒤의 정식 판 (261018xxx) 이 "낮은 버전" 이라 업데이트가 막힌다. build.gradle.kts 가 날짜를 붙이므로 APK 의 --build-number 는 순번만 (예 987).
|
||||||
|
# 정식 판 (-Release) 은 순번 < 900, 시험판은 순번 >= 900.
|
||||||
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_apk.ps1 -Apk build\app\outputs\flutter-apk\app-release.apk [-Release]
|
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_apk.ps1 -Apk build\app\outputs\flutter-apk\app-release.apk [-Release]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)][string]$Apk,
|
[Parameter(Mandatory = $true)][string]$Apk,
|
||||||
|
|
@ -74,6 +78,17 @@ $same = @(foreach ($t in $tags) {
|
||||||
if ($t -match '^v(\d+)\.(\d+)\.(\d+)_(\d+)$' -and [long]$Matches[4] -lt 900 -and
|
if ($t -match '^v(\d+)\.(\d+)\.(\d+)_(\d+)$' -and [long]$Matches[4] -lt 900 -and
|
||||||
([long]$Matches[1] % 100) * 10000000 + [long]$Matches[2] * 100000 + [long]$Matches[3] * 1000 + [long]$Matches[4] -eq $code) { $t }
|
([long]$Matches[1] % 100) * 10000000 + [long]$Matches[2] * 100000 + [long]$Matches[3] * 1000 + [long]$Matches[4] -eq $code) { $t }
|
||||||
})
|
})
|
||||||
|
# versionCode 의 모양: 판 이름의 날짜 × 1000 + 순번 (build.gradle.kts 와 같은 셈)
|
||||||
|
if ($vname -match '^(\d+)\.(\d+)\.(\d+)') {
|
||||||
|
$day = ([long]$Matches[1] % 100) * 10000 + [long]$Matches[2] * 100 + [long]$Matches[3]
|
||||||
|
if ([math]::Floor($code / 1000) -ne $day) {
|
||||||
|
$bad += "versionCode $code 가 판 이름 $vname 의 날짜 ($day × 1000 + 순번) 와 맞지 않음 - APK 는 --build-number 에 순번만 (예 987, 날짜를 붙이지 않음)"
|
||||||
|
} elseif (-not $Release -and ($code % 1000) -lt 900) {
|
||||||
|
$bad += "시험판인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판은 --build-number 9xx (정식 판이면 -Release)"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$bad += "판 이름을 읽지 못함: '$vname' (년.월.일 이어야 함)"
|
||||||
|
}
|
||||||
if ($Release -and ($code % 1000) -ge 900) {
|
if ($Release -and ($code % 1000) -ge 900) {
|
||||||
$bad += "정식 판 (-Release) 인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판 번호입니다 (정식 판은 --build-number 없이, pubspec 의 +순번)"
|
$bad += "정식 판 (-Release) 인데 versionCode $code ($vname) 의 순번이 $($code % 1000) - 시험판 번호입니다 (정식 판은 --build-number 없이, pubspec 의 +순번)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
# - 정식 판 (-Release): 번호가 1000 보다 작아야 한다 (시험 번호가 정식 zip 에 들어가지 않게).
|
# - 정식 판 (-Release): 번호가 1000 보다 작아야 한다 (시험 번호가 정식 zip 에 들어가지 않게).
|
||||||
# - 정식 판 (-Release) 의 순번이 900 이상이면 실패 (900 이상은 시험판 순번 - check_apk.ps1 과 같은 규칙, 관리자 지시 10/19).
|
# - 정식 판 (-Release) 의 순번이 900 이상이면 실패 (900 이상은 시험판 순번 - check_apk.ps1 과 같은 규칙, 관리자 지시 10/19).
|
||||||
# 이 검사는 git 태그를 보지 않으므로 시험판 태그를 단 뒤 다시 검사해도 거짓 실패가 없다.
|
# 이 검사는 git 태그를 보지 않으므로 시험판 태그를 단 뒤 다시 검사해도 거짓 실패가 없다.
|
||||||
|
# - 시험판 번호의 날짜는 판 이름 (pubspec) 의 날짜 (관리자 지시 10/20, d3 판단): 2026.10.18+261018987 은 맞고 +261020985 (빌드한 날) 는 실패.
|
||||||
|
# 근거: ① APK versionCode (판 이름 날짜 × 1000 + 순번) 와 같은 번호가 되어 두 판을 같은 이름으로 견줄 수 있다.
|
||||||
|
# ② 앱의 판 견주기 (compareVersions) 는 + 뒤 번호 전체를 견준다 - 날짜가 섞이면 같은 판 이름에서 985 (261020985) 가
|
||||||
|
# 987 (261018987) 보다 새것으로 보인다. 순번은 900 이상 (시험판).
|
||||||
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_windows_build.ps1 -Dir <배포 폴더 (Lib 가 있는 곳)> [-Release]
|
# 사용: powershell -ExecutionPolicy Bypass -File tool\check_windows_build.ps1 -Dir <배포 폴더 (Lib 가 있는 곳)> [-Release]
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)][string]$Dir,
|
[Parameter(Mandatory = $true)][string]$Dir,
|
||||||
|
|
@ -21,6 +25,12 @@ if ($Release) {
|
||||||
if ($build -ge 900) { throw "정식 판인데 시험 번호입니다: $ver (화면 v$shown). --build-number 없이 (pubspec 의 +순번) 빌드하세요" }
|
if ($build -ge 900) { throw "정식 판인데 시험 번호입니다: $ver (화면 v$shown). --build-number 없이 (pubspec 의 +순번) 빌드하세요" }
|
||||||
} elseif ($build -lt 1000) {
|
} elseif ($build -lt 1000) {
|
||||||
throw ("시험판인데 정식 판 번호입니다: $ver (화면 v$shown - 정식 판과 같게 보임).`n" +
|
throw ("시험판인데 정식 판 번호입니다: $ver (화면 v$shown - 정식 판과 같게 보임).`n" +
|
||||||
"flutter build windows --release --build-number <년월일><순번 3자리> (예: 261012972) 로 다시 빌드하세요")
|
"flutter build windows --release --build-number <판 이름 날짜 6자리><순번 3자리> (예: 261012972) 로 다시 빌드하세요")
|
||||||
|
} else {
|
||||||
|
$day = ([long]$Matches[1] % 100) * 10000 + [long]$Matches[2] * 100 + [long]$Matches[3]
|
||||||
|
if ([math]::Floor($build / 1000) -ne $day -or ($build % 1000) -lt 900) {
|
||||||
|
throw ("시험판 번호가 판 이름과 맞지 않습니다: $ver - 번호는 판 이름의 날짜 ($day) + 순번 900~999 여야 합니다 (빌드한 날이 아님).`n" +
|
||||||
|
"flutter build windows --release --build-number $day<순번 3자리> (예: ${day}987) 로 다시 빌드하세요")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"통과: $ver (화면 v$shown) · $(if ($Release) { '정식 판' } else { '시험판 - 정식 판 번호와 다름' })"
|
"통과: $ver (화면 v$shown) · $(if ($Release) { '정식 판' } else { '시험판 - 정식 판 번호와 다름' })"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue