diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index a26a585a..a28fb549 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -42,7 +42,7 @@ jobs: "-DTERMFORGE_CONPTY_BACKEND_DIR=$dependencyRoot/conpty" ` "-DTERMFORGE_MSVC_RUNTIME_DIR=$dependencyRoot/crt" - name: Build Qt product and independent smoke - run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_terminal_modes_test --parallel 2 + run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_terminal_modes_test qt_hidden_retention_test --parallel 2 - name: Record complete path-free runtime manifests run: | python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-bin/Release --executable termforge_qt_shell.exe @@ -57,6 +57,8 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-modes-bin/Release --executable qt_terminal_modes_test.exe if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-hidden-retention-bin/Release --executable qt_hidden_retention_test.exe + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Preserve runtime file identities without binaries if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -71,8 +73,9 @@ jobs: build/qt-cpp/qt-unicode-bin/Release/runtime-manifest.json build/qt-cpp/qt-unicode-render-bin/Release/runtime-manifest.json build/qt-cpp/qt-modes-bin/Release/runtime-manifest.json + build/qt-cpp/qt-hidden-retention-bin/Release/runtime-manifest.json - name: Run Qt offscreen smoke in its owned Job - run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?)$' --no-tests=error --verbose + run: ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?|qt_hidden_retention)$' --no-tests=error --verbose # Keep this configuration independent from the default/system-backend and # Alacritty reference jobs. SDKs, runtime folders and raw logs are not # publication artifacts; this job does not produce a release package. diff --git a/CMakeLists.txt b/CMakeLists.txt index 20d8bee0..c74d8be8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -764,6 +764,20 @@ if(TERMFORGE_BUILD_QT_SHELL) "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$" --retained-contract) set_tests_properties(qt_terminal_modes_retained PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_executable(qt_hidden_retention_test src/qt/hidden_retention_test.cpp) + set_target_properties(qt_hidden_retention_test PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qt-hidden-retention-bin/$") + termforge_qt_entry(qt_hidden_retention_test) + target_link_libraries(qt_hidden_retention_test PRIVATE Qt6::Test) + add_custom_command(TARGET qt_hidden_retention_test POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "$" + "$/platforms/" + VERBATIM) + add_test(NAME qt_hidden_retention COMMAND "${Python3_EXECUTABLE}" + "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$") + set_tests_properties(qt_hidden_retention PROPERTIES TIMEOUT 35 + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;TERMFORGE_TEST_ARTIFACTS=${PROJECT_BINARY_DIR}/test-artifacts") if(TERMFORGE_ENABLE_RESIZE_REPRO) add_test(NAME qt_terminal_modes_resize_repro COMMAND "${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/src/qt/run_smoke.py" "$" --resize) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 5160a9a3..4ad3f045 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -19,7 +19,8 @@ | 缩窄后跨前方显示重排的 CUP/DSR 寻址(#27/#29 显式入口) | [单例原版四阶段严格记录](../research/ui/results/2026-11-03-reflow-addressing-reference/README.md)、[核心字面回放与身份](../experiments/core/results/2026-11-03-reflow-addressing/README.md):`CSI 8;2HQ` 写入文档行 6(保留的缩放前源片段行)而非重排行 5,DSR `8;3`、caret 同口径;窄屏矩形 (155,144) 恢复 (155,126) 证实显示页独立 +1 行重排 | 单例固定 ASCII、`91×20→61×20→91×20`、一个动作、最大一次运行;只补既有样本无法区分的行映射,不含备用屏、高度、Unicode、历史、样式、Qt/固定 ConPTY 或性能;默认 `Screen::resize`/Qt 未切换,#27/#29 不关闭 | | 正式 Qt / 固定 ConPTY 接入保留事务、显示投影与显示光标 | [契约、身份与差异](../experiments/shell/results/2026-11-03-qt-retained-display/README.md):可达真实 child→固定 ConPTY→core→widget;新 `qt_terminal_modes_retained` 12/12 检查通过,只缩宽后显示页第 8 行与原生重排逐字节一致,B/C/D 保留源位,命中经 layout revision 回到源格;Qt 正式路径 CTest 5/5,核心 36/36(含 `display_cursor_core`) | 旧六阶段 `--resize` 仍 exit 1 且失败信息不变,保留为原生重排网格与保留行合同的坐标差;同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据;不覆盖真实呈现/DPI/历史/Find/性能,#27/#29 不关闭 | | 正式 Qt / 固定 ConPTY 固定 Unicode 样本贯通呈现与独立接收(#30 切片) | [链路、样本与实测](../experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md):新增 `qt_unicode_render`,5 条固定样本经公开 Qt 事件→真实 child `ReadConsoleW` 精确接收→同字节 CR→CRLF 回显→核心解析→`TerminalWidget` 离屏抓图;receiver/echo SHA-256 与源字节逐字节一致,CJK 实测 fallback `Alibaba PuHuiTi`、emoji `Segoe UI Emoji`(advance 21>span 18 但无裁剪参考墨迹 3..17 未越格),结合标 `marks=1`,第二条空会话 0 字节且无样本文字;Qt 正式路径 6/6 | U+200D/U+FE0F 仍被 recovery parser 替换为 U+FFFD(只记录,不批准新聚类/宽度策略);offscreen + 宿主 fallback 依赖、`presented_done=null`,不覆盖物理呈现/原生 IME/真实 DPI/完整字素或 #30/#155 验收 | -| 隐藏主屏纯高度变化的正文保留(#27 工程合同) | [RED/GREEN、命令与边界](../experiments/core/results/2026-11-04-hidden-height-retention/README.md):修复前 `resize_hidden_height_test` 证明隐藏 live/历史被按未变宽度裁剪并丢失保留尾;`resize_preserving` 改为只在宽度实际变化时适配隐藏帧后,三场景通过并注册 `retained_resize_hidden_height_core`;组合回放 20/20、事务检查不变,正式 Qt CTest 5/5 | 工程不变量;原版对照见下一行。2026-11-04 用户确认保留完整正文、接受原版返回裁剪差异,工程身份不变,正式 Qt 路径验收未完成。未覆盖备用屏后续输出、保存光标交叉、Unicode、历史淘汰、Qt 物理呈现/DPI 或性能;#27/#29 不关闭 | +| 隐藏主屏纯高度变化的正文保留(#27 工程合同) | [RED/GREEN、命令与边界](../experiments/core/results/2026-11-04-hidden-height-retention/README.md):修复前 `resize_hidden_height_test` 证明隐藏 live/历史被按未变宽度裁剪并丢失保留尾;`resize_preserving` 改为只在宽度实际变化时适配隐藏帧后,三场景通过并注册 `retained_resize_hidden_height_core`;组合回放 20/20、事务检查不变,正式 Qt CTest 5/5 | 工程不变量;原版对照见下一行。2026-11-04 用户确认保留完整正文、接受原版返回裁剪差异,工程身份不变;固定七阶段正式 Qt 路径已离屏核对(见下方正式路径行),物理呈现/DPI 未覆盖。未覆盖备用屏后续输出、保存光标交叉、Unicode、历史淘汰、Qt 物理呈现/DPI 或性能;#27/#29 不关闭 | +| 正式 Qt / 固定 ConPTY 隐藏帧七阶段完整正文(#27/#29 正式路径切片) | [场景、阶段与显示/命中](../experiments/shell/results/2026-11-04-qt-hidden-retention/README.md):真实 child 写入 91×20 固定 75 B 正文→公开 Qt 缩窄 61→`1049h`→仅高度 20→14→20→`1049l`→恢复宽度 91;58 检查 0 失败,2 次运行除 `child_pid` 外一致;阶段 1/2/6/7 逻辑正文 SHA `0e683ff0…` 相同,保留行 id `12`、91-Cell 源行与历史 5 行前缀保持,显示页/光标/命中均映射同一源身份;Qt 正式路径 7/7(8.99 s)、核心 41/41(23.05 s) | 仅 offscreen 固定样本;非物理呈现(`presented_done=null`)、DPI、IME 或性能验收;child 原生行单独记录,不作为保留文本 oracle;原严格失败、补充审计与 `semantic_acceptance=false` 不改写;#27/#29/#30/#7 不关闭 | | 原版隐藏主屏高度的录制恢复与离线补充审计(#27/#29) | [补充审计、阶段观察与差异](../research/ui/results/2026-11-02-resize-hidden-height-reference/README.md):七阶段原版录制的严格审计失败保留;从仍可用的取证环境只读恢复完整输入后,离线补充审计通过(计划 `938992c4…`、参考 EXE `7b1053…`/4047 清单 `e459adf2…`、执行源清单 `51e80b93…`,0 次重跑),补充件 `4c0f28f2…` | 审计显示返回主屏后宽行按当前宽度裁剪为 61 空格 + `w`(62 字符)、DSR `19;7→14;7`,与 #311 的保留合同存在差异;2026-11-04 用户已确认保留完整正文、接受该原版观察为产品有意差异([决定评论](https://github.com/JavaZeroo/termforge/issues/29#issuecomment-6627911863));本记录不修改实现、不重跑、不关闭 #27/#29;语义验收 false,备用屏阶段 API 文本不等于活动备用屏网格 | | 原版取证运行时与归档可恢复性盘点 | [环境类别、已核验身份、解除方式与条件](research/original-evidence-runtime-2026-11-04.md):原取证 QEMU 域仍可用,参考 EXE `7b1053…`、4047 文件清单 `e459adf2…`、隐藏高度计划 `938992c4…`、执行源清单 `51e80b93…` 与完整录制输入均在;宿主归档缺少 `app`/`profile`/`reference-source.json` 是此前 blocked 的原因 | 只发布路径类别、哈希与证明字段;私有录制/profile/原包不发布;解除方式只适用于原始输入完整且允许只读复核的记录,不外推到未核对归档;环境丢失后同类旧失败只能保持 blocked | | 自主 C++ 同 owner 保留列宽事务与有限编辑(#27/#29 独立入口) | [源码、定向红绿与限制](../experiments/core/results/2026-11-02-retained-columns/README.md):`a35be70` 的四个寻址 case 为 64/64 阶段;`fdeaed6` 使打印/ICH/DCH 按协议宽更新目标行,内容长度与 `written` 分离,三例编辑为 64/64 阶段。该固定源码重新构建后核心 22/22、Qt/固定 ConPTY 4/4,各配置身份独立记录 | 默认 resize / Qt 未切换;Qt 回归不执行新保留入口。Q/R 与 ICH/DCH 红绿分别保留,owner 存储及空白长度工程检查没有独立先行 RED。后续光标与隐藏帧修正另列,不继承本轮结果;默认裁剪与 B/CD 失败不豁免 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8dad8a5d..3fa9d120 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -31,7 +31,7 @@ 行存储已由 owner 封装,行 ID 与文档顺序分离;[精确查询](../experiments/core/results/2026-10-29-order-query/README.md)、[封存存储](../experiments/core/results/2026-10-29-sealed-row-storage/README.md)及 `text_search.hpp` 的顺序 revision 处理已在 main(#170/#172/#175)。#169 的关闭本身不证明 Qt 历史/选择/Find 已交付。[#29](https://github.com/JavaZeroo/termforge/issues/29) 的叠加 `ViewMapping` 已引入(#264),`HistoryViewport` 快照现携带显示页映射;部分列选区不再投影成整行。Qt 基础拖选通过同一映射命中与高亮,颜色只写入显示副本,普通点击清除选区;[模型与 Qt 接线记录](../experiments/shell/results/2026-11-01-r2-core-input/README.md)分列核心映射和用户事件证据。系统复制/粘贴、选词/选行、历史浏览/自动滚动与 Find 仍未迁移,物理 resize 仍会丢弃裁掉的正文;#29/#30 均未结项。旧 #108–#113 堆叠方案已关闭,不再列为待合入产品实现。 -[有界 Unicode Cell 显示投影](../experiments/core/results/2026-11-02-display-projection-unicode/README.md)保留完整宽字、已有组合标/样式、源身份和选择/Find范围,显示补白不创造源文字。[增量 `ProjectionJob`](../experiments/core/results/2026-11-02-projection-job/README.md)每步最多读200个源片段,保存未完成半行和页面,完整页才发布;500个单列片段实际按200/200/100步完成,跨步骤选择、复制与Find映射通过。每片段仍限500 Cell,每lead最多68字节/16个mark;不完整原子、trail起点及一列放不下宽字等请求拒绝。任务绑定owner、epoch、order、独立content revision与请求代次;旧单次`project`的source_limit可能停滞,需使用分步入口。源锚点不是全局显示行号,任意显示位置定位与持续输出下的进展保证仍待实现。默认 `Screen::resize` 仍物理裁剪;正式 Qt 路径已改走保留事务并绘制独立显示页、显示光标与映射命中([只缩宽契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md)),旧六阶段 native-sync 失败保留为原生重排网格与保留行坐标层的差异;隐藏主屏纯高度变化保留正文的[工程合同](../experiments/core/results/2026-11-04-hidden-height-retention/README.md)与[原版补充审计](../research/ui/results/2026-11-02-resize-hidden-height-reference/README.md)已分别记录:原版返回时按当前宽度裁剪隐藏行;2026-11-04 用户已确认产品保留完整正文并接受该原版差异([ADR-0010](adr/0010-bounded-resize-retention.md))。工程回归仍仅指核心显式入口门禁,正式 Qt 路径对该场景的验收尚未完成。#27/#29保持开放。 +[有界 Unicode Cell 显示投影](../experiments/core/results/2026-11-02-display-projection-unicode/README.md)保留完整宽字、已有组合标/样式、源身份和选择/Find范围,显示补白不创造源文字。[增量 `ProjectionJob`](../experiments/core/results/2026-11-02-projection-job/README.md)每步最多读200个源片段,保存未完成半行和页面,完整页才发布;500个单列片段实际按200/200/100步完成,跨步骤选择、复制与Find映射通过。每片段仍限500 Cell,每lead最多68字节/16个mark;不完整原子、trail起点及一列放不下宽字等请求拒绝。任务绑定owner、epoch、order、独立content revision与请求代次;旧单次`project`的source_limit可能停滞,需使用分步入口。源锚点不是全局显示行号,任意显示位置定位与持续输出下的进展保证仍待实现。默认 `Screen::resize` 仍物理裁剪;正式 Qt 路径已改走保留事务并绘制独立显示页、显示光标与映射命中([只缩宽契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md)),旧六阶段 native-sync 失败保留为原生重排网格与保留行坐标层的差异;隐藏主屏纯高度变化保留正文的[工程合同](../experiments/core/results/2026-11-04-hidden-height-retention/README.md)与[原版补充审计](../research/ui/results/2026-11-02-resize-hidden-height-reference/README.md)已分别记录:原版返回时按当前宽度裁剪隐藏行;2026-11-04 用户已确认产品保留完整正文并接受该原版差异([ADR-0010](adr/0010-bounded-resize-retention.md))。工程回归仍仅指核心显式入口门禁;2026-11-04 起正式 Qt / 固定 ConPTY 固定七阶段离屏证据已补([结果](../experiments/shell/results/2026-11-04-qt-hidden-retention/README.md),58 检查、2 次运行一致),物理呈现/DPI、其他尺寸/Unicode/连续输出与完整 reflow 仍未验收。#27/#29保持开放。 [同 owner 保留列宽事务](../experiments/core/results/2026-11-02-retained-columns/README.md)使用独立 `Screen::resize_columns_preserving` 入口:活动正文保留源 Cell、ID/version 与有效软续接,历史不被补齐或重写;普通打印、ICH/DCH 按当前协议宽度更新目标行,独立内容长度保留有意义的尾空白。实际列宽变化会[限制活动光标并取消延迟末列写入](../experiments/core/results/2026-11-02-resize-pending/README.md),并[复位两帧的滚动边界](../experiments/core/results/2026-11-02-resize-mode/README.md);[隐藏主屏](../experiments/core/results/2026-11-02-resize-hidden/README.md)还会裁剪 live 正文,不与活动正文采用同一保留策略。原版字面回放分别覆盖寻址、写入/插删、缩放后下一字符和隐藏主屏返回,各组身份与验证范围在对应报告中独立记录。[缩窄后跨前方重排的 CUP/DSR 寻址原版样本](../research/ui/results/2026-11-03-reflow-addressing-reference/README.md)与[对应核心回放](../experiments/core/results/2026-11-03-reflow-addressing/README.md)确认协议行仍是保留的缩放前行、DSR/caret 同口径,显示页独立重排;仍只走显式入口,默认 `Screen::resize` 与 Qt 未切换。 @@ -56,7 +56,7 @@ Qt 构建显式准备并部署[固定微软 CRT 14.51.36247.0](../third_party/ms | 中文 IME 与键盘 | **限定验收**:基础按键/修饰键/UTF-16 与实际接收字节;[固定 ZWJ/keycap 输入](../experiments/shell/results/2026-10-26-unicode-keyboard/validation.json)在默认/候选各两轮精确接收。固定 Microsoft 拼音[产品场景](../research/ime/results/2026-10-24-product-ime-contexts/validation.json)覆盖两区域组合/提交/取消、候选、输出/resize/zoom/受控 DPI、隐藏/关闭、历史阅读和原生控件 | 限定 Windows Server 2025 固定拼音及已有场景;同次组合由八会话工作流另验收。固定字素绘制/选择已由 #39 限定完成,但所有输入法、应用键盘模式、完整 IME 和真实不同 DPI 跨屏仍未完成 | R1 / R2 | | VT 核心 | **实现中,未验收**:默认自研支持基础光标/擦除(含 ECH)、CR/LF/BS/TAB、有限 SGR(粗体/暗色/下划线/反显/删除线/隐藏/上划线)和有界恢复;新增编辑类 `ICH`/`DCH`(`CSI @`/`CSI P`)、`IL`/`DL`(`CSI L`/`CSI M`)、滚动区域 `DECSTBM`(`CSI Pt;Pb r`,归位与非法区域行为按参考实现)、`ED3`(`CSI 3J` 只清回滚)与 `RIS`(`ESC c`),`DSR 6n` 回复、`DECCKM`(`?1`)与 `DECTCEM`(`?25`)已接入 Win32 与 Qt 两个入口,均有[核心契约测试](../experiments/core/edit_csi_test.cpp)与[回复测试](../experiments/core/reply_test.cpp)。显式候选 `.4` 接入 `Term/Processor`、C++ 拥有值适配、稳定行元数据、每缓冲区 margin 与流式字素;[共同轨迹](../experiments/differential/results/2026-10-26-candidate-common-v6/summary.json)及[源 margin 合同](../experiments/shell/results/2026-10-26-margin-state/validation.json)为各自固定版本结果 | [#27](https://github.com/JavaZeroo/termforge/issues/27) 的 origin/wrap、模式/resize、插删与滚动区域交叉未闭合;[#41](https://github.com/JavaZeroo/termforge/issues/41) 系统 ConPTY 已丢失的 margin 不能由末端补回。ECH pending-wrap 差异见[限定合同](research/more-capture-ech.md);`DA1` 仅 strict 回放回复 VT102,recovery 产品路径不回复;`DA2` 与 `OSC 10/11` 回复未实现,原版未知仍不算匹配;`SGR 58` 等下划线颜色属性亦未实现;#36/#38、完整呈现和平台支持独立验收 | R2 / R4 | | Unicode 与文字显示 | **实现中,未验收**:默认自研保留 Unicode 15。显式候选 `.4` 的 [#39 Unicode 17 流式字素合同](research/streaming-graphemes.md)已限定验收:固定 ZWJ/keycap 的单 lead/两列、光标、SGR/查询、原子选择复制、单列 resize 与有界无损分片;实际 ConPTY/DWrite 核对固定样本的单 glyph、字体和网格。正式 Qt/固定 ConPTY 路径已把固定 ASCII/CJK/补充平面/结合标样本贯通到独立 child 精确接收与离屏绘制,CJK/emoji 实际 fallback 面、advance 与无裁剪参考墨迹逐格记录([固定结果](../experiments/shell/results/2026-11-04-qt-unicode-presentation/README.md)) | [#30](https://github.com/JavaZeroo/termforge/issues/30) 仍需完整输入/IME 交叉;所有脚本/整形、通用 fallback、所有原生重放、真彩色/更多属性、Powerline 和真实跨屏未验收。Qt 呈现仅离屏固定样本,依赖宿主 fallback 面,U+200D/U+FE0F 仍被 recovery parser 替换为 U+FFFD(只记录,不批准新策略);分段 helper 的规范数据通过不代表完整终端 Unicode 正确性;[#54](https://github.com/JavaZeroo/termforge/issues/54) 保留已观察的性能成本 | R2 / R4 | -| resize 与 reflow | **实现中,未验收**:默认仍按 ADR-0005 物理行裁剪;[显式保留列宽事务与显示投影](../experiments/core/results/2026-11-02-retained-columns/README.md)通过四例有限寻址诊断;[同 owner 宽高组合保留事务](../experiments/core/results/2026-11-02-retained-combined/README.md)把固定五阶段原版回放从 8/20 阶段修至 20/20,并注册核心门禁;正式 Qt 路径已接入该事务、独立显示页、显示光标与映射命中([只缩宽契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md) 12/12、Qt 正式 CTest 5/5),默认 resize 入口未替换;[隐藏主屏纯高度变化保留正文的工程合同](../experiments/core/results/2026-11-04-hidden-height-retention/README.md)已注册门禁,[原版七阶段补充审计](../research/ui/results/2026-11-02-resize-hidden-height-reference/README.md)显示返回主屏时按当前宽度裁剪隐藏行;该差异已由用户于 2026-11-04 确认保留完整正文并接受为产品有意差异([ADR-0010](adr/0010-bounded-resize-retention.md)),其正式 Qt 路径验收仍未完成。历史候选在[固定 ConPTY 重放](../experiments/core/results/2026-10-26-resize-replay/validation.json)与连续同步中保持行身份、Find/选区和有界历史,淘汰/清空不复活;候选 `.4` 另覆盖主/备用屏缩到单列 | [ADR-0010](adr/0010-bounded-resize-retention.md)已确认正文保留与显示投影方向,#27/#29 联合交叉仍未完成;[正式路径 B/CD 缩放诊断](../experiments/shell/results/2026-11-02-qt-terminal-modes/README.md)仍红且失败信息保留,已归类为原生重排网格与保留行坐标层差异,同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据;隐藏主屏高度的严格审计失败保留、离线补充审计已通过(单次七阶段,0 次重跑),返回时按当前宽度裁剪已确认为产品有意差异(保留完整正文,2026-11-04);正式路径验收仍待补。固定事务不等于完整 reflow 或 WindTerm 等价。[#156固定交错诊断](../experiments/shell/results/2026-10-29-resize-repaint-witness/README.md)见证默认核心在两种时序下选区失效,历史几何错配已有字节/消费网格证据;[#194](https://github.com/JavaZeroo/termforge/issues/194) 后续按用户接受未复现收口,未证明产品修复,重现时按该项条件重开。系统/实验 ConPTY、备用屏/滚动区、真实 DPI、呈现、性能及 #36/#38 分别验收;默认缩窄不可恢复 | R2 | +| resize 与 reflow | **实现中,未验收**:默认仍按 ADR-0005 物理行裁剪;[显式保留列宽事务与显示投影](../experiments/core/results/2026-11-02-retained-columns/README.md)通过四例有限寻址诊断;[同 owner 宽高组合保留事务](../experiments/core/results/2026-11-02-retained-combined/README.md)把固定五阶段原版回放从 8/20 阶段修至 20/20,并注册核心门禁;正式 Qt 路径已接入该事务、独立显示页、显示光标与映射命中([只缩宽契约](../experiments/shell/results/2026-11-03-qt-retained-display/README.md) 12/12、Qt 正式 CTest 5/5),默认 resize 入口未替换;[隐藏主屏纯高度变化保留正文的工程合同](../experiments/core/results/2026-11-04-hidden-height-retention/README.md)已注册门禁,[原版七阶段补充审计](../research/ui/results/2026-11-02-resize-hidden-height-reference/README.md)显示返回主屏时按当前宽度裁剪隐藏行;该差异已由用户于 2026-11-04 确认保留完整正文并接受为产品有意差异([ADR-0010](adr/0010-bounded-resize-retention.md)),正式 Qt / 固定 ConPTY 固定七阶段离屏路径已核对保留正文(58 检查,[结果](../experiments/shell/results/2026-11-04-qt-hidden-retention/README.md)),物理呈现/DPI 与完整 reflow 未验收。历史候选在[固定 ConPTY 重放](../experiments/core/results/2026-10-26-resize-replay/validation.json)与连续同步中保持行身份、Find/选区和有界历史,淘汰/清空不复活;候选 `.4` 另覆盖主/备用屏缩到单列 | [ADR-0010](adr/0010-bounded-resize-retention.md)已确认正文保留与显示投影方向,#27/#29 联合交叉仍未完成;[正式路径 B/CD 缩放诊断](../experiments/shell/results/2026-11-02-qt-terminal-modes/README.md)仍红且失败信息保留,已归类为原生重排网格与保留行坐标层差异,同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据;隐藏主屏高度的严格审计失败保留、离线补充审计已通过(单次七阶段,0 次重跑),返回时按当前宽度裁剪已确认为产品有意差异(保留完整正文,2026-11-04);固定七阶段正式路径已离屏核对,物理呈现/DPI、其他尺寸/连续输出与完整 reflow 仍待补。固定事务不等于完整 reflow 或 WindTerm 等价。[#156固定交错诊断](../experiments/shell/results/2026-10-29-resize-repaint-witness/README.md)见证默认核心在两种时序下选区失效,历史几何错配已有字节/消费网格证据;[#194](https://github.com/JavaZeroo/termforge/issues/194) 后续按用户接受未复现收口,未证明产品修复,重现时按该项条件重开。系统/实验 ConPTY、备用屏/滚动区、真实 DPI、呈现、性能及 #36/#38 分别验收;默认缩窄不可恢复 | R2 | | 本地 shell 选择 | **实现中,未验收**:`ShellLaunch` 分离可执行文件、参数、工作目录及 shell 命令差异;cmd、Windows PowerShell 5.1、PowerShell 7、显式 Ubuntu WSL 各有[两轮真实启动/目录/尺寸/正常退出证据](../experiments/shell/results/2026-10-24-r2-shell-lifecycle/validation.json),无效程序启动错误可见且可关闭后恢复 cmd。四 shell 测试已补 [WSL 原生尺寸程序返回后的新提示符同步](research/wsl-size-return.md),正常退出证据只在实际全部回收后写入;默认/system 的事件反例、原两轮及完整回归分别留证 | 仅声明上述固定入口和隔离目录;#80 原 hosted stage6 是否同因仍未知,#81 hosted 准备超时另行定位;任意 profile/参数、通用分屏/排序、全部运行时错误提示及管理员 shell 未完成 | R2;权限场景 R4 | | 常用 TUI | **实现中,未验收**:显式候选 + 系统 ConPTY 有固定 Vim 进入、编辑、60×12 resize、退出写盘、主屏恢复及光标回复的[产品证据](../experiments/core/CANDIDATE.md);More 尾部 ECH 与捕获端点有[限定修复](research/more-capture-ech.md) | [#28](https://github.com/JavaZeroo/termforge/issues/28) 的更多应用及完整输入/输出模式未完成;PR #50 实验后端的 GUI Vim [#52](https://github.com/JavaZeroo/termforge/issues/52) 仍有失败;[native 捕获目录复用](research/vim-fixture-isolation.md)已独立定位并修正,不能代替 GUI 或完整 TUI 验收。headless 比较与自有 DIB 不等于真实呈现,`presented_done=null` | R2 | diff --git a/docs/adr/0010-bounded-resize-retention.md b/docs/adr/0010-bounded-resize-retention.md index 480c9de9..4878a1a2 100644 --- a/docs/adr/0010-bounded-resize-retention.md +++ b/docs/adr/0010-bounded-resize-retention.md @@ -46,7 +46,7 @@ | 原版观察 | WindTerm 2.7.0 在上述单例七阶段路径返回时裁剪隐藏行 | [补充审计](../../research/ui/results/2026-11-02-resize-hidden-height-reference/README.md);旧严格失败保留,未重跑 | | 产品有意差异 | TermForge 返回主屏保留完整源正文 | 本节;用户 2026-11-04 确认 | | 工程回归 | `resize_hidden_height_test`(`retained_resize_hidden_height_core`,核心显式入口) | [#311 工程合同](../../experiments/core/results/2026-11-04-hidden-height-retention/README.md);工程不变量,不是原版 oracle | -| 正式路径验收 | 真实 child → 固定 ConPTY → Qt 路径上的同一场景 | **未完成**:既有 Qt 保留契约只覆盖仅缩宽、同高度(12/12),#311 的 Qt CTest 5/5 只是既有套件回归;留 #29 后续切片 | +| 正式路径验收 | 真实 child → 固定 ConPTY → Qt 路径上的同一场景 | **已通过固定七阶段离屏证据**([结果](../../experiments/shell/results/2026-11-04-qt-hidden-retention/README.md):58 检查、2 次运行一致,保留行 id/正文/历史前缀/显示映射保持);物理呈现/DPI、其他尺寸/Unicode/连续输出仍未验收 | **边界**: diff --git a/experiments/shell/results/2026-11-04-qt-hidden-retention/README.md b/experiments/shell/results/2026-11-04-qt-hidden-retention/README.md new file mode 100644 index 00000000..55eeece0 --- /dev/null +++ b/experiments/shell/results/2026-11-04-qt-hidden-retention/README.md @@ -0,0 +1,64 @@ +# 正式 Qt / 固定 ConPTY:隐藏帧七阶段完整正文保留 + +本切片把 2026-11-04 用户确认的“隐藏帧返回保留完整正文、接受 WindTerm 返回裁剪差异”合同贯通到**正式 Qt / 固定 ConPTY 路径**:真实 child 写入固定 91×20 主屏正文,公开 Qt 控件缩窄到 61 列,child 进入备用屏,仅改变高度(20→14→20),`1049l` 返回主屏,再恢复宽度到 91。逐阶段分别记录真实 child 尺寸/输出、固定 ConPTY transport、核心 owner/行身份/正文以及 Qt 显示页/光标/命中。 + +这是固定场景的**正式路径离屏证据**,不是原版 oracle,也不是物理呈现、DPI、IME、性能或完整 reflow 验收;#312 原严格失败、离线补充审计与 `semantic_acceptance=false` 均不改写。 + +## 场景与固定源 + +固定 75 B ASCII 源(与 #311 核心工程合同同一字面样本): + +```text +\x1b[?6l\x1b[?7h\x1b[r\x1b[0m\x1b[2J +\x1b[2;1HHTOP \x1b[6;1HHARD_A CRLF HARD_B \x1b[12;90Huvw \x1b[19;1HLOWEND +``` + +主屏文档第 11 行(零基)为 `89 空格 + uvw`(92 字符,按 91 列物理行 + 1 格软续接保存);第 18 行为 `LOWEND`。七阶段依次为:主屏正文 91×20 → 缩窄 61×20 → `1049h` 进入备用屏 → 仅缩高 61×14 → 恢复高度 61×20 → `1049l` 返回主屏 61×20 → 恢复宽度 91×20。 + +## 验证入口 + +新增 `qt_hidden_retention`(`src/qt/hidden_retention_test.cpp`、`src/qt/hidden_retention_fixture.hpp`):真实 child 通过独立命名管道逐阶段确认自己的控制台几何与选中原生行;测试只在 child ACK 后核对核心/显示,更不复用 resize 请求本身作为 child 确认。门禁已注册到 CTest 与 Qt workflow。 + +## 实测结果 + +本地 Windows 11 x64 定向构建、offscreen;正式 Qt 路径 CTest **7/7 通过(8.99 s,含新测试)**;新测试 **58 项检查、0 失败**,连续两次运行除 `child_pid` 外输出一致;核心回归 **41/41 通过(23.05 s)**。 + +| 阶段 | child CSBI | 核心 | 光标 | 正文/显示核对 | +|---|---|---|---|---| +| 1 主屏正文 | 91×20 | 91×20 primary | `[18,6]` | 保留行 id `12`;显示行 11=`89 空格+uv`、12=`w`、18=`LOWEND`;显示光标 `[18,6]`;命中 (11,89)→行 11 列 89 | +| 2 缩窄 | 61×20 | 61×20 primary | `[18,6]` | 源行仍为 91 Cell;显示行 11 空、12=`28 空格+uvw`、18=`LOWEND`;命中 (12,28)→行 11 列 89 | +| 3 进入备用屏 | 61×20 | 61×20 alternate | `[1,8]` | 备用屏显示 `ALT_MARK`;隐藏主屏正文=固定 92 字符行,行 id/光标不变;正常追加历史为空 | +| 4 仅缩高 | 61×14 | 61×14 alternate | `[2,10]` | 备用屏 `ALT_SHRUNK`;隐藏主屏前缀 5 行进入历史、保留行仍为 91 Cell、正文完整、行 id 不变、光标行随前缀移动 | +| 5 恢复高度 | 61×20 | 61×20 alternate | `[2,12]` | 备用屏 `ALT_RESTORED`;隐藏主屏正文/历史/行 id 不变,未被回拉或重写 | +| 6 1049l 返回 | 61×20 | 61×20 primary | `[13,6]` | 历史 5 行 + live 20 行;正文 SHA 与阶段 1 相同(`0e683ff0…`);显示行 7=`28 空格+uvw`、13=`LOWEND`;显示光标 `[13,6]`;命中 (7,28)→保留行 11 列 89 | +| 7 恢复宽度 | 91×20 | 91×20 primary | `[13,6]` | 正文 SHA 仍相同;显示行 6=`89 空格+uv`、7=`w`、13=`LOWEND`;命中 (6,89)→保留行 11 列 89 | + +关键事实: + +- 本切片未修改生产实现,也未发现正式路径丢失保留正文的 RED;初次运行的失败来自新测试自身的显示行预期与夹具原生行读取坐标错误,修正的是测试/夹具,不是核心保留行为。 +- 阶段 1/2/6/7 的逻辑正文 SHA-256 均为 `0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e`,即隐藏高度变化与 1049 返回未丢失用户已确认的完整正文。 +- 隐藏主屏只在宽度实际变化时适配足迹;仅高度变化保留 91-Cell 源行。返回主屏后源行仍可按当前宽度投影为完整的 92 字符逻辑行。 +- 显示页、显示光标与命中均消费同一保留源身份;核心 owner/行 id `12` 与历史结构在七阶段中保持。 +- child 原生行单独记录:阶段 6/7 原生第 11 行为空(ConPTY 原生重排网格),而产品保留源行完整;**原生网格不是已确认保留文本的 oracle**,本记录不把两者混为同一对照。 + +## 复现 + +```powershell +cmake -S . -B build/qt-cpp -G "Visual Studio 17 2022" -A x64 ` + -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF ` + -DQt6_DIR="/qt/lib/cmake/Qt6" ` + -DTERMFORGE_CONPTY_BACKEND_DIR="/conpty" ` + -DTERMFORGE_MSVC_RUNTIME_DIR="/crt" +cmake --build build/qt-cpp --config Release --target qt_hidden_retention_test --parallel 2 +ctest --test-dir build/qt-cpp -C Release -R '^qt_hidden_retention$' --output-on-failure +ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?|qt_hidden_retention)$' --output-on-failure +``` + +环境、依赖身份、逐阶段记录、源码/产物哈希见 [validation.json](validation.json);公开样例见 [hidden-retention.json](hidden-retention.json)(仅含固定源、阶段标量与 `child_pid`,无私有路径)。 + +## 边界 + +- 仅离屏证据:非物理呈现(`presented_done=null`)、非真实 DPI/焦点、非原生 IME、非性能验收。 +- 仅一个固定 75 B ASCII 样本与上述七阶段尺寸;不覆盖其他尺寸、连续输出、宽字/Unicode/结合字、保存光标交叉、历史淘汰上限、选择/Find/历史浏览用户流程、完整 TUI 或同时缩宽+缩高的原版视口/滚动/CUP。 +- 保留正文是用户 2026-11-04 已确认的**有意差异**;原版观察、严格审计失败、补充审计与 `semantic_acceptance=false` 保持原身份,不为匹配原版丢失结果而裁剪正文。 +- #27/#29/#30/#7 仍 OPEN;不声明完整 resize/reflow、WindTerm 等价或 R2 完成。默认 `Screen::resize` 的物理裁剪行为未改变。 diff --git a/experiments/shell/results/2026-11-04-qt-hidden-retention/hidden-retention.json b/experiments/shell/results/2026-11-04-qt-hidden-retention/hidden-retention.json new file mode 100644 index 00000000..0a9374e0 --- /dev/null +++ b/experiments/shell/results/2026-11-04-qt-hidden-retention/hidden-retention.json @@ -0,0 +1,132 @@ +{ + "backend": "cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 91x20", + "checks": 58, + "child_pid": 41356, + "failures": 0, + "fixed_formal_path_scenario": true, + "offscreen_only": true, + "original_behavior_oracle": false, + "physical_presentation": false, + "presented_done": null, + "r27_complete": false, + "r29_complete": false, + "retained_row_id": 12, + "scenario": "primary-91x20-narrow-61-1049h-height-14-20-1049l-widen-91", + "schema": "termforge-qt-hidden-retention-run-v1", + "source_bytes": 75, + "stages": [ + { + "body_sha256": "0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e", + "core_alternate": false, + "core_columns": 91, + "core_cursor": [ + 18, + 6 + ], + "core_rows": 20, + "native_columns": 91, + "native_row_11_hex": "20202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020207576", + "native_row_18_hex": "4c4f57454e44", + "native_rows": 20, + "presented_done": null, + "stage": 1 + }, + { + "body_sha256": "0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e", + "core_alternate": false, + "core_columns": 61, + "core_cursor": [ + 18, + 6 + ], + "core_rows": 20, + "native_columns": 61, + "native_row_11_hex": "", + "native_row_18_hex": "4c4f57454e44", + "native_rows": 20, + "presented_done": null, + "stage": 2 + }, + { + "body_sha256": "014b988ab50ec1f18d053946673dab3f52ae70d154bb30c26f79efd6d114c171", + "core_alternate": true, + "core_columns": 61, + "core_cursor": [ + 1, + 8 + ], + "core_rows": 20, + "native_columns": 61, + "native_row_11_hex": "", + "native_row_18_hex": "", + "native_rows": 20, + "presented_done": null, + "stage": 3 + }, + { + "body_sha256": "341aa5e43b3a9ffa9d04043386f01ee18f08dade4b10cca568a1ebb5c89ee030", + "core_alternate": true, + "core_columns": 61, + "core_cursor": [ + 2, + 10 + ], + "core_rows": 14, + "native_columns": 61, + "native_row_11_hex": "", + "native_row_18_hex": "", + "native_rows": 14, + "presented_done": null, + "stage": 4 + }, + { + "body_sha256": "e085c7cef2438b5acf9cd04ed2cc311e7b6ebdd9fbf3fe49e74eba6610a93e3f", + "core_alternate": true, + "core_columns": 61, + "core_cursor": [ + 2, + 12 + ], + "core_rows": 20, + "native_columns": 61, + "native_row_11_hex": "", + "native_row_18_hex": "", + "native_rows": 20, + "presented_done": null, + "stage": 5 + }, + { + "body_sha256": "0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e", + "core_alternate": false, + "core_columns": 61, + "core_cursor": [ + 13, + 6 + ], + "core_rows": 20, + "native_columns": 61, + "native_row_11_hex": "", + "native_row_18_hex": "4c4f57454e44", + "native_rows": 20, + "presented_done": null, + "stage": 6 + }, + { + "body_sha256": "0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e", + "core_alternate": false, + "core_columns": 91, + "core_cursor": [ + 13, + 6 + ], + "core_rows": 20, + "native_columns": 91, + "native_row_11_hex": "20202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020207576", + "native_row_18_hex": "4c4f57454e44", + "native_rows": 20, + "presented_done": null, + "stage": 7 + } + ], + "windterm_equivalence": false +} diff --git a/experiments/shell/results/2026-11-04-qt-hidden-retention/validation.json b/experiments/shell/results/2026-11-04-qt-hidden-retention/validation.json new file mode 100644 index 00000000..acadbc20 --- /dev/null +++ b/experiments/shell/results/2026-11-04-qt-hidden-retention/validation.json @@ -0,0 +1,126 @@ +{ + "schema": "termforge-qt-hidden-retention-validation-v1", + "source_commit": "185c893e64f43c69c73d9be2de69ff7dddb0106c", + "source_tree": "1d7a425fa5d168b35636d261dd4e5c5fbd5b839f", + "source_binding": "The fixture, test, CMake registration and Qt workflow wiring were committed before the final local builds and regressions; this validation file is a documentation follow-up. The working tree was clean at the start of verification, and the recorded source hashes are the committed (LF) blob contents.", + "scope": "One fixed 75 B ASCII hidden-frame scenario through the formal Qt / fixed-ConPTY path: real child primary body at 91x20, public Qt width shrink to 61, alternate-screen entry, height-only 20->14->20 changes, 1049l return at 61, and width restore to 91. Checks retained source body, owner/line identity, history prefix, display page, display caret and hit mapping; the child native grid is recorded separately and is not the retained-body oracle.", + "passed": true, + "scoped_validation_passed": true, + "original_behavior_oracle": false, + "semantic_acceptance": false, + "intentional_difference_confirmed": true, + "physical_presentation_verified": false, + "native_ime_verified": false, + "offscreen_only": true, + "windterm_parity": false, + "r27_complete": false, + "r29_complete": false, + "r2_complete": false, + "presented_done": null, + "environment": { + "os": "Windows 11 x64 build 26200", + "compiler": "MSVC 19.42.34433.0", + "qt": "6.10.3 win64_msvc2022_64, offscreen platform", + "conpty": "1.25.260930003", + "conpty_files": { + "conpty.dll": "feeef341d891643c62d30b6b07800bc70f0bc148f44cb8c3bee84aa557ae805a", + "OpenConsole.exe": "3d66b23d0a71bb8eed2b77edc8b9df9bf54ce6c8fb74c863a30e760997f80586" + }, + "msvc_runtime": "14.51.36247.0", + "python": "3.12.10", + "configuration": "Release, Visual Studio 17 2022, offscreen, owned Job" + }, + "dependencies": { + "preparation": "Prepared dependency tree reused from the just-merged #313 verification slice; the pinned input manifests, archive identities and SDK tree identity are recorded below. CI prepares the same inputs independently.", + "qt_archive_sha256": "4db84dee7fe3c558f242bef0a88852613af76580dc6d2b24596479f47004dad7", + "sdk_tree_sha256": "a35ab6fe76b62e4d9bd6d3c6b0d476c63583070cea5c9118ef5bb02042ffe266", + "conpty_package_sha256": "6d6f8b008c655d814c498d8205d4012dcd4d0b6dfc1096461d0a185d1e3caf03", + "msvc_runtime_package_sha256": "843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c", + "input_manifests": { + "qt": "2796294f254f7fe0370be99d2de8f7745734c7352ac2648b1f8d9a8fab5ad2cd", + "conpty": "e75e522a3355c0a66c6d60cafa02413a83d8757262747f650b77c959c09ed439", + "msvc_runtime": "154176c8b4ae6dd57fce453e2c2002ff484344b39451fa9880b07db4794f3758" + } + }, + "commands": { + "configure": "cmake -S . -B build/qt-cpp -G \"Visual Studio 17 2022\" -A x64 -DTERMFORGE_BUILD_QT_SHELL=ON -DBUILD_TESTING=ON -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF -DQt6_DIR=/qt/lib/cmake/Qt6 -DTERMFORGE_CONPTY_BACKEND_DIR=/conpty -DTERMFORGE_MSVC_RUNTIME_DIR=/crt", + "new_target": "cmake --build build/qt-cpp --config Release --target qt_hidden_retention_test --parallel 2", + "new_test": "ctest --test-dir build/qt-cpp -C Release -R '^qt_hidden_retention$' --output-on-failure", + "qt_regression": "ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?|qt_hidden_retention)$' --output-on-failure", + "core_regression": "cmake --build build --config Release --parallel 4 && ctest --test-dir build -C Release -R '^(.*_core|core|resize_replay_contract|recovery|keyboard)$' --output-on-failure" + }, + "results": { + "qt_formal_path_ctest": { + "tests": [ + "qt_cpp_shell", + "qt_unicode_input", + "qt_unicode_render", + "qt_cpp_resize", + "qt_terminal_modes", + "qt_terminal_modes_retained", + "qt_hidden_retention" + ], + "passed": 7, + "failed": 0, + "seconds": 8.99 + }, + "core_ctest": { + "includes": "every *_core native check, core, keyboard, recovery and resize_replay_contract", + "passed": 41, + "failed": 0, + "seconds": 23.05 + }, + "new_test_runs": { + "supervisor": "termforge-qt-cpp-supervisor-v1", + "runs": 2, + "both_passed": true, + "job_after": {"active": 0, "total": 4, "terminated": 0}, + "checks": 58, + "failures": 0, + "content_identical_ignoring_child_pid": true, + "nondeterministic_fields": ["child_pid"], + "ctest_seconds": 0.44 + } + }, + "product_identity": { + "backend": "cpp-core / qt-widgets / reviewed-side-by-side-1.25.260930003 / 91x20", + "retained_row_id": 12, + "fixed_source_bytes": 75, + "source_sha256": "ff48cd41220a72e5da2350dc97c3c8c8c884d48cc4f7fa2328609bf8bc0cb09e", + "retained_body_sha256_phases_1_2_6_7": "0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e" + }, + "stages": [ + {"stage": 1, "native": [91, 20], "core": [91, 20], "alternate": false, "core_cursor": [18, 6], "body_sha256": "0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e"}, + {"stage": 2, "native": [61, 20], "core": [61, 20], "alternate": false, "core_cursor": [18, 6], "body_sha256": "0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e"}, + {"stage": 3, "native": [61, 20], "core": [61, 20], "alternate": true, "core_cursor": [1, 8]}, + {"stage": 4, "native": [61, 14], "core": [61, 14], "alternate": true, "core_cursor": [2, 10]}, + {"stage": 5, "native": [61, 20], "core": [61, 20], "alternate": true, "core_cursor": [2, 12]}, + {"stage": 6, "native": [61, 20], "core": [61, 20], "alternate": false, "core_cursor": [13, 6], "body_sha256": "0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e"}, + {"stage": 7, "native": [91, 20], "core": [91, 20], "alternate": false, "core_cursor": [13, 6], "body_sha256": "0e683ff0a35c4b0a07acd6c5ca015280f99e3586911bc1d3da7f40252b3ff50e"} + ], + "display_observations": { + "phase_1": {"caret": [18, 6], "row_11": "89 spaces + uv", "row_12": "w", "row_18": "LOWEND", "hit_row_column": [11, 89]}, + "phase_2": {"caret": [18, 6], "row_11": "", "row_12": "28 spaces + uvw", "row_18": "LOWEND", "hit_row_column": [12, 28]}, + "phase_6": {"caret": [13, 6], "row_6": "", "row_7": "28 spaces + uvw", "row_13": "LOWEND", "hit_row_column": [7, 28]}, + "phase_7": {"caret": [13, 6], "row_6": "89 spaces + uv", "row_7": "w", "row_13": "LOWEND", "hit_row_column": [6, 89]} + }, + "source_hashes": { + "src/qt/hidden_retention_fixture.hpp": "d55bdd7f0dc0dc3d19c7966b3a79f152b0a3d4a62cab28b5c3a5d7868d4ccf83", + "src/qt/hidden_retention_test.cpp": "adb5f8d555847c5f43fbd7c1c619566dc8b72da04f401c77d64ae921b6da3fe3", + "CMakeLists.txt": "c4b1f9f8c00f89b6aca41aadd4c78825cdf9cc4b64dfd37e386ceffe3a00ddec", + ".github/workflows/qt-cpp.yml": "164cdd67f9da238dbc0c75ec8e434f6377ea9e899a62052610c1283573c7da77" + }, + "artifact_hashes": { + "hidden-retention.json": "c107a11d90384ddc0a6e0f36273f1fbe98bd2fddff96b813c385e35a36da0ba4", + "qt_hidden_retention_test.exe": "063eb4d46e579c20ff536037612d59711fb906292ba0e18cc365e4ea45366e78" + }, + "implementation_sources_match_recorded_commit": true, + "limitations": [ + "Offscreen local evidence only: not physical presentation, not a presented-frame endpoint (presented_done=null), not real 96/144 DPI, not native IME, not a physical keyboard or performance measurement.", + "One fixed 75 B ASCII sample and the literal 91x20 -> 61x20 -> 61x14 -> 61x20 -> 61x20 -> 91x20 sequence; no other sizes, continuous output, wide/Unicode/combining text, saved-cursor cross, history eviction limit, selection/copy/Find/history user flow or complete TUI.", + "The child native grid and selected native rows are recorded as a separate transport observation; they are not the oracle for the user-confirmed retained body, and no native grid equivalence is claimed.", + "The original WindTerm hidden-height observation, its preserved strict audit failure, the offline supplement and semantic_acceptance=false are unchanged; this product evidence does not reclassify them or remove the confirmed intentional difference.", + "The formal-path scenario does not close #27/#29/#30/#7, does not claim complete resize/reflow or WindTerm equivalence, and does not change the default Screen::resize physical-clipping behavior.", + "The local build reused the just-merged #313 prepared dependency tree identity; the Qt workflow prepares the same pinned manifests independently on CI." + ] +} diff --git a/src/qt/README.md b/src/qt/README.md index b2aa5dcd..8ac8e55d 100644 --- a/src/qt/README.md +++ b/src/qt/README.md @@ -6,7 +6,7 @@ 网格沿当前 Roboto Mono 字体度量和终端可用区域计算,保留八像素边距与一行状态区,并沿现有 Screen 资源上限限制为 1..500 列、1..200 行。字体变化重新计算度量;隐藏标签在显示/布局后同步。GUI 线程负责核心尺寸事务,现有生命周期 worker 用单个最新请求串行执行 ConPTY resize、wait、cancel 和 close;GUI 不等待 native resize。重复同尺寸不重新初始化屏幕或发起新尺寸操作;关闭、错误与退出停止接受并清除待处理请求,已选取的 native 操作由同一 worker 收尾,不重新激活会话。 -正式路径的尺寸事务改用 `Screen::resize_preserving`:变窄时保留源 Cell、行身份与软续接,不立即销毁右侧正文。绘制消费者按当前宽度生成独立显示投影页(`HistoryViewport::project`),协议光标只被翻译为显示光标,供光标下划线、IME 候选位置与预编辑预览使用;选择/命中继续消费同一份 `ViewMapping`,并用页面 layout revision 拒绝过期页。不支持的保留 Cell 或无法映射的光标回退到物理视图页,避免隐藏输出。当前验证与仍存差异见[固定结果](../../experiments/shell/results/2026-11-03-qt-retained-display/README.md):只缩宽的显示重排与保留行合同已通过真实 child/固定 ConPTY 核对;旧六阶段 native-sync 诊断仍失败并保留,原生重排网格不被当作产品寻址 oracle。同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据,完整 reflow 和历史/选择/Find 用户流程未验收。 +正式路径的尺寸事务改用 `Screen::resize_preserving`:变窄时保留源 Cell、行身份与软续接,不立即销毁右侧正文。绘制消费者按当前宽度生成独立显示投影页(`HistoryViewport::project`),协议光标只被翻译为显示光标,供光标下划线、IME 候选位置与预编辑预览使用;选择/命中继续消费同一份 `ViewMapping`,并用页面 layout revision 拒绝过期页。不支持的保留 Cell 或无法映射的光标回退到物理视图页,避免隐藏输出。当前验证与仍存差异见[固定结果](../../experiments/shell/results/2026-11-03-qt-retained-display/README.md):只缩宽的显示重排与保留行合同已通过真实 child/固定 ConPTY 核对;旧六阶段 native-sync 诊断仍失败并保留,原生重排网格不被当作产品寻址 oracle。2026-11-04 又补[隐藏帧七阶段正式路径离屏证据](../../experiments/shell/results/2026-11-04-qt-hidden-retention/README.md):主屏正文→缩窄→1049h→仅缩高→恢复→1049l→恢复宽度的保留正文、行身份、显示页/光标/命中通过 58 项检查;child 原生网格仅记录,不是保留文本 oracle。同时缩宽+缩高的原版视口/滚动与后续 CUP 仍缺独立证据,物理呈现/DPI、完整 reflow 和历史/选择/Find 用户流程未验收。 终端可在无修饰、无组合文本时用左键正向或反向拖选,普通点击清除旧选区。每个 widget 独立持有 `TextSelection`,命中与高亮经 `HistoryViewport` 显示快照中的 `ViewMapping` 映射到文档位置;只装饰显示副本,不修改核心文字或样式。部分列选区只投影到相应列,避免整行高亮。该接线是可见网格的基础拖选,不包含历史浏览、系统剪贴板、选词/选行或 Find;[固定实现与验证边界](../../experiments/shell/results/2026-11-01-r2-core-input/README.md)与完整 #29/#30 出口分开。 @@ -25,9 +25,9 @@ cmake -S . -B build/qt-cpp -G "Visual Studio 17 2022" -A x64 ` -DQt6_DIR="/lib/cmake/Qt6" ` -DTERMFORGE_CONPTY_BACKEND_DIR="" ` -DTERMFORGE_MSVC_RUNTIME_DIR="" -cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_terminal_modes_test +cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_terminal_modes_test qt_hidden_retention_test build/qt-cpp/qt-bin/Release/termforge_qt_shell.exe -ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?)$' --output-on-failure +ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_terminal_modes(_retained)?|qt_hidden_retention)$' --output-on-failure ``` 当前主线注册的 Qt CTest 包括上述五项与 `qt_terminal_modes_retained`(只缩宽的保留/投影契约)、`qt_unicode_render`(固定 Unicode 样本的独立接收 + 离屏绘制/fallback 记录);IME 合成事件检查在 `qt_cpp_shell` 内。历史冻结包的 `qt_ime_input_test.exe` 不属于当前主线构建目标,不用于替代这组验证。旧六阶段 `--resize` 仍以显式诊断运行并保留失败,不注册进默认 CI。 diff --git a/src/qt/hidden_retention_fixture.hpp b/src/qt/hidden_retention_fixture.hpp new file mode 100644 index 00000000..060e67d2 --- /dev/null +++ b/src/qt/hidden_retention_fixture.hpp @@ -0,0 +1,186 @@ +// Test-only fixed hidden-frame source and independent child acknowledgements. +// +// The child owns its real fixed-ConPTY console. It writes the literal seven +// stage sources, then reports its own console-buffer geometry and selected +// native rows. Those native rows are a separate transport observation, not the +// oracle for the user-confirmed retained primary body. +#pragma once +#include "resize_fixture.hpp" +#include +#include + +namespace termforge::qt::test { + +inline std::string_view hidden_retention_source(unsigned stage) { + switch (stage) { + case 0: return {}; + case 1: return "\x1b[?6l\x1b[?7h\x1b[r\x1b[0m\x1b[2J" + "\x1b[2;1HHTOP\x1b[6;1HHARD_A\r\nHARD_B\x1b[12;90Huvw\x1b[19;1HLOWEND"; + case 2: return {}; + case 3: return "\x1b[?1049h\x1b[0m\x1b[2J\x1b[2;1HALT_MARK"; + case 4: return "\x1b[3;1HALT_SHRUNK"; + case 5: return "\x1b[3;1HALT_RESTORED"; + case 6: return "\x1b[?1049l"; + case 7: return {}; + default: throw std::runtime_error("Hidden-retention fixture stage invalid"); + } +} + +// Selected zero-based native rows, bounded by the literal 91x24 fixture. +inline constexpr int hidden_retention_rows[] = {0, 5, 6, 11, 12, 18}; + +struct HiddenRetentionReply { + uint32_t magic = 0x54464852, stage = 0, child_pid = 0, source_bytes = 0; + CONSOLE_SCREEN_BUFFER_INFO info{}; + struct Row { + int32_t row = -1; + uint32_t present = 0; + std::array cells{}; + }; + std::array rows{}; +}; + +class HiddenRetentionFixture final { + tf_conpty::Handle pipe_; + std::wstring name_; + bool connected_ = false; + DWORD client_pid_ = 0; +public: + HiddenRetentionFixture() { + static std::atomic sequence{0}; + name_ = L"\\\\.\\pipe\\TermForgeQtHiddenRetention-v1-" + std::to_wstring(GetCurrentProcessId()) + + L"-" + std::to_wstring(GetTickCount64()) + L"-" + std::to_wstring(++sequence); + pipe_.reset(CreateNamedPipeW(name_.c_str(), PIPE_ACCESS_INBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_NOWAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, 65536, 65536, 0, nullptr)); + if (pipe_.get() == INVALID_HANDLE_VALUE) tf_conpty::fail("CreateNamedPipe(hidden-retention fixture)"); + if (!ConnectNamedPipe(pipe_.get(), nullptr)) { + const DWORD error = GetLastError(); + if (error != ERROR_PIPE_LISTENING && error != ERROR_PIPE_CONNECTED) + tf_conpty::fail("ConnectNamedPipe(hidden-retention fixture)", error); + } + } + const std::wstring& pipe_name() const { return name_; } + std::optional take_reply() { + if (!connected_) { + if (ConnectNamedPipe(pipe_.get(), nullptr)) return std::nullopt; + const DWORD error = GetLastError(); + if (error == ERROR_PIPE_LISTENING) return std::nullopt; + if (error != ERROR_PIPE_CONNECTED) tf_conpty::fail("ConnectNamedPipe(hidden-retention reply)", error); + ULONG client = 0, session = 0; DWORD owner_session = 0; + if (!GetNamedPipeClientProcessId(pipe_.get(), &client) || + !GetNamedPipeClientSessionId(pipe_.get(), &session) || + !ProcessIdToSessionId(GetCurrentProcessId(), &owner_session)) + tf_conpty::fail("GetNamedPipeClientIdentity(hidden-retention fixture)"); + if (!client || session != owner_session) + throw std::runtime_error("Hidden-retention fixture client mismatch"); + client_pid_ = client; connected_ = true; + } + DWORD available = 0, message = 0; + if (!PeekNamedPipe(pipe_.get(), nullptr, 0, nullptr, &available, &message)) + tf_conpty::fail("PeekNamedPipe(hidden-retention reply)"); + if (!available) return std::nullopt; + if (message != sizeof(HiddenRetentionReply) || available < sizeof(HiddenRetentionReply)) + throw std::runtime_error("Hidden-retention reply length mismatch"); + HiddenRetentionReply reply; DWORD count = 0; + if (!ReadFile(pipe_.get(), &reply, sizeof(reply), &count, nullptr)) + tf_conpty::fail("ReadFile(hidden-retention reply)"); + if (count != sizeof(reply) || reply.magic != 0x54464852 || reply.child_pid != client_pid_ || + reply.stage > 7 || reply.source_bytes != hidden_retention_source(reply.stage).size() || + reply.info.dwSize.X < 1 || reply.info.dwSize.X > 91 || + reply.info.dwSize.Y < 1 || reply.info.dwSize.Y > 24) + throw std::runtime_error("Hidden-retention reply identity/source/geometry mismatch"); + for (size_t index = 0; index < std::size(reply.rows); ++index) { + if (reply.rows[index].row != hidden_retention_rows[index] || + (reply.rows[index].present && reply.rows[index].row >= reply.info.dwSize.Y)) + throw std::runtime_error("Hidden-retention reply row identity mismatch"); + } + return reply; + } +}; + +inline int hidden_retention_child(const std::wstring& pipe_name) { + const std::wstring prefix = L"\\\\.\\pipe\\TermForgeQtHiddenRetention-v1-"; + if (pipe_name.rfind(prefix, 0) != 0 || pipe_name.size() > 180) + throw std::runtime_error("Hidden-retention pipe name invalid"); + const auto separator = pipe_name.find(L'-', prefix.size()); + if (separator == std::wstring::npos) throw std::runtime_error("Hidden-retention owner missing"); + std::string digits; + for (const wchar_t c : pipe_name.substr(prefix.size(), separator - prefix.size())) { + if (c < L'0' || c > L'9') throw std::runtime_error("Hidden-retention owner invalid"); + digits += static_cast(c); + } + const uint64_t owner = size_nonce(digits); + BOOL owned = FALSE; + if (owner > MAXDWORD || !IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) + throw std::runtime_error("Hidden-retention child requires its owned Session Job"); + tf_conpty::Handle pipe(CreateFileW(pipe_name.c_str(), GENERIC_WRITE | FILE_WRITE_ATTRIBUTES, + 0, nullptr, OPEN_EXISTING, 0, nullptr)); + if (pipe.get() == INVALID_HANDLE_VALUE) tf_conpty::fail("CreateFile(hidden-retention child pipe)"); + ULONG server = 0, server_session = 0; DWORD child_session = 0; + if (!GetNamedPipeServerProcessId(pipe.get(), &server) || + !GetNamedPipeServerSessionId(pipe.get(), &server_session) || + !ProcessIdToSessionId(GetCurrentProcessId(), &child_session)) + tf_conpty::fail("GetNamedPipeServerIdentity(hidden-retention child)"); + if (server != owner || server_session != child_session) + throw std::runtime_error("Hidden-retention fixture server mismatch"); + DWORD pipe_mode = PIPE_NOWAIT; + if (!SetNamedPipeHandleState(pipe.get(), &pipe_mode, nullptr, nullptr)) + tf_conpty::fail("SetNamedPipeHandleState(hidden-retention child)"); + tf_conpty::Handle input(CreateFileW(L"CONIN$", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr)); + tf_conpty::Handle output(CreateFileW(L"CONOUT$", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr)); + if (input.get() == INVALID_HANDLE_VALUE || output.get() == INVALID_HANDLE_VALUE) + tf_conpty::fail("CreateFile(hidden-retention child console)"); + if (!SetConsoleMode(input.get(), ENABLE_VIRTUAL_TERMINAL_INPUT) || !SetConsoleOutputCP(CP_UTF8) || + !SetConsoleMode(output.get(), ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT | + ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN)) + tf_conpty::fail("SetConsoleMode(hidden-retention child)"); + const auto reply = [&](unsigned stage) { + HiddenRetentionReply value; value.stage = stage; value.child_pid = GetCurrentProcessId(); + value.source_bytes = static_cast(hidden_retention_source(stage).size()); + tf_conpty::Handle active(CreateFileW(L"CONOUT$", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr)); + if (active.get() == INVALID_HANDLE_VALUE || !GetConsoleScreenBufferInfo(active.get(), &value.info)) + tf_conpty::fail("GetConsoleScreenBufferInfo(hidden-retention child)"); + const auto size = value.info.dwSize; + if (size.X < 1 || size.X > 91 || size.Y < 1 || size.Y > 24) + throw std::runtime_error("Hidden-retention child native grid exceeds fixed bound"); + for (size_t index = 0; index < std::size(value.rows); ++index) { + auto& row = value.rows[index]; + row.row = hidden_retention_rows[index]; + if (row.row >= size.Y) continue; + SMALL_RECT rect{0, static_cast(row.row), static_cast(size.X - 1), + static_cast(row.row)}; + if (!ReadConsoleOutputW(active.get(), row.cells.data(), {91, 1}, + {0, 0}, &rect) || + rect.Left != 0 || rect.Top != row.row || rect.Right != size.X - 1 || rect.Bottom != row.row) + tf_conpty::fail("ReadConsoleOutputW(hidden-retention child)"); + row.present = 1; + } + DWORD count = 0; + if (!WriteFile(pipe.get(), &value, sizeof(value), &count, nullptr) || count != sizeof(value)) + tf_conpty::fail("WriteFile(hidden-retention child reply)"); + }; + unsigned stage = 0; + reply(stage); + for (;;) { + char key = 0; DWORD count = 0; + if (!ReadFile(input.get(), &key, 1, &count, nullptr) || count != 1) + tf_conpty::fail("ReadFile(hidden-retention child input)"); + if (key == 'x' && stage == 7) return 37; + if (key != static_cast('1' + stage) || stage >= 7) + throw std::runtime_error("Hidden-retention child input stage out of order"); + ++stage; + const auto source = hidden_retention_source(stage); + if (!source.empty()) { + DWORD written = 0; + if (!WriteFile(output.get(), source.data(), static_cast(source.size()), &written, nullptr) || + written != source.size()) tf_conpty::fail("WriteFile(hidden-retention child source)"); + } + reply(stage); + } +} + +} // namespace termforge::qt::test diff --git a/src/qt/hidden_retention_test.cpp b/src/qt/hidden_retention_test.cpp new file mode 100644 index 00000000..064ff562 --- /dev/null +++ b/src/qt/hidden_retention_test.cpp @@ -0,0 +1,558 @@ +// Formal Qt / fixed-ConPTY hidden-frame retention: seven fixed phases. +// +// A real child writes a fixed primary body at 91x20, the public Qt widget +// narrows it to 61 columns, the child enters the alternate screen, only the +// height changes (14 -> 20), the child returns with 1049l, and the width is +// restored. Each resize request is separate from the child's independent +// acknowledgement. The retained source body, line identity, display page, +// display caret and hit mapping are checked against literal expectations; the +// child's native grid is recorded as a separate transport observation and is +// not used as the retained-body oracle. +#include "terminal_widget.hpp" +#include "hidden_retention_fixture.hpp" +#include "../../experiments/core/text_document.hpp" +#include "../../experiments/core/text_projection.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using termforge::Screen; +using termforge::ScreenBuffer; +using termforge::TextDocument; +using termforge::qt::SessionModel; +using termforge::qt::TerminalWidget; +using termforge::qt::test::HiddenRetentionFixture; +using termforge::qt::test::HiddenRetentionReply; + +namespace { +std::string child_log_path; +void child_log(const std::string& text) { + if (child_log_path.empty()) return; + std::ofstream output(child_log_path, std::ios::trunc); + if (output) output << text << '\n'; +} +void require(bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} +std::string hex(std::string_view bytes) { + static constexpr char digits[] = "0123456789abcdef"; + std::string result; + for (const unsigned char byte : bytes) { + result += digits[byte >> 4]; result += digits[byte & 15]; + } + return result; +} +std::string sha256_hex(std::string_view bytes) { + return QCryptographicHash::hash(QByteArray(bytes.data(), static_cast(bytes.size())), + QCryptographicHash::Sha256).toHex().toStdString(); +} +void omit_empty_tail(std::vector& lines) { + while (!lines.empty() && lines.back().empty()) lines.pop_back(); +} +std::vector source_lines(const TextDocument& document) { + if (document.size() > 200) throw std::runtime_error("source exceeds the bounded fixture"); + std::vector result; + std::string logical; + for (size_t rank = 0; rank < document.size(); ++rank) { + const auto line = document.line(rank); + logical += line.projection.text; + if (!line.joins_next) { + result.push_back(std::move(logical)); + logical.clear(); + } + } + if (!logical.empty()) result.push_back(std::move(logical)); + omit_empty_tail(result); + return result; +} +std::vector body_of(const Screen& screen) { + return source_lines(TextDocument(screen)); +} +std::vector expected_body() { + std::vector result = { + "", "HTOP", "", "", "", "HARD_A", "HARD_B", "", "", "", "", + std::string(89, ' ') + "uvw", "", "", "", "", "", "LOWEND", "" + }; + omit_empty_tail(result); + return result; +} +std::string body_sha256(const std::vector& body) { + std::string joined; + for (const auto& line : body) { joined += line; joined += '\n'; } + return sha256_hex(joined); +} +// The hidden primary frame is inspected through an owner copy so the active +// frame and the original screen guards are not switched for the assertion. +Screen hidden_frame(const Screen& screen) { + Screen copy(screen); + if (screen.active_buffer() != ScreenBuffer::primary) copy.activate_buffer(ScreenBuffer::primary); + return copy; +} +std::string line_text(const termforge::Line& line) { + return termforge::project_line(line).text; +} +std::string display_row(const termforge::ViewSnapshot& page, size_t row) { + if (row >= page.lines.size()) return {}; + std::string text; + for (const auto& cell : page.lines[row]) text += cell.text; + while (!text.empty() && text.back() == ' ') text.pop_back(); + return text; +} +std::string native_row(const HiddenRetentionReply& reply, int row) { for (const auto& selected : reply.rows) { + if (selected.row != row) continue; + if (!selected.present) return {}; + std::string text; + for (int column = 0; column < reply.info.dwSize.X && column < 91; ++column) + text += static_cast(selected.cells[static_cast(column)].Char.UnicodeChar); + while (!text.empty() && text.back() == ' ') text.pop_back(); + return text; + } + return {}; +} +struct StageRecord { + unsigned stage = 0; + int native_columns = 0, native_rows = 0; + int core_columns = 0, core_rows = 0; + int core_row = 0, core_column = 0; + bool alternate = false; + std::string body_sha256; + std::string native_row_11, native_row_18; +}; +class Checks { + size_t& failures_; + size_t& checks_; +public: + Checks(size_t& failures, size_t& checks) : failures_(failures), checks_(checks) {} + void operator()(bool condition, const char* message) { + ++checks_; + if (condition) return; + ++failures_; + std::cerr << "hidden-retention check failed: " << message << '\n'; + } +}; +template +void await(Predicate predicate, const SessionModel& model, QElapsedTimer& lifecycle, const char* message) { + QElapsedTimer phase; phase.start(); + while (!predicate() && phase.elapsed() < 5000 && lifecycle.elapsed() < 18000) { + QTest::qWait(5); + if (!model.error().isEmpty()) throw std::runtime_error(model.error().toStdString()); + if (model.finished() && !predicate()) + throw std::runtime_error(std::string("hidden-retention child ended before its stage: ") + message); + } + require(predicate(), message); +} +void resize_public(TerminalWidget& terminal, SessionModel& model, int columns, int rows, + QElapsedTimer& lifecycle) { + const QFontMetricsF metrics(terminal.font()); + const int cell_width = qCeil(metrics.horizontalAdvance(QLatin1Char('M'))); + const int cell_height = qCeil(metrics.height()) + 2; + // Same geometry arithmetic as the fixed Qt modes test: the widget derives + // its grid from the public pixel size, the model then owns the transaction. + terminal.resize(16 + columns * cell_width, 24 + (rows + 1) * cell_height); + await([&] { + const auto state = model.resize_state(); + return state.requested_columns == columns && state.requested_rows == rows && !state.pending && + state.transport_columns == columns && state.transport_rows == rows; + }, model, lifecycle, "public Qt resize did not finish the bounded native request"); + require(model.screen().width() == columns && model.screen().height() == rows, + "core geometry did not follow the public resize"); +} +HiddenRetentionReply wait_reply(HiddenRetentionFixture& fixture, unsigned stage, + const SessionModel& model, QElapsedTimer& lifecycle) { + std::optional result; + await([&] { if (!result) result = fixture.take_reply(); return result.has_value(); }, + model, lifecycle, "child stage acknowledgement missed the fixed deadline"); + require(result->stage == stage && result->child_pid == model.resize_state().child_pid, + "child acknowledgement came from another stage or Session"); + return *result; +} +HiddenRetentionReply send_stage(TerminalWidget& terminal, HiddenRetentionFixture& fixture, unsigned stage, + SessionModel& model, QElapsedTimer& lifecycle) { + require(terminal.hasFocus(), "public key target lost focus"); + QTest::keyClicks(&terminal, QString::number(stage)); + return wait_reply(fixture, stage, model, lifecycle); +} +void check_native_geometry(const HiddenRetentionReply& reply, int columns, int rows, Checks& check, + const char* context) { + check(reply.info.dwSize.X == columns && reply.info.dwSize.Y == rows, + (std::string(context) + ": child console buffer geometry differs").c_str()); + check(reply.info.srWindow.Right - reply.info.srWindow.Left + 1 == columns && + reply.info.srWindow.Bottom - reply.info.srWindow.Top + 1 == rows, + (std::string(context) + ": child console window geometry differs").c_str()); +} +StageRecord capture_stage(unsigned stage, const HiddenRetentionReply& reply, const Screen& screen) { + StageRecord record; + record.stage = stage; + record.native_columns = reply.info.dwSize.X; + record.native_rows = reply.info.dwSize.Y; + record.core_columns = screen.width(); + record.core_rows = screen.height(); + record.core_row = screen.row; + record.core_column = screen.column; + record.alternate = screen.active_buffer() == ScreenBuffer::alternate; + record.body_sha256 = body_sha256(body_of(screen)); + record.native_row_11 = native_row(reply, 11); + record.native_row_18 = native_row(reply, 18); + return record; +} +} // namespace + +int main(int argc, char** argv) { + SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX); + _set_abort_behavior(0, _WRITE_ABORT_MSG | _CALL_REPORTFAULT); + if (argc == 4 && std::string_view(argv[1]) == "--hidden-retention-child") { + child_log_path = argv[3]; + try { + return termforge::qt::test::hidden_retention_child( + QString::fromLocal8Bit(argv[2]).toStdWString()); + } catch (const std::exception& error) { + child_log(std::string("hidden-retention child failed: ") + error.what()); + std::cerr << error.what() << '\n'; + return 3; + } + } + if (argc == 3 && std::string_view(argv[1]) == "--hidden-retention-child") { + try { + return termforge::qt::test::hidden_retention_child( + QString::fromLocal8Bit(argv[2]).toStdWString()); + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 3; + } + } + if (argc != 1) { + std::cerr << "usage: qt_hidden_retention_test\n"; + return 2; + } + // Instantiate the SessionModel's fixed bundled ConPTY only under the owned + // Job supervisor; the gate is read before QApplication like the other Qt + // formal-path tests. + char gate[3]{}; + DWORD count = 0; + BOOL owned = FALSE; + if (!ReadFile(GetStdHandle(STD_INPUT_HANDLE), gate, sizeof(gate), &count, nullptr) || + count != sizeof(gate) || std::string_view(gate, count) != "GO\n" || + !IsProcessInJob(GetCurrentProcess(), nullptr, &owned) || !owned) { + std::cerr << "Qt hidden-retention test requires its owned Job supervisor\n"; + return 2; + } + QApplication app(argc, argv); + QApplication::setQuitOnLastWindowClosed(false); + QByteArray child_log_path; + try { + QElapsedTimer lifecycle; lifecycle.start(); + QTemporaryDir workspace; + require(workspace.isValid(), "fixture workspace unavailable"); + child_log_path = workspace.filePath(QStringLiteral("child-error.txt")).toUtf8(); + wchar_t executable[32768]{}; + const DWORD length = GetModuleFileNameW(nullptr, executable, 32768); + require(length && length < 32768, "fixture executable unavailable"); + HiddenRetentionFixture fixture; + SessionModel model; + QWidget window; + window.resize(1200, 900); + TerminalWidget terminal(model, &window); + window.show(); + terminal.setFocus(); + require(model.start(executable, L"--hidden-retention-child \"" + fixture.pipe_name() + L"\" \"" + + QString::fromUtf8(child_log_path).toStdWString() + L"\"", + workspace.path().toStdWString()), "hidden-retention child did not start"); + const auto ready = wait_reply(fixture, 0, model, lifecycle); + require(ready.info.dwSize.X == 80 && ready.info.dwSize.Y == 24, + "child did not start at the literal 80x24 geometry"); + require(model.screen().width() == 80 && model.screen().height() == 24 && + model.screen().active_buffer() == ScreenBuffer::primary, + "core did not start at the literal primary 80x24 geometry"); + require(!model.configuration_identity().isEmpty() && + !model.configuration_identity().contains(QStringLiteral("system")), + "formal entry did not load the fixed bundled ConPTY identity"); + + size_t failures = 0, checks_count = 0; + Checks check(failures, checks_count); + std::vector records; + + // Phase 1: initial primary body at 91x20. The widget grows first; the + // child's acknowledgement is independent of the requested size. + resize_public(terminal, model, 91, 20, lifecycle); + auto reply = send_stage(terminal, fixture, 1, model, lifecycle); + check_native_geometry(reply, 91, 20, check, "phase 1"); + await([&] { return body_of(model.screen()) == expected_body(); }, model, lifecycle, + "phase 1 primary body did not reach the core"); + const uint64_t retained_id = model.screen().visible_lines()[11].id; + records.push_back(capture_stage(1, reply, model.screen())); + check(model.screen().active_buffer() == ScreenBuffer::primary, "phase 1 is not primary"); + check(body_of(model.screen()) == expected_body(), "phase 1 body differs from the fixed source"); + check(line_text(model.screen().visible_lines()[11]) == std::string(89, ' ') + "uv" && + model.screen().visible_lines()[11].size() == 91, + "phase 1 retained source row 11 differs from the literal 91-cell row"); + check(model.screen().visible_lines()[11].id == retained_id, "phase 1 line identity mismatch"); + check(model.screen().row == 18 && model.screen().column == 6, + "phase 1 protocol cursor left the source position"); + { + terminal.grab(); + const auto& page = terminal.painted_page(); + check(page.has_value() && terminal.painted_row_offset() == 0, + "phase 1 widget painted no display page"); + if (page) { + check(display_row(*page, 11) == std::string(89, ' ') + "uv" && + display_row(*page, 12) == "w" && display_row(*page, 18) == "LOWEND", + "phase 1 display page did not reflow the retained 92-character row"); + check(terminal.display_caret_row() == 18 && terminal.display_caret_column() == 6, + "phase 1 display caret differs from the source cursor"); + const auto hit = page->mapping.hit(TextDocument(model.screen()), 11, 89, + termforge::ViewAffinity::leading, + page->mapping.layout_revision()); + check(hit.has_value() && hit->line_id == retained_id && hit->column == 89, + "phase 1 hit-test did not return retained row 11 column 89"); + } + } + + // Phase 2: narrow while the primary frame is active. The retained source + // row stays 91 cells wide; only the display projection reflows. + resize_public(terminal, model, 61, 20, lifecycle); + reply = send_stage(terminal, fixture, 2, model, lifecycle); + check_native_geometry(reply, 61, 20, check, "phase 2"); + await([&] { return model.screen().width() == 61 && body_of(model.screen()) == expected_body(); }, + model, lifecycle, "phase 2 narrowed retained body did not reach the core"); + records.push_back(capture_stage(2, reply, model.screen())); + check(model.screen().active_buffer() == ScreenBuffer::primary, "phase 2 is not primary"); + check(body_of(model.screen()) == expected_body(), "phase 2 narrowing lost retained body text"); + check(model.screen().visible_lines()[11].size() == 91 && + line_text(model.screen().visible_lines()[11]) == std::string(89, ' ') + "uv", + "phase 2 narrowing rewrote the retained source row"); + check(model.screen().visible_lines()[11].id == retained_id, "phase 2 line identity changed"); + { + terminal.grab(); + const auto& page = terminal.painted_page(); + check(page.has_value() && terminal.painted_row_offset() == 0, + "phase 2 widget painted no display page"); + if (page) { + check(display_row(*page, 11).empty() && + display_row(*page, 12) == std::string(28, ' ') + "uvw" && + display_row(*page, 18) == "LOWEND", + "phase 2 display page did not reflow the retained row at 61 columns"); + check(terminal.display_caret_row() == 18 && terminal.display_caret_column() == 6, + "phase 2 display caret differs from the source cursor"); + const auto hit = page->mapping.hit(TextDocument(model.screen()), 12, 28, + termforge::ViewAffinity::leading, + page->mapping.layout_revision()); + check(hit.has_value() && hit->line_id == retained_id && hit->column == 89, + "phase 2 hit-test did not return retained row 11 column 89"); + } + } + + // Phase 3: enter the alternate screen at the narrowed 61x20 geometry. + reply = send_stage(terminal, fixture, 3, model, lifecycle); + check_native_geometry(reply, 61, 20, check, "phase 3"); + await([&] { + return model.screen().active_buffer() == ScreenBuffer::alternate && + model.screen().visible_lines().size() >= 2 && + line_text(model.screen().visible_lines()[1]) == "ALT_MARK"; + }, model, lifecycle, "phase 3 alternate marker did not reach the core"); + records.push_back(capture_stage(3, reply, model.screen())); + { + const auto hidden = hidden_frame(model.screen()); + check(hidden.active_buffer() == ScreenBuffer::primary, "phase 3 hidden frame is not primary"); + check(hidden.history_size() == 0 && body_of(hidden) == expected_body(), + "phase 3 hidden primary body was lost on alternate entry"); + check(hidden.visible_lines()[11].size() == 91 && hidden.visible_lines()[11].id == retained_id, + "phase 3 hidden primary row identity changed"); + check(hidden.row == 18 && hidden.column == 6, "phase 3 hidden primary cursor changed"); + } + + // Phase 4: only the height shrinks while the alternate frame is active. + resize_public(terminal, model, 61, 14, lifecycle); + reply = send_stage(terminal, fixture, 4, model, lifecycle); + check_native_geometry(reply, 61, 14, check, "phase 4"); + await([&] { + return model.screen().height() == 14 && + model.screen().visible_lines().size() >= 3 && + line_text(model.screen().visible_lines()[2]) == "ALT_SHRUNK"; + }, model, lifecycle, "phase 4 alternate height shrink did not reach the core"); + records.push_back(capture_stage(4, reply, model.screen())); + { + const auto hidden = hidden_frame(model.screen()); + check(hidden.history_size() == 5, "phase 4 hidden height shrink did not append the prefix"); + check(body_of(hidden) == expected_body(), "phase 4 hidden height-only shrink lost body text"); + const auto index = hidden.locate(retained_id); + check(index.has_value() && *index == 11 && hidden.retained_line(*index).size() == 91 && + line_text(hidden.retained_line(*index)) == std::string(89, ' ') + "uv", + "phase 4 hidden height-only shrink rewrote the retained row"); + check(hidden.row == 13 && hidden.column == 6, + "phase 4 hidden primary cursor did not follow the retained prefix"); + } + + // Phase 5: restore the height while the alternate frame is still active. + resize_public(terminal, model, 61, 20, lifecycle); + reply = send_stage(terminal, fixture, 5, model, lifecycle); + check_native_geometry(reply, 61, 20, check, "phase 5"); + await([&] { + return model.screen().height() == 20 && + model.screen().visible_lines().size() >= 3 && + line_text(model.screen().visible_lines()[2]) == "ALT_RESTORED"; + }, model, lifecycle, "phase 5 alternate height restore did not reach the core"); + records.push_back(capture_stage(5, reply, model.screen())); + { + const auto hidden = hidden_frame(model.screen()); + check(hidden.history_size() == 5, "phase 5 hidden history changed during height restore"); + check(body_of(hidden) == expected_body(), "phase 5 hidden height restore lost body text"); + const auto index = hidden.locate(retained_id); + check(index.has_value() && *index == 11 && hidden.retained_line(*index).size() == 91, + "phase 5 hidden height restore rewrote the retained row"); + } + + // Phase 6: return to the primary frame at 61x20. The confirmed contract + // retains the complete 92-character source row, unlike the original + // WindTerm clipping recorded separately. + reply = send_stage(terminal, fixture, 6, model, lifecycle); + check_native_geometry(reply, 61, 20, check, "phase 6"); + await([&] { + return model.screen().active_buffer() == ScreenBuffer::primary && + body_of(model.screen()) == expected_body(); + }, model, lifecycle, "phase 6 primary body did not return through 1049l"); + records.push_back(capture_stage(6, reply, model.screen())); + check(model.screen().active_buffer() == ScreenBuffer::primary, "phase 6 is not primary"); + check(body_of(model.screen()) == expected_body(), + "phase 6 returning to primary lost the confirmed full body"); + check(model.screen().history_lines().size() == 5 && + model.screen().history_lines()[4].id != retained_id, + "phase 6 retained prefix is not the expected history"); + { + const auto index = model.screen().locate(retained_id); + check(index.has_value() && *index == 11 && + line_text(model.screen().retained_line(*index)) == std::string(89, ' ') + "uv" && + model.screen().retained_line(*index).size() == 91, + "phase 6 hidden height-only path rewrote the retained source row"); + } + check(model.screen().row == 13 && model.screen().column == 6 && + model.screen().history_lines().size() + static_cast(model.screen().row) == 18, + "phase 6 protocol cursor left its retained source position"); + { + terminal.grab(); + const auto& page = terminal.painted_page(); + check(page.has_value() && terminal.painted_row_offset() == 0, + "phase 6 widget painted no display page"); + if (page) { + check(display_row(*page, 6).empty() && + display_row(*page, 7) == std::string(28, ' ') + "uvw" && + display_row(*page, 13) == "LOWEND", + "phase 6 display page does not show the retained body at 61 columns"); + check(terminal.display_caret_row() == 13 && terminal.display_caret_column() == 6, + "phase 6 display caret differs from the retained source position"); + const auto hit = page->mapping.hit(TextDocument(model.screen()), 7, 28, + termforge::ViewAffinity::leading, + page->mapping.layout_revision()); + check(hit.has_value() && hit->line_id == retained_id && hit->column == 89, + "phase 6 hit-test did not return the retained full row"); + } + } + + // Phase 7: restore the width to 91x20 after returning to the primary. + resize_public(terminal, model, 91, 20, lifecycle); + reply = send_stage(terminal, fixture, 7, model, lifecycle); + check_native_geometry(reply, 91, 20, check, "phase 7"); + await([&] { return model.screen().width() == 91 && body_of(model.screen()) == expected_body(); }, + model, lifecycle, "phase 7 width restore lost the retained body"); + records.push_back(capture_stage(7, reply, model.screen())); + check(model.screen().active_buffer() == ScreenBuffer::primary, "phase 7 is not primary"); + check(body_of(model.screen()) == expected_body(), + "phase 7 width restore lost the confirmed full body"); + { + const auto index = model.screen().locate(retained_id); + check(index.has_value() && *index == 11 && model.screen().retained_line(*index).size() == 91, + "phase 7 width restore rewrote the retained source row"); + terminal.grab(); + const auto& page = terminal.painted_page(); + check(page.has_value() && terminal.painted_row_offset() == 0, + "phase 7 widget painted no display page"); + if (page) { + check(display_row(*page, 6) == std::string(89, ' ') + "uv" && + display_row(*page, 7) == "w" && display_row(*page, 13) == "LOWEND", + "phase 7 display page does not show the full body at 91 columns"); + check(terminal.display_caret_row() == 13 && terminal.display_caret_column() == 6, + "phase 7 display caret differs from the retained source position"); + const auto hit = page->mapping.hit(TextDocument(model.screen()), 6, 89, + termforge::ViewAffinity::leading, + page->mapping.layout_revision()); + check(hit.has_value() && hit->line_id == retained_id && hit->column == 89, + "phase 7 hit-test did not return the retained full row"); + } + } + + QTest::keyClicks(&terminal, QStringLiteral("x")); + await([&] { return model.finished(); }, model, lifecycle, "hidden-retention child did not exit"); + require(model.natural_exit() && model.exit_code() == 37 && model.cleanup_verified() && + model.active_processes_after() == 0 && model.output_eof(), + "hidden-retention child natural exit or owned cleanup was not verified"); + + QJsonArray stages; + for (const auto& record : records) { + stages.append(QJsonObject{ + {"stage", static_cast(record.stage)}, + {"native_columns", record.native_columns}, + {"native_rows", record.native_rows}, + {"core_columns", record.core_columns}, + {"core_rows", record.core_rows}, + {"core_alternate", record.alternate}, + {"core_cursor", QJsonArray{record.core_row, record.core_column}}, + {"body_sha256", QString::fromStdString(record.body_sha256)}, + {"native_row_11_hex", QString::fromStdString(hex(record.native_row_11))}, + {"native_row_18_hex", QString::fromStdString(hex(record.native_row_18))}, + {"presented_done", QJsonValue()} + }); + } + const QJsonObject result{ + {"schema", "termforge-qt-hidden-retention-run-v1"}, + {"scenario", "primary-91x20-narrow-61-1049h-height-14-20-1049l-widen-91"}, + {"backend", model.configuration_identity()}, + {"child_pid", static_cast(model.resize_state().child_pid)}, + {"source_bytes", static_cast(termforge::qt::test::hidden_retention_source(1).size())}, + {"retained_row_id", static_cast(retained_id)}, + {"stages", stages}, + {"checks", static_cast(checks_count)}, + {"failures", static_cast(failures)}, + {"original_behavior_oracle", false}, + {"fixed_formal_path_scenario", true}, + {"offscreen_only", true}, + {"physical_presentation", false}, + {"windterm_equivalence", false}, + {"r27_complete", false}, + {"r29_complete", false}, + {"presented_done", QJsonValue()} + }; + const auto artifacts = qEnvironmentVariable("TERMFORGE_TEST_ARTIFACTS"); + if (!artifacts.isEmpty()) { + require(QDir().mkpath(artifacts), "artifact directory could not be created"); + QFile saved(artifacts + QStringLiteral("/hidden-retention.json")); + require(saved.open(QIODevice::WriteOnly) && + saved.write(QJsonDocument(result).toJson()) > 0, "sample could not be retained"); + } + std::cout << QJsonDocument(result).toJson(QJsonDocument::Compact).constData() << '\n'; + return failures ? 1 : 0; + } catch (const std::exception& error) { + if (!child_log_path.isEmpty()) { + QFile file(QString::fromUtf8(child_log_path)); + if (file.open(QIODevice::ReadOnly)) + std::cerr << "hidden-retention child log: " << file.readAll().constData() << '\n'; + else + std::cerr << "hidden-retention child log is missing\n"; + } + std::cerr << "Qt hidden-retention: " << error.what() << '\n'; + return 1; + } +}