diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 16726a3..c8d29be 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -2449,7 +2449,7 @@ "자막 파일 선택": "Choose subtitle files", "자막 파일 추가": "Add subtitle files", "자막 파일을 받을 수 없습니다 ({0})": "Cannot download the subtitle file ({0})", - "자막 편집 · {0}": "Edit subtitles · {0}", + "자막 편집": "Edit subtitles", "자막을 불러올 수 없습니다: {0}": "Cannot load subtitles: {0}", "자막이 비어 있습니다.": "The subtitles are empty.", "자막이 없습니다.": "No subtitles.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 06098d9..321e1a0 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -2449,7 +2449,7 @@ "자막 파일 선택": "字幕ファイルを選択", "자막 파일 추가": "字幕ファイルを追加", "자막 파일을 받을 수 없습니다 ({0})": "字幕ファイルをダウンロードできません({0})", - "자막 편집 · {0}": "字幕編集・{0}", + "자막 편집": "字幕編集", "자막을 불러올 수 없습니다: {0}": "字幕を読み込めません: {0}", "자막이 비어 있습니다.": "字幕が空です。", "자막이 없습니다.": "字幕がありません。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 46a3752..eb8face 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -2449,7 +2449,7 @@ "자막 파일 선택": "选择字幕文件", "자막 파일 추가": "添加字幕文件", "자막 파일을 받을 수 없습니다 ({0})": "无法下载字幕文件({0})", - "자막 편집 · {0}": "编辑字幕 · {0}", + "자막 편집": "编辑字幕", "자막을 불러올 수 없습니다: {0}": "无法加载字幕: {0}", "자막이 비어 있습니다.": "字幕为空。", "자막이 없습니다.": "没有字幕。", diff --git a/integration_test/windows_ui2_shot_test.dart b/integration_test/windows_ui2_shot_test.dart new file mode 100644 index 0000000..341c70f --- /dev/null +++ b/integration_test/windows_ui2_shot_test.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/subtitle_editor_controller.dart'; +import 'package:jj_mkvmaker/core/encode_options.dart'; +import 'package:jj_mkvmaker/core/languages.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/core/srt.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/subtitle_editor_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:jj_mkvmaker/ui/video_adjust_dialog.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 관리자 지시 (10/21, 28 찾음): ① 화면 · 색 보정 창 - 고른 비율 칩 ("세로 9:16") 은 ✓ 만 (그림 위에 체크가 겹치지 않게) +/// ② 자막 편집 화면 - 제목은 "자막 편집", 파일 이름은 그 아래 한 줄 (가운데를 줄여 끝 .srt 가 보이게). ko · ja × 412 · 960 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('화면 · 색 보정 칩 · 자막 편집 제목 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_ui2shot_'); + appTempInto(tmp); + final video = p.join(tmp.path, '아주 긴 이름의 시험용 동영상 파일 - 제1화 고화질 버전.mp4'); + final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe'); + final r = await t.runAsync(() => Process.run(ffmpeg, [ + '-y', '-v', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=10:duration=2', // + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', video, + ])); + expect(r!.exitCode, 0, reason: '${r.stderr}'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool.locate(), storage: DesktopStorageService())); + await t.runAsync(() async { + await c.init(); + await c.addVideos([video]); + }); + final shotKey = GlobalKey(); + final navKey = GlobalKey(); + await t.pumpWidget(MaterialApp( + theme: buildTheme(), + debugShowCheckedModeBanner: false, + navigatorKey: navKey, + builder: (context, child) => RepaintBoundary(key: shotKey, child: child), + home: const Scaffold(body: SizedBox.expand()), + )); + Future settle([int n = 10]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 80))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(3); + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'ui2_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + // ① 화면 · 색 보정 - "세로 9:16" 을 고른다 + unawaited(showVideoAdjust(navKey.currentContext!, c)); + await settle(20); + final portrait = find.text(FrameChoice.portrait.label); + await t.ensureVisible(portrait); + await t.tap(portrait); + await settle(); + final chip = find.ancestor(of: portrait, matching: find.byType(ChoiceChip)); + expect(t.widget(chip).selected, isTrue); + expect(t.widget(chip).showCheckmark, isFalse, reason: '그림 위에 체크가 겹치지 않게'); + expect(find.descendant(of: chip, matching: find.byIcon(Icons.check)), findsOneWidget, reason: '✓ 만'); + expect(find.descendant(of: chip, matching: find.byIcon(Icons.crop_portrait)), findsNothing); + await shot('1_adjust'); + navKey.currentState!.popUntil((r) => r.isFirst); + await settle(); + + // ② 자막 편집 - 긴 파일 이름 + final v = c.videos.single; + final s = SubtitleEntry.external(path: p.join(tmp.path, '아주 긴 이름의 시험용 동영상 파일 - 제1화 고화질 버전.ko.srt'), language: languageOf('ko')); + v.subtitles.add(s); + final editor = SubtitleEditorController(parseSrt('1\n00:00:01,000 --> 00:00:02,000\n안녕\n'), saveCharset: 'UTF-8'); + unawaited(navKey.currentState!.push(MaterialPageRoute(builder: (_) => SubtitleEditorPage(app: c, video: v, entry: s, editor: editor)))); + await settle(12); + expect(find.text(tr('자막 편집')), findsOneWidget, reason: '제목은 "자막 편집" 만'); + final name = find.textContaining('.srt'); + expect(name, findsOneWidget, reason: '파일 이름은 아래 한 줄 - 끝 (.srt) 이 보임'); + await shot('2_editor'); + expect(t.widget(name).data!.endsWith('.srt'), isTrue, reason: '${t.widget(name).data}'); + }); + } + } +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index a608b2f..0f4e4b9 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -1504,6 +1504,8 @@ class SettingsStore { }; /// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움) + /// 규칙 (관리자 10/21): 새 종류의 비밀 값은 지금 꼴 ([secretForms]) 과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 로 만든다 - + /// 겹치면 그 꼴을 아는 예전 판이 저장할 때 "없어진 항목" 으로 보고 지운다. 새 꼴은 [secretForms] 에도 더한다 static Map _secretsOf(AppSettings s) => !s.rememberPasswords ? const {} : { for (final e in _osKeys.entries) if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key), @@ -1778,8 +1780,15 @@ class SettingsStore { /// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를 /// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽). /// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다 - static bool ownsSecret(String k) => _osKeys.containsValue(k) || k == _davServers || _ownForm.hasMatch(k); - static final _ownForm = RegExp(r'^(dav\.pw|sftp\.pw|sftp\.key|sftp\.kp|smb\.pw|ftp\.pw|ai\.key)\..+$'); + /// 규칙 (관리자 10/21): 새 종류의 비밀 값은 아래 꼴들과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 을 쓴다 (겹치면 예전 판이 저장할 때 지움) + static bool ownsSecret(String k) => secretForms.any((f) => f.hasMatch(k)); + + /// 이 판이 만드는 비밀 열쇠의 꼴 ([_secretsOf] 와 같아야 한다 - 시험이 모든 열쇠가 정확히 한 꼴에만 맞는지 본다) + static final secretForms = [ + for (final k in _osKeys.values) RegExp('^${RegExp.escape(k)}\$'), + RegExp('^${RegExp.escape(_davServers)}\$'), + for (final pre in const ['dav.pw', 'sftp.pw', 'sftp.key', 'sftp.kp', 'smb.pw', 'ftp.pw', 'ai.key']) RegExp('^${RegExp.escape(pre)}' r'\..+$'), + ]; /// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서 /// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다. diff --git a/lib/ui/subtitle_editor_page.dart b/lib/ui/subtitle_editor_page.dart index e96e239..787035f 100644 --- a/lib/ui/subtitle_editor_page.dart +++ b/lib/ui/subtitle_editor_page.dart @@ -12,6 +12,7 @@ import '../core/text_codec.dart'; import '../services/preview_player.dart'; import 'app_actions.dart'; import 'key_hint.dart'; +import 'path_label.dart' show middleEllipsis; import 'theme.dart'; import 'timeline.dart'; import '../l10n/tr.dart'; @@ -487,12 +488,21 @@ class _SubtitleEditorPageState extends State { actions: const AppActions(), middle: Row( children: [ + // 관리자 지시 (10/21, 28 찾음): 960 에서도 "자막 편집 · …" 로 잘려 파일 이름이 안 보였다 - 제목은 "자막 편집" 만, + // 파일 이름은 그 아래 한 줄 (가운데를 줄여 앞 · 끝 (.srt) 이 보이게, 길게 누르거나 마우스를 올리면 전체) Expanded( - child: Text( - trf('자막 편집 · {0}', [_entry.displayName]), - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), - ), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('자막 편집'), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + LayoutBuilder(builder: (context, box) { + final style = TextStyle(fontSize: 12, color: JjColors.textDim); + return Tooltip( + message: _entry.displayName, + child: Text(middleEllipsis(context, _entry.displayName, style, box.maxWidth), + maxLines: 1, overflow: TextOverflow.clip, style: style), + ); + }), + ]), ), // 폭이 좁으면 (휴대폰) 도구 줄을 밀어서 본다 - 설정 · 종료 버튼은 늘 오른쪽 끝에 Flexible( @@ -581,16 +591,37 @@ class _HeaderRow extends StatelessWidget { Widget build(BuildContext context) => Container( color: JjColors.panel, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row(children: [ - SizedBox(width: 48, child: Text('#', style: _hdr)), - SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)), - SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)), - Expanded(child: Text(tr('내용'), style: _hdr)), - SizedBox(width: 96), - ]), + child: LayoutBuilder(builder: (context, box) { + if (!_cueNarrow(box.maxWidth)) { + return Row(children: [ + SizedBox(width: 48, child: Text('#', style: _hdr)), + SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)), + SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)), + Expanded(child: Text(tr('내용'), style: _hdr)), + SizedBox(width: 96), + ]); + } + // F9: 좁을 때 - 위 # · 시작 · 끝, 아래 내용 (줄과 같은 두 단) + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + SizedBox(width: _narrowNo, child: Text('#', style: _hdr)), + SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)), + SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)), + ]), + Padding(padding: const EdgeInsets.only(left: _narrowNo), child: Text(tr('내용'), style: _hdr)), + ]); + }), ); } +/// 관리자 지시 (10/21, 15 찾음 F9): 자막 편집 표는 # · 시작 · 끝 · 단추 칸 폭이 정해져 있어 (48 + 130 + 130 + 96) 폰 세로 (412) 에서 +/// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝 · 단추, 아래: 내용). 시간 칸은 줄이지 않는다 (편집이 어려움) +bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200; + +/// 두 단일 때 # 칸 · 단추 하나의 폭 (위 단이 412 에 들게: 28 + 130 + 130 + 2 × 40 = 368) +const _narrowNo = 28.0; +const _narrowButton = 40.0; + final _hdr = TextStyle(fontSize: 12, color: JjColors.textDim); class _CueRow extends StatefulWidget { @@ -666,11 +697,10 @@ class _CueRowState extends State<_CueRow> { color: widget.active ? JjColors.accent : Colors.transparent, width: 3), ), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 48, + child: LayoutBuilder(builder: (context, box) { + final narrow = _cueNarrow(box.maxWidth); + final number = SizedBox( + width: narrow ? _narrowNo : 48, child: InkWell( onTap: widget.onSelect, child: Padding( @@ -681,11 +711,10 @@ class _CueRowState extends State<_CueRow> { color: widget.selected ? JjColors.accent : JjColors.textDim)), ), ), - ), - SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true)), - SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false)), - Expanded( - child: TextField( + ); + final start = SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true)); + final end = SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false)); + final text = TextField( controller: _text, minLines: 1, maxLines: 4, @@ -693,27 +722,42 @@ class _CueRowState extends State<_CueRow> { decoration: const InputDecoration(isDense: true, border: InputBorder.none), onTap: () => e.select(widget.cue), onChanged: (t) => e.setText(widget.cue, t), - ), + ); + // 좁으면 단추 누를 자리를 40 으로 (기본 48) - 위 단이 폭에 들게 + final small = narrow ? const BoxConstraints(minWidth: _narrowButton, minHeight: _narrowButton) : null; + final buttons = [ + IconButton( + tooltip: tr('아래에 줄 추가'), + iconSize: 18, + constraints: small, + padding: narrow ? EdgeInsets.zero : null, + icon: Icon(Icons.add, color: JjColors.textDim), + onPressed: () => e.insertAfter(widget.index), ), - SizedBox( - width: 96, - child: Row(children: [ - IconButton( - tooltip: tr('아래에 줄 추가'), - iconSize: 18, - icon: Icon(Icons.add, color: JjColors.textDim), - onPressed: () => e.insertAfter(widget.index), - ), - IconButton( - tooltip: tr('줄 삭제'), - iconSize: 18, - icon: Icon(Icons.delete_outline, color: JjColors.textDim), - onPressed: () => e.removeAt(widget.index), - ), - ]), + IconButton( + tooltip: tr('줄 삭제'), + iconSize: 18, + constraints: small, + padding: narrow ? EdgeInsets.zero : null, + icon: Icon(Icons.delete_outline, color: JjColors.textDim), + onPressed: () => e.removeAt(widget.index), ), - ], - ), + ]; + if (!narrow) { + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + number, + start, + end, + Expanded(child: text), + SizedBox(width: 96, child: Row(children: buttons)), + ]); + } + // F9: 좁을 때 두 단 - 위 # · 시작 · 끝 · 단추, 아래 내용 (# 칸만큼 들여) + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end, const Spacer(), ...buttons]), + Padding(padding: const EdgeInsets.only(left: _narrowNo), child: text), + ]); + }), ); } diff --git a/lib/ui/video_adjust_dialog.dart b/lib/ui/video_adjust_dialog.dart index 99f498f..56cd908 100644 --- a/lib/ui/video_adjust_dialog.dart +++ b/lib/ui/video_adjust_dialog.dart @@ -156,7 +156,11 @@ class _VideoAdjustState extends State<_VideoAdjust> { /// 뷰어: 조정 후 장면 (누르고 있으면 조정 전) Widget _viewer() { final img = _showBefore ? _before : _after; - return Container( + // 관리자 지시 (10/21, 15 찾음 F10): 좁으면 (폰 세로) 아래의 파일 이름 · 시각 글이 [누르고 있으면 조정 전] 위에 겹쳤다 - + // 좁을 때만 그 글을 미리보기 위쪽으로 (단추 자리는 그대로) + return LayoutBuilder(builder: (context, box) { + final narrow = box.maxWidth < 560; + return Container( color: Colors.black, child: Stack(children: [ Positioned.fill( @@ -193,13 +197,16 @@ class _VideoAdjustState extends State<_VideoAdjust> { const Positioned(right: 12, top: 12, child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))), if (v != null) Positioned( - right: 8, - bottom: 8, + left: narrow ? 8 : null, + right: narrow ? 36 : 8, // 위 오른쪽의 돌아가는 표시 자리는 비운다 + top: narrow ? 8 : null, + bottom: narrow ? null : 8, child: Text('${v!.fileName} · ${_fmt(_at)}', - style: const TextStyle(fontSize: 11, color: Colors.white70)), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11, color: Colors.white70)), ), ]), ); + }); } static String _fmt(Duration d) => @@ -225,7 +232,9 @@ class _VideoAdjustState extends State<_VideoAdjust> { Wrap(spacing: 6, runSpacing: 6, children: [ for (final f in FrameChoice.values) ChoiceChip( - avatar: Icon(frameIcons[f], size: 16), + // 관리자 지시 (10/21, 28 찾음): 고르면 체크 표가 그림 위에 겹쳐 그려졌다 - 다른 고른 칩처럼 ✓ 만 (그림 자리에) + avatar: Icon(s.frame == f ? Icons.check : frameIcons[f], size: 16), + showCheckmark: false, label: Text(f.label), selected: s.frame == f, onSelected: (_) => _set(s.copyWith(frame: f)), diff --git a/test/ftp_test.dart b/test/ftp_test.dart index 0dd8ab8..950768c 100644 --- a/test/ftp_test.dart +++ b/test/ftp_test.dart @@ -294,6 +294,25 @@ void main() { addTearDown(() { if (!finished) writeFail('끝나지 않음 (시간 초과 · 예외)'); }); + // 관리자 지시 (10/21, 28 원문): 전체 시험 부하에서 시험 서버 (pyftpdlib) 가 처음 연결에 16초 동안 답하지 않아 실패했다 (앱의 "답하지 않음" 처리는 맞음). + // 처음 연결 (서버 준비) 만 한 번 다시 시도한다 - 맞추기 도중의 실패 · 비교 결과가 다른 것은 다시 시도하지 않는다 + Future warm() async { + final w = FtpRemote(srv(security: 'none')); + try { + await w.list('/'); + } finally { + w.close(); + } + } + + try { + await warm(); + } on FtpException catch (e) { + if (e.kind != FtpFailure.unreachable) rethrow; + progress.add('처음 연결 실패 (${sw.elapsedMilliseconds}ms, $e) - 다시 시도함'); + await Future.delayed(const Duration(seconds: 2)); + await warm(); + } for (final opts in sceneOptions) { final t0 = sw.elapsedMilliseconds; progress.add('$opts - 시작 ${t0}ms'); @@ -329,6 +348,8 @@ void main() { } finished = true; if (bad.isNotEmpty) writeFail('결과가 rsync 와 다름 ${bad.length}개'); + // 다시 시도해서 통과한 것도 기록에 남긴다 (서버 준비가 늦었던 때를 알 수 있게) + if (bad.isEmpty && progress.any((x) => x.contains('다시 시도함'))) writeFail('통과 (처음 연결을 다시 시도함)'); expect(bad, isEmpty, reason: bad.join('\n')); }, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5))); diff --git a/test/settings_store_test.dart b/test/settings_store_test.dart index 13038bc..48dea72 100644 --- a/test/settings_store_test.dart +++ b/test/settings_store_test.dart @@ -2,8 +2,11 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/ftp.dart'; import 'package:jj_mkvmaker/core/sftp.dart'; +import 'package:jj_mkvmaker/core/smb.dart'; import 'package:jj_mkvmaker/core/webdav.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart' show AiService; import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:path/path.dart' as p; @@ -62,6 +65,37 @@ void main() { expect(secrets.values.keys.where((k) => !SettingsStore.ownsSecret(k)), ['sec.master']); }); + test('관리자 규칙 (10/21): 이 판이 만드는 비밀 열쇠는 모두 secretForms 의 꼴 중 정확히 하나에만 맞고, 꼴마다 쓰인다 (새 종류는 겹치지 않는 새 머리글로)', () async { + final tmp = Directory.systemTemp.createTempSync('jj_secret_rule_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final secrets = MemorySecretStore()..values['sec.master'] = 'hash'; + final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + final s = await store.load() + ..openSubtitlesKey = 'k' + ..openSubtitlesUser = 'u' + ..openSubtitlesPassword = 'op' + ..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] + ..sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'KEY', keyPassphrase: 'kp')] + ..smbServers = [const SmbServer(id: 'm1', name: 'n', host: 'h', share: 'v', password: 'mp')] + ..ftpServers = [const FtpServer(id: 'f1', name: 'n', host: 'h', password: 'fp')] + ..aiServices = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]; + await store.save(s); + final made = secrets.values.keys.where((k) => !k.startsWith('sec.')).toList()..sort(); + expect(made, hasLength(11), reason: '$made'); // os 3 · dav.servers · dav.pw · sftp.pw · key · kp · smb.pw · ftp.pw · ai.key + final used = {}; + for (final k in made) { + final hits = SettingsStore.secretForms.where((f) => f.hasMatch(k)).toList(); + expect(hits, hasLength(1), reason: '$k 이 맞는 꼴: ${hits.map((f) => f.pattern)}'); + used.addAll(hits); + } + expect(used, hasLength(SettingsStore.secretForms.length), reason: '안 쓰인 꼴: ${SettingsStore.secretForms.where((f) => !used.contains(f)).map((f) => f.pattern)}'); + // 마스터 · 최상 (sec.) 은 설정의 꼴이 아니다 (설정 저장이 건드리지 않음) + expect(SettingsStore.ownsSecret('sec.master'), isFalse); + // 같은 머리글의 새 종류 (예: dav.token.x) 는 이 판의 꼴이 아니다 - 그래서 지우지 않지만, 새 종류는 새 머리글 (dav2.) 을 쓰는 것이 규칙 + expect(SettingsStore.ownsSecret('dav.token.x'), isFalse); + expect(SettingsStore.ownsSecret('dav.pwx'), isFalse, reason: '꼴의 점 (.) 이 아무 글자가 되지 않게'); + }); + test('깨진 설정 파일: 백업으로 되살리고, 깨진 파일은 보관 · 알림 정보', () async { final store = SettingsStore(file); await store.save(AppSettings()..homeUrl = 'https://good.example');