Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/BOARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 补遗(审计发现的悬空项)

Expand Down Expand Up @@ -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/ 或建白名单逐步清零 |
Expand All @@ -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 出图质量考量 |
6 changes: 4 additions & 2 deletions docs/MASTERPLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 框选群拖、
Expand Down
10 changes: 8 additions & 2 deletions lib/features/canvas/widgets/canvas_top_chrome.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 14 additions & 5 deletions lib/features/canvas/widgets/canvas_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
Expand Down Expand Up @@ -332,7 +334,7 @@ class _CanvasBody extends ConsumerWidget {
final Widget leftArea = Stack(
children: [
Positioned.fill(child: canvasArea),
// 边/泳道加载失败 → 非阻塞横幅(节点照常渲染),可忽略。
// 边/泳道/泳道方向加载失败 → 非阻塞横幅(节点照常渲染),可忽略。
Positioned(
top: InkSpacing.md,
left: InkSpacing.md,
Expand Down Expand Up @@ -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 <CanvasEdge>[];
// 泳道数据。
Expand Down Expand Up @@ -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});

Expand All @@ -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();
}
Expand Down
3 changes: 2 additions & 1 deletion lib/features/canvas/widgets/image_config_inspector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,8 @@ class _PromptPreview extends ConsumerWidget {
.cast<StyleLane?>()
.firstWhere((_) => true, orElse: () => null);

// 画布 base 前缀 / 后缀(失败降级为空字符串)
// 良性降级(GAP-3 审计 B 类):base 前后缀读失败降级为空——与提交链路
// generation_controller._assembleFullPrompt 的一致性降级同步(预览=实发)。
final baseStyle =
ref.watch(canvasBaseStyleProvider(canvasId)).valueOrNull ??
(prefix: '', suffix: '');
Expand Down
2 changes: 2 additions & 0 deletions lib/features/settings/widgets/about_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/features/studio/widgets/library_sidebar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions lib/theme/components/ink_error_banner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
65 changes: 65 additions & 0 deletions test/features/canvas/widgets/canvas_view_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -187,6 +188,14 @@ class _ErrorEdgesController extends CanvasEdgesController {
}
}

/// 节点加载失败 → 画布整屏 _LoadError(GAP-3:文案须走 l10n)。
class _ErrorNodesController extends CanvasNodesController {
@override
Future<List<CanvasNode>> build(String canvasId) async {
throw const LocalIOError();
}
}

/// 泳道恒空且不触 PG,隔离测试只让边失败。
class _EmptyLanesController extends CanvasLanesController {
@override
Expand Down Expand Up @@ -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: <Override>[
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: <Override>[
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,
Expand Down Expand Up @@ -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()),
],
);
Expand Down
Loading