diff --git a/docs/BOARD.md b/docs/BOARD.md index c798b727..261e65dc 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -95,6 +95,7 @@ | ON-3 设置页 ffmpeg 状态行:仿安全存储探测模式;未找到=平台化指引(winget/brew+INKFRAME_FFMPEG),mac/win 外复用 exportVideoFfmpegMissing 防双源;重进设置页即重探 | #196 | | ON-4 网络错误文案走查 + i18n pass:三轴并行审读(错误/一致性/僵尸);45 键修订(错误归因与下一步动作、术语统一工作室/提示词/服务商/图片、省略号统一);删 canvasNodeType* 6 僵尸键 | #197 | | GAP-7 Inspector 测试欠账收口(预设点选应用+成本文案精确断言)+ ON-5 五屏空态 golden(Studio empty/error、Canvas empty、Gallery empty、Settings;ubuntu 铸线)——第 10 条(ON-3/4/GAP-7/ON-5)收官;**余 GAP-1 整卡、GAP-3 余量未清**(评审 P1 纠偏:勿宣「全部收官」) | #198 | +| GAP-3 余量收口(24 站点审计:方向读错并入横幅链;raw toString 上屏收敛——设置页探测诊断行为有意例外;base style 编辑器读错中止防空覆盖(评审 P2-3);InkErrorBanner.onRetry 死参数删除;**InkAsyncSlot 判 YAGNI**——列表槽位 LB-06 已全收口) | #199 | ## M1 补遗(审计发现的悬空项) @@ -130,7 +131,7 @@ | **build_runner 全量构建损坏**(analyzer 7.4.5 无法序列化 Dart 3.11 dot-shorthand,riverpod_generator 崩溃挂死;靠 asset graph 缓存掩盖,定向 `--build-filter` 可用) | 🅿️ | **调研已完成**([BLOCKERS-2026-07-06.md](BLOCKERS-2026-07-06.md) §2):唯一瓶颈 freezed 3.2.5 与 riverpod_generator 4.0.4 的 analyzer 约束相斥,freezed 3.2.6 stable 一出即与 Riverpod 3 迁移合并立项(同时解掉 custom_lint 卡点,见 §1);盯 freezed#1353 | | M2 Inspector 区 widget 级测试——参考图区/角色区/失败提示已补(PR #138),预设点选应用与成本文案断言随 GAP-7 收口 | ✅ | #198:点选→字段+落库双断言;perCall 成本精确文案 | | characters / prompt_presets 仓储真库 CRUD 集成测试 | 🅿️ | 仅作 UoW 装配件出现;对齐 postgres_repositories_integration_test | -| Inspector/网格 AsyncValue error 态吞没(镜像模式统一改 `.when`) | ✅ | LB-06/#166:batch 网格/结果 Inspector/入边区/预设/角色区/画布边泳道 error 横幅收口;GAP-3 余量(InkAsyncSlot 共享件、library_sidebar 等残站点)仍归 GAP-3 卡 | +| Inspector/网格 AsyncValue error 态吞没(镜像模式统一改 `.when`) | ✅ | LB-06/#166 主体 + #199 余量收口(24 站点审计:唯一真吞错=方向读错已修;library_sidebar 判良性降级加注释;InkAsyncSlot 判 YAGNI 不建)——**GAP-3 卡关闭** | | 软删项目「可恢复」无 UI 入口(restore/listTrashed 仓储层已就绪) | ✅ | LB-15/#190:sidebar 回收站对话框(项目级)+ 管理画布已删区(画布级);永久删除仍显式排除 | | slot 状态字符串常量化('generating' 等散落约 10+ 处,全仓既有约定) | ✅ | LB-01/#156:`core/constants/job_statuses.dart` 单一真相源 | | canvas→generation 跨 feature import 违例(18 处 / 11 文件:job_state / jobs_registry / batch_results_controller / cost_estimator 等,违反 ARCHITECTURE §1.3 互 import 禁令) | 🅿️ | 待 import 边界 lint(custom_lint 卡点解除后)收口:上提共享模型到 core/ 或建白名单逐步清零 | @@ -152,6 +153,7 @@ | 三大重操作互斥只在导入侧单向查(LB-12 拍板 9):还原/导出入口不查 projectImportBusyProvider——导入进行中仍可点还原 | 🅿️ | 反向补查三行;或统一 heavyOperationBusyProvider 归一三个 busy 位 | | 导入补偿删除失败→projects/{uuid} 孤儿目录无回收路径(#192 评审 P3-2:无 .import- 前缀 sweep 不认,reaper 又 DRY-RUN);另记拍板 4 三处字面偏差(U+FFFD 奇名可过/最终路径长未预检/isWithin 代 resolveInProject)均安全失败 | 🅿️ | 随 LB-13 reaper 转真删同窗:无行背书目录纳入回收;字面偏差随安全面复审顺修 | | **迁移纪律备忘(#192 评审 P3-6)**:导入的列白名单过滤依赖「迁移只加可空/有默认列」——将来任何「新增 NOT NULL 无默认」迁移会让旧项目包导入必炸 | 🅿️ | ADR-0012 补一句:新增列必须可空或带默认,否则同时给导入侧加填充逻辑 | +| GAP-3 评审 P3 残留(#199):①方向读错期 lane_toolbar 置灰未做(二元域无损毁,但 toggle 到不了 horizontal 的怪异 UX);②横幅三源 `??` 链+单 `_dismissed` 遮蔽——关掉 edges 错后并发 lanes/direction 错不上屏(改集合);③非 InkError→errorUnknown 后无任何日志线索(此前 raw toString 至少可报障),建议 error 分支补 log 或 ProviderObserver.providerDidFail | 🅿️ | ①②低害 UX;③可观测性,随日志面收口 | | ON-5 评审 P3 残留(#198):①golden sentinel 单点——删 studio_empty.png 五测静默 skip 而 node_card 仍 ran>0 骗过整 job 守卫;评审提的 `skipped>0&&baselines>0→fail` 会误伤增量铸线 bootstrap(本 PR 自身流程即反例),需更细粒度方案;②成本断言 0.01×1 测不出漏乘 batch,补 maxBatchSize>1→\$0.02 用例;③ci.yml 与 update-goldens.yml 双 pin FLUTTER_VERSION 升级必须同步+重铸 | 🅿️ | ①设计再议 ②一测的事 ③升级 checklist 项 | | ON-3 评审 P3 残留(#196):卸载 ffmpeg 后设置页旧 Available 滞留(hit 缓存 app 级,设置页不调 invalidate;导出失败路径会自愈);PATH 命中显示裸 `ffmpeg` 当路径 | 🅿️ | 低害:重启/导出失败自愈;若做刷新按钮同窗顺修 | | ON-2b 评审 P3 三条(#195):①真 PG 回滚测只走 projects+canvas 两仓储,建议扩成与 createSample 同构四步;②泳道带厚 400 魔数散落三处(接口默认/注释/测试),建议提 kDefaultLaneSize;③示例 laneStylePrompt 走 zh 本地化与 base_style_presets「模型合约保英文」惯例有张力(用户可见可编辑,判定可接受)——产品可拍板改为仅本地化 label | 🅿️ | ①②低成本顺窗;③产品取舍,英文语系 provider 出图质量考量 | diff --git a/docs/MASTERPLAN.md b/docs/MASTERPLAN.md index 32deb96a..02875107 100644 --- a/docs/MASTERPLAN.md +++ b/docs/MASTERPLAN.md @@ -182,8 +182,10 @@ W1(LB-03/04/06/17)→ W2(LB-07/09/13/14/16)→ W3(LB-10/11/15/18)→ W4(LB-12 术语统一、省略号统一、canvasNodeType* 6 僵尸键清除)。 **GAP-7 + ON-5**(质量闸)✅ **#198 已合**(Inspector 预设/成本断言收债; 五屏空态 golden 入 CI,ubuntu 铸线)——第 10 条收官。 - **清单余量(未收官,#198 评审 P1 核对)**:第 7 条 GAP-1 设置页 Custom Provider - 编辑 UI(L,整卡未做);第 6 条 GAP-3 余量(InkAsyncSlot 共享件+残站点,债表在案)。 + **GAP-3 余量**✅ **#199 已合**(24 站点审计:泳道方向读错并入横幅链;raw toString + 上屏收敛,探测诊断行有意例外;base style 读错中止防空覆盖;InkAsyncSlot 判 + YAGNI——第 6 条关闭)。 + **清单余量(未收官)**:仅第 7 条 GAP-1 设置页 Custom Provider 编辑 UI(L,整卡未做)。 **上线后首迭代起**:PL-4b undo 栈、CV-4 左工具栏实装/CV-5 视口 chrome(zoom 指示+fit,minimap 后置)、 CV-2/CV-3(节点色条/Inspector 浮动,拍板后随时)、PL-3 右键菜单(建议尽早)、PL-5 框选群拖、 diff --git a/lib/features/canvas/widgets/canvas_top_chrome.dart b/lib/features/canvas/widgets/canvas_top_chrome.dart index 0031954d..89ded91f 100644 --- a/lib/features/canvas/widgets/canvas_top_chrome.dart +++ b/lib/features/canvas/widgets/canvas_top_chrome.dart @@ -285,8 +285,14 @@ class _BaseStyleButton extends ConsumerWidget { ({String prefix, String suffix}) cur; try { cur = await ref.read(canvasBaseStyleProvider(canvasId).future); - } on InkError catch (_) { - cur = (prefix: '', suffix: ''); + } on InkError catch (e) { + // 读失败必须中止(#199 评审 P2-3):空预填打开编辑器,用户一保存就把 + // 已存前后缀覆盖为空——自由文本域无护栏,提示后不开门。 + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + SnackBar(content: Text(l10nAsyncError(context, e))), + ); + return; } if (!context.mounted) return; final r = await showBaseStyleEditorDialog( diff --git a/lib/features/canvas/widgets/canvas_view.dart b/lib/features/canvas/widgets/canvas_view.dart index 3262d24b..936951c3 100644 --- a/lib/features/canvas/widgets/canvas_view.dart +++ b/lib/features/canvas/widgets/canvas_view.dart @@ -68,7 +68,9 @@ class CanvasView extends ConsumerWidget { color: colors.surface1, child: const Center(child: CircularProgressIndicator()), ), - error: (err, _) => _LoadError(message: err.toString()), + // raw toString 上屏收敛(GAP-3 审计;设置页安全存储探测行的诊断详情 + // 为有意例外)——走 l10n 映射。 + error: (err, _) => _LoadError(message: l10nAsyncError(context, err)), data: (nodes) => _CanvasBody(canvasId: canvasId, nodes: nodes), ); } @@ -332,7 +334,7 @@ class _CanvasBody extends ConsumerWidget { final Widget leftArea = Stack( children: [ Positioned.fill(child: canvasArea), - // 边/泳道加载失败 → 非阻塞横幅(节点照常渲染),可忽略。 + // 边/泳道/泳道方向加载失败 → 非阻塞横幅(节点照常渲染),可忽略。 Positioned( top: InkSpacing.md, left: InkSpacing.md, @@ -426,6 +428,7 @@ class _CanvasStage extends ConsumerWidget { // 只重建涉及的卡片,不再整层重建(丝滑核心)。 final selectedEdgeId = ref.watch(selectedEdgeControllerProvider); final edges = + // 良性降级(GAP-3 审计 B 类):错误横幅在 _EdgeLaneErrorSlot ref.watch(canvasEdgesControllerProvider(canvasId)).valueOrNull ?? const []; // 泳道数据。 @@ -1089,8 +1092,11 @@ class _NodeCardSlot extends ConsumerWidget { } } -/// 边/泳道加载失败横幅插槽:非阻塞——节点已在 _CanvasStage 用 valueOrNull 降级 -/// 照常渲染,这里仅补一条可忽略的错误横幅解释失败原因(此前静默吞错)。 +/// 画布元数据(边/泳道/泳道方向)加载失败横幅插槽:非阻塞——节点已在 +/// _CanvasStage 用 valueOrNull 降级照常渲染,这里仅补一条可忽略的错误横幅 +/// 解释失败原因(此前静默吞错)。GAP-3 余量:方向读失败此前完全无提示; +/// lane_toolbar 会基于错误基线 horizontal 翻转写库(二元域下写入值仍在 +/// 存量/意图之内,无损毁,但错误期 toggle 永远到不了 horizontal——置灰记债)。 class _EdgeLaneErrorSlot extends ConsumerStatefulWidget { const _EdgeLaneErrorSlot({required this.canvasId}); @@ -1112,7 +1118,10 @@ class _EdgeLaneErrorSlotState extends ConsumerState<_EdgeLaneErrorSlot> { final lanesError = ref .watch(canvasLanesControllerProvider(widget.canvasId)) .error; - final error = edgesError ?? lanesError; + final directionError = ref + .watch(canvasLaneDirectionProvider(widget.canvasId)) + .error; + final error = edgesError ?? lanesError ?? directionError; if (error == null || identical(error, _dismissed)) { return const SizedBox.shrink(); } diff --git a/lib/features/canvas/widgets/image_config_inspector.dart b/lib/features/canvas/widgets/image_config_inspector.dart index 7c267b6d..4f252055 100644 --- a/lib/features/canvas/widgets/image_config_inspector.dart +++ b/lib/features/canvas/widgets/image_config_inspector.dart @@ -964,7 +964,8 @@ class _PromptPreview extends ConsumerWidget { .cast() .firstWhere((_) => true, orElse: () => null); - // 画布 base 前缀 / 后缀(失败降级为空字符串) + // 良性降级(GAP-3 审计 B 类):base 前后缀读失败降级为空——与提交链路 + // generation_controller._assembleFullPrompt 的一致性降级同步(预览=实发)。 final baseStyle = ref.watch(canvasBaseStyleProvider(canvasId)).valueOrNull ?? (prefix: '', suffix: ''); diff --git a/lib/features/settings/widgets/about_section.dart b/lib/features/settings/widgets/about_section.dart index 44df4035..560f8e98 100644 --- a/lib/features/settings/widgets/about_section.dart +++ b/lib/features/settings/widgets/about_section.dart @@ -133,6 +133,8 @@ class AboutSection extends ConsumerWidget { const SizedBox(height: InkSpacing.xs), _Row( label: context.l10n.settingsAboutSecureStorageLabel, + // 有意例外(#199 评审 P2-1 拍板):探测行的 raw 异常文本是 + // 诊断详情(同路径展示类),不走 l10n 映射——用户报障需要原文。 value: probeAsync.when( data: (probe) => probe.available ? context.l10n diff --git a/lib/features/studio/widgets/library_sidebar.dart b/lib/features/studio/widgets/library_sidebar.dart index e586c1fd..c7b6f953 100644 --- a/lib/features/studio/widgets/library_sidebar.dart +++ b/lib/features/studio/widgets/library_sidebar.dart @@ -45,6 +45,8 @@ class LibrarySidebar extends ConsumerWidget { _SectionLabel(label: context.l10n.studioLibrary), projectsAsync.when( loading: () => const _SidebarLoading(), + // 良性降级(GAP-3 审计 B 类):同 provider 的错误由主区 + // _StudioErrorState 呈现(横幅+重试,重试同时救活本树)。 error: (_, _) => const SizedBox.shrink(), data: (projects) => _LibraryTree( studioName: studioName, diff --git a/lib/theme/components/ink_error_banner.dart b/lib/theme/components/ink_error_banner.dart index d753f8b9..779e662a 100644 --- a/lib/theme/components/ink_error_banner.dart +++ b/lib/theme/components/ink_error_banner.dart @@ -5,14 +5,14 @@ import '../app_theme.dart'; import '../tokens.dart'; class InkErrorBanner extends StatelessWidget { + // onRetry 死参数已删(GAP-3 审计:从未渲染、零调用方传参)——重试按钮 + // 由各站点按需自置(gallery/_StudioErrorState 等),骨架只管呈现错误。 const InkErrorBanner({ super.key, required this.message, - this.onRetry, }); final String message; - final VoidCallback? onRetry; @override Widget build(BuildContext context) { diff --git a/test/features/canvas/widgets/canvas_view_test.dart b/test/features/canvas/widgets/canvas_view_test.dart index 8b03f47b..3da42315 100644 --- a/test/features/canvas/widgets/canvas_view_test.dart +++ b/test/features/canvas/widgets/canvas_view_test.dart @@ -14,6 +14,7 @@ import 'package:inkframe/features/canvas/models/canvas_node.dart'; import 'package:inkframe/features/canvas/models/style_lane.dart'; import 'package:inkframe/features/canvas/providers/canvas_edges_controller.dart'; import 'package:inkframe/features/canvas/providers/canvas_lanes_controller.dart'; +import 'package:inkframe/features/canvas/util/lane_geometry.dart'; import 'package:inkframe/features/canvas/providers/canvas_nodes_controller.dart'; import 'package:inkframe/features/canvas/providers/canvas_selection_controller.dart'; import 'package:inkframe/features/canvas/providers/current_canvas_id.dart'; @@ -187,6 +188,14 @@ class _ErrorEdgesController extends CanvasEdgesController { } } +/// 节点加载失败 → 画布整屏 _LoadError(GAP-3:文案须走 l10n)。 +class _ErrorNodesController extends CanvasNodesController { + @override + Future> build(String canvasId) async { + throw const LocalIOError(); + } +} + /// 泳道恒空且不触 PG,隔离测试只让边失败。 class _EmptyLanesController extends CanvasLanesController { @override @@ -378,6 +387,58 @@ void main() { expect(find.text('Failed to move node'), findsOneWidget); }); + testWidgets('泳道方向加载失败 → 非阻塞错误横幅(GAP-3 余量:此前静默回落 horizontal)', + (tester) async { + await pumpInkApp( + tester, + const Scaffold(body: CanvasView()), + overrides: [ + currentCanvasIdProvider.overrideWith((ref) => 'c1'), + canvasNodesControllerProvider + .overrideWith(() => _FakeNodesController(twoNodes)), + canvasEdgesControllerProvider + .overrideWith(() => _FakeEdgesController()), + canvasLanesControllerProvider.overrideWith(() => _EmptyLanesController()), + canvasLaneDirectionProvider('c1').overrideWith( + (_) async => throw const LocalIOError(), + ), + fileResolverServiceProvider.overrideWithValue(_StubResolver()), + ], + ); + await tester.pumpAndSettle(); + + // 非阻塞:节点照常渲染;方向失败有横幅提示。 + expect(find.byType(NodeCard), findsNWidgets(2)); + expect(find.byType(InkErrorBanner), findsOneWidget); + expect( + find.text('Local disk I/O error. Check space and permissions.'), + findsOneWidget, + ); + }); + + testWidgets('节点加载失败 → _LoadError 走 l10n 文案而非 raw toString(GAP-3)', + (tester) async { + await pumpInkApp( + tester, + const Scaffold(body: CanvasView()), + overrides: [ + currentCanvasIdProvider.overrideWith((ref) => 'c1'), + canvasNodesControllerProvider + .overrideWith(() => _ErrorNodesController()), + fileResolverServiceProvider.overrideWithValue(_StubResolver()), + ], + ); + await tester.pumpAndSettle(); + + expect( + find.text('Local disk I/O error. Check space and permissions.'), + findsOneWidget, + ); + // raw toString 绝不上屏——InkError.toString() = 'InkError(local_io_error, …)' + expect(find.textContaining('InkError('), findsNothing); + expect(find.textContaining('local_io_error'), findsNothing); + }); + testWidgets('边加载失败 → 非阻塞错误横幅,节点照常渲染;可关闭', (tester) async { await pumpInkApp( tester, @@ -447,6 +508,10 @@ void main() { canvasEdgesControllerProvider.overrideWith(() => edges), // 泳道恒空且不触 PG,避免加载失败横幅混入额外 close 图标。 canvasLanesControllerProvider.overrideWith(() => _EmptyLanesController()), + // 方向同理密封(GAP-3 后 _EdgeLaneErrorSlot 也 watch 它)。 + canvasLaneDirectionProvider('c1').overrideWith( + (_) async => LaneDirection.horizontal, + ), fileResolverServiceProvider.overrideWithValue(_StubResolver()), ], );