diff --git a/CMakeLists.txt b/CMakeLists.txt index b17e9b37..07812493 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -165,6 +165,10 @@ add_executable(resize_preserving_transaction_test experiments/core/resize_preser target_link_libraries(resize_preserving_transaction_test PRIVATE termforge_core) termforge_warnings(resize_preserving_transaction_test) +add_executable(resize_hidden_height_test experiments/core/resize_hidden_height_test.cpp) +target_link_libraries(resize_hidden_height_test PRIVATE termforge_core) +termforge_warnings(resize_hidden_height_test) + add_executable(resize_unicode_test experiments/core/resize_unicode_test.cpp) target_link_libraries(resize_unicode_test PRIVATE termforge_core) termforge_warnings(resize_unicode_test) @@ -460,6 +464,8 @@ set_tests_properties(display_cursor_core PROPERTIES TIMEOUT 10) add_test(NAME retained_resize_edit_core COMMAND resize_edit_test) add_test(NAME retained_resize_pending_core COMMAND resize_pending_test) add_test(NAME retained_resize_hidden_core COMMAND resize_hidden_test) +add_test(NAME retained_resize_hidden_height_core COMMAND resize_hidden_height_test) +set_tests_properties(retained_resize_hidden_height_core PROPERTIES TIMEOUT 10) add_test(NAME retained_resize_erase_core COMMAND resize_erase_test) add_test(NAME retained_resize_mode_core COMMAND resize_mode_test) add_test(NAME alternate_mode_core COMMAND alternate_mode_test) @@ -531,6 +537,8 @@ add_test(NAME edit_csi_core COMMAND edit_csi_test) termforge_add_python_test(windterm_resize_height_measured_optimized -O -m unittest research.ui.test_resize_height_measured_reference research.ui.test_export_resize_height_measured_reference -v) termforge_add_python_test(windterm_resize_combined_reference -m unittest research.ui.test_resize_combined_reference research.ui.test_export_resize_combined_reference -v) termforge_add_python_test(windterm_resize_combined_optimized -O -m unittest research.ui.test_resize_combined_reference research.ui.test_export_resize_combined_reference -v) + termforge_add_python_test(windterm_resize_hidden_height_reference -m unittest research.ui.test_resize_hidden_height_reference research.ui.test_export_resize_hidden_height_reference -v) + termforge_add_python_test(windterm_resize_hidden_height_optimized -O -m unittest research.ui.test_resize_hidden_height_reference research.ui.test_export_resize_hidden_height_reference -v) find_program(TERMFORGE_NODE_EXECUTABLE NAMES node) if(TERMFORGE_NODE_EXECUTABLE) add_test(NAME windterm_resize_address_controller COMMAND ${TERMFORGE_NODE_EXECUTABLE} @@ -581,6 +589,10 @@ add_test(NAME edit_csi_core COMMAND edit_csi_test) ${CMAKE_SOURCE_DIR}/research/ui/test_resize_combined_stages.js ${CMAKE_SOURCE_DIR}/research/ui/resize_combined_reference.js) set_tests_properties(windterm_resize_combined_controller PROPERTIES TIMEOUT 15) + add_test(NAME windterm_resize_hidden_height_controller COMMAND ${TERMFORGE_NODE_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/research/ui/test_resize_hidden_height_stages.js + ${CMAKE_SOURCE_DIR}/research/ui/resize_hidden_height_reference.js) + set_tests_properties(windterm_resize_hidden_height_controller PROPERTIES TIMEOUT 15) endif() # Pure offline regressions only; OwnedNodeTests and frozen batches are never launched here. termforge_add_python_test(node_contract_export -m unittest research.ui.test_export_node_contract_deadline research.ui.test_node_contract_deadline.CheckpointTests research.ui.test_node_contract_deadline.LauncherVerdictTests -v) diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index 3602628f..be60b677 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -18,6 +18,7 @@ | 原版 resize 后寻址与自主核心字面诊断 | [独立直接协议样本](../research/ui/results/2026-11-02-resize-address-reference/README.md)、[默认裁剪路径的失败诊断](../experiments/core/results/2026-11-02-resize-address/README.md) | 四例自然追加、CR、LF、CUP 保留动作前观测,文档、DSR、显示坐标分别核对;第五例 EL1 保留原审计失败。默认 `Screen::resize` 历史诊断仅 16/64 阶段通过、退出 1;当时 `EXCLUDE_FROM_ALL`,现参与默认构建但 CTest 只注册另行验证的显式保留入口。旧红不设 `WILL_FAIL`,常规 CI 或另一入口通过不能豁免默认路径差异 | | 缩窄后跨前方显示重排的 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 不关闭 | +| 隐藏主屏纯高度变化的正文保留(#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 | 工程不变量,不是原版隐藏高度 oracle:原版七阶段记录仍为严格审计失败,归档缺少取证运行时的完整发行物/profile,无法在本工作区严格复核;未覆盖备用屏后续输出、保存光标交叉、Unicode、历史淘汰、Qt 物理呈现/DPI 或性能;#27/#29 不关闭 | | 自主 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 失败不豁免 | | 原版活动/隐藏帧与缩放后下一字符 | [6次原版、29阶段](../research/ui/results/2026-11-02-resize-frame-reference/README.md):5次原审计通过、1次隐藏主屏原审计失败后独立离线补充通过;无补跑。文档、DSR与显示分别记录 | 活动备用屏保留正文;隐藏主屏返回的正文和边界不同;4个ON/OFF缩放末列样本支持下一字符覆盖当前位置。原版Document索引差异、私有模式位及完整网格不据此判定 | | 自主 C++ 缩放后的末列输入与隐藏主屏 | [末列输入红绿](../experiments/core/results/2026-11-02-resize-pending/README.md):`9b67886` 的4例44/72→72/72;[隐藏主屏红绿](../experiments/core/results/2026-11-02-resize-hidden/README.md):`afdcf59` 的12/32→32/32,精确比较正文空白长度、源位置、显示与DSR | 定向实现均只走显式保留入口;默认 resize / Qt 尚未接入。隐藏主屏样本不能推出活动帧 margin、单纯隐藏增宽或完整 Unicode 语义;各报告区分固定生产源码、执行后身份与缺失的历史二进制 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 89d803da..3bd874e3 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仍使用物理 `snapshot()`**;[B/CD缩放失败](../experiments/shell/results/2026-11-02-qt-terminal-modes/README.md)未修复,#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)),原版隐藏高度严格证据仍缺。#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、字体和网格 | [#30](https://github.com/JavaZeroo/termforge/issues/30) 仍需完整输入/IME 交叉;所有脚本/整形、通用 fallback、所有原生重放、真彩色/更多属性、Powerline 和真实跨屏未验收。分段 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,并注册核心门禁,尚未替换默认 resize 或接入 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)仍红,固定事务不等于完整 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)已注册门禁。历史候选在[固定 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 仍缺独立证据;隐藏主屏高度的原版严格取证未完成,当前合同为工程不变量。固定事务不等于完整 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/experiments/core/resize_hidden_height_test.cpp b/experiments/core/resize_hidden_height_test.cpp new file mode 100644 index 00000000..4f2f0ec2 --- /dev/null +++ b/experiments/core/resize_hidden_height_test.cpp @@ -0,0 +1,174 @@ +// Retained-frame engineering contract: a hidden primary frame keeps its +// retained source cells when only the height changes. The original +// hidden-primary height recording is preserved but its strict audit failed, so +// these assertions are an implementation invariant under the confirmed +// retained-body direction (ADR-0010), not an original-behavior oracle. A width +// change while the frame is hidden still fits it to the protocol footprint, +// matching the separately recorded hidden-width policy. +#include "terminal.hpp" +#include "history_view.hpp" +#include "text_document.hpp" +#include +#include +#include +#include +#include +#include + +using namespace termforge; + +struct Checks { + std::string context; + size_t assertions = 0, failures = 0; + + void expect(bool condition, const std::string& message) { + ++assertions; + if (condition) return; + ++failures; + std::cerr << context << ": " << message << '\n'; + } +}; + +static void feed(Parser& parser, std::string_view bytes) { parser.feed(bytes); } + +static void omit_empty_tail(std::vector& lines) { + while (!lines.empty() && lines.back().empty()) lines.pop_back(); +} + +static 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; +} + +static std::string line_text(const Line& line) { + std::string text; + for (const auto& cell : line) text += cell.text; + return text; +} + +// The fixed 75 B original source from the combined width/height sample. Its +// document keeps the retained `uvw` tail at zero-based row 11, columns 89..91. +static constexpr std::string_view source = "\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"; + +static std::vector expected_body() { + std::vector result = { + "", "HTOP", "", "", "", "HARD_A", "HARD_B", "", "", "", "", + std::string(89, ' ') + "uvw", "", "", "", "", "", "LOWEND", "" + }; + omit_empty_tail(result); + return result; +} + +static std::vector body_of(const Screen& screen) { + TextDocument document(screen); + return source_lines(document); +} + +// The hidden frame is inspected through an owner copy so the active frame and +// the original screen guards are not switched for the assertion itself. +static Screen hidden_frame(const Screen& screen) { + Screen copy(screen); + if (screen.active_buffer() != ScreenBuffer::primary) copy.activate_buffer(ScreenBuffer::primary); + return copy; +} + +// Narrow first while the frame is active so its rows are retained wider than +// the protocol width, then hide it and change only the height. +static void hidden_retains_tail_across_height_only() { + Checks checks{"hidden/height-only"}; + Parser parser(20, 91, 1000); + feed(parser, source); + parser.screen.resize_columns_preserving(61); + // The 92-character logical row is held as a 91-cell physical row plus one + // soft continuation cell; both keep their source width after narrowing. + checks.expect(parser.screen.visible_lines()[11].size() == 91, + "active width retention did not keep the source tail"); + parser.screen.activate_buffer(ScreenBuffer::alternate); + parser.screen.resize_preserving(14, 61); + parser.screen.resize_preserving(20, 61); + const auto hidden = hidden_frame(parser.screen); + bool tail = false; + for (const auto& line : hidden.visible_lines()) + if (line.size() == 91 && line_text(line).find("uv") != std::string::npos) tail = true; + checks.expect(tail, "hidden live row lost its retained tail across height-only changes"); + parser.screen.activate_buffer(ScreenBuffer::primary); + checks.expect(body_of(parser.screen) == expected_body(), + "returning to the primary frame lost retained source text"); + if (checks.failures) throw std::runtime_error("hidden/height-only failures"); +} + +// Shrink enough while hidden that the retained tail rows move into primary +// history; a later height restore must not rewrite the appended history rows. +static void hidden_retains_tail_in_history() { + Checks checks{"hidden/history"}; + Parser parser(20, 91, 1000); + feed(parser, source); + parser.screen.resize_columns_preserving(61); + parser.screen.move(19, 0); + parser.screen.activate_buffer(ScreenBuffer::alternate); + parser.screen.resize_preserving(5, 61); + const auto hidden = hidden_frame(parser.screen); + checks.expect(hidden.history_size() >= 14, + "hidden height shrink did not move the prefix into history"); + bool retained = false; + bool continuation = false; + for (const auto& line : hidden.history_lines()) { + const auto text = line_text(line); + if (line.size() == 91 && text.find("uv") != std::string::npos) retained = true; + // The one-cell soft continuation is padded to the active width before + // the frame is hidden; its leading written cell still identifies it. + if (!text.empty() && text[0] == 'w' && text.find_first_not_of(' ') == 0) continuation = true; + } + checks.expect(retained && continuation, + "hidden height shrink cropped the appended history rows"); + parser.screen.resize_preserving(20, 61); + parser.screen.activate_buffer(ScreenBuffer::primary); + checks.expect(body_of(parser.screen) == expected_body(), + "returning after hidden shrink/restore lost retained source text"); + if (checks.failures) throw std::runtime_error("hidden/history failures"); +} + +// A width change while hidden still follows the separately recorded hidden +// width policy: live rows fit the protocol footprint. This is deliberately not +// applied to a height-only change above. +static void hidden_width_change_still_fits_footprint() { + Checks checks{"hidden/mixed-width-change"}; + Parser parser(20, 91, 1000); + feed(parser, source); + parser.screen.resize_columns_preserving(61); + parser.screen.activate_buffer(ScreenBuffer::alternate); + parser.screen.resize_preserving(14, 40); + parser.screen.activate_buffer(ScreenBuffer::primary); + checks.expect(parser.screen.visible_lines()[6].size() == 40, + "hidden width change did not fit the row to the new footprint"); + checks.expect(body_of(parser.screen)[11].find("uvw") == std::string::npos, + "hidden width policy no longer crops the retained tail"); + if (checks.failures) throw std::runtime_error("hidden/mixed-width-change failures"); +} + +int main() { + try { + hidden_retains_tail_across_height_only(); + hidden_retains_tail_in_history(); + hidden_width_change_still_fits_footprint(); + std::cout << "hidden retained-frame height checks passed; " + "engineering invariant, no original/Qt/performance acceptance\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/experiments/core/results/2026-11-04-hidden-height-retention/README.md b/experiments/core/results/2026-11-04-hidden-height-retention/README.md new file mode 100644 index 00000000..bf0a7e02 --- /dev/null +++ b/experiments/core/results/2026-11-04-hidden-height-retention/README.md @@ -0,0 +1,35 @@ +# 隐藏主屏纯高度变化的正文保留(工程合同) + +`resize_preserving` 从 #308 起负责高度事务。隐藏帧在早先缩宽后可能保留宽于当前协议宽度的源行(例如原版 75 B 样本的 89 空格 + `uvw` 尾);本切片修复一个会导致该正文永久丢失的实现缺陷。 + +**缺陷与影响。** 旧实现不论宽度是否变化,都把隐藏帧的 live 行与移入历史的行裁剪到当前协议宽度。当前正式 Qt 路径已使用 `resize_preserving`(#310),因此备用屏(TUI)活动期间只改变窗口高度,会裁掉隐藏主屏保留的右尾;返回主屏后正文、选择与 Find 源文本随之缺失。该行为与 ADR-0010 已确认的正文保留方向冲突。 + +**修复。** 隐藏帧只在 **宽度实际改变** 时适配到新协议足迹(沿用既有隐藏宽度证据策略);纯高度变化保留隐藏 live 与追加历史的源 Cell,不重写其宽度。活动帧不受影响。 + +## 验证 + +| 入口 | 实际结果 | 口径 | +|---|---|---| +| RED:修复前 `resize_hidden_height_test` | `hidden/height-only` 失败:隐藏 live 行丢失保留尾;返回主屏正文丢失 | 固定 75 B 源;先缩宽保留 91 列源行,再隐藏主帧并 20→14→20 行 | +| GREEN:修复后同测试 | **3/3 场景通过**(纯高度保留、历史前缀保留、隐藏宽度变化仍适配足迹),退出 0 | 工程不变量,不是原版 oracle | +| 组合字面回放 `resize_combined_test` | **20/20 阶段、0/560 失败**;映射/选择 20/20、0/744 | 与 #308 相同字面预期,未放宽 | +| 事务分配失败/历史检查 `resize_preserving_transaction_test` | 通过(151/42 次注入失败后重试一致等) | 既有工程检查,未改断言 | +| 核心 CTest | **37/37 通过**(新增 `retained_resize_hidden_height_core`) | 本工作树定向构建 | +| Qt 正式路径 CTest | **5/5 通过**(含 `qt_terminal_modes_retained` 12/12 契约) | 真实 child → 固定 ConPTY → core → widget,offscreen | +| 隐藏高度探针离线 CTest | **3/3 通过,65.76 秒**(normal / `-O` / node controller) | 只校验冻结探针与导出器,不启动原版 | +| 旧六阶段 `--resize` 诊断 | **exit 1,失败保留** | 未弱化、未设 `WILL_FAIL`;原生重排网格与保留行坐标层差异不变 | + +构建/运行环境:Windows 11 x64、Visual Studio 17 2022、MSVC 19.42.34433.0、Qt 6.10.3 offscreen、随包固定 ConPTY 1.25.260930003、app-local CRT、Release。源码/二进制 SHA-256、实现提交与逐项计数见 [validation.json](validation.json)。 + +```powershell +cmake --build build --config Release --target resize_hidden_height_test resize_combined_test resize_preserving_transaction_test +ctest --test-dir build -C Release -R "_core$" --output-on-failure +ctest --test-dir build/qt-cpp -C Release -R "^(qt_cpp_(shell|resize)|qt_unicode_input|qt_terminal_modes(_retained)?)$" --output-on-failure +``` + +## 边界 + +- 这是 ADR-0010 方向下的 **工程保留不变量**,不是原版隐藏主屏高度行为的对照通过。原版七阶段隐藏高度记录仍保留为严格审计失败;其离线补充审计草稿可运行,但归档记录缺少取证运行时的完整发行物与 profile,当前工作区无法完成严格导出的来源复核。未重跑、未改写原失败,也不据此分类原版隐藏高度策略。 +- 纯高度变化后隐藏行可宽于当前协议宽度,与活动保留行同一表示;显示投影/编辑路径沿用既有有界处理。隐藏帧在宽度改变时仍按单独记录的隐藏宽度策略适配足迹。 +- 未覆盖备用屏活动期间的后续输出、保存光标交叉、Unicode/宽字/结合字、历史淘汰上限、Qt 物理呈现/DPI、选择/Find 用户流程或性能。 +- #27/#29 保持 OPEN;本切片不声明完整 resize/reflow、WindTerm 等价或 R2 完成。 diff --git a/experiments/core/results/2026-11-04-hidden-height-retention/validation.json b/experiments/core/results/2026-11-04-hidden-height-retention/validation.json new file mode 100644 index 00000000..9f6b59e6 --- /dev/null +++ b/experiments/core/results/2026-11-04-hidden-height-retention/validation.json @@ -0,0 +1,67 @@ +{ + "schema": "termforge-hidden-height-retention-validation-v1", + "date": "2026-11-04", + "status": "engineering-retention-contract-passed", + "original_oracle": false, + "r27_complete": false, + "r29_complete": false, + "base_commit": "9884884cd89fb9990acb937c11af0a439f6f04a6", + "implementation_commits": [ + "0f4b4d18504951f388455a5b370aa7cdb7e5f3b1", + "6158f1b534ce66d0d2a8dbe75ce3f43c835d21d5" + ], + "hashes_collected_after_runs": true, + "build": { + "platform": "Windows 11 x64", + "generator": "Visual Studio 17 2022", + "compiler": "MSVC 19.42.34433.0", + "qt": "6.10.3 offscreen", + "conpty": "1.25.260930003", + "configuration": "Release" + }, + "source_files_sha256": { + "CMakeLists.txt": "8cbff18b9c4a7d97bf4505e17d60174bd88207cc7ca211bbeee205f7dde56ccf", + "experiments/core/screen.hpp": "60c75e1a19929fae16920677a4b81fec30d689c6d9ee34af155b304fc70341d6", + "experiments/core/resize_hidden_height_test.cpp": "4dfb04cb7e818c7aa393e774ba296f65a6ceab2c95bb7fd6fd73c5103437cb73" + }, + "binary_sha256": { + "resize_hidden_height_test.exe": "b48b80f5406d8303a0829ca9364ebb4bb7880c94184dcb962093059d7b441229", + "resize_combined_test.exe": "4c1aa54c54b99b005e9a263e1579c6f6be057565d26970482e150562d0c8811d", + "resize_preserving_transaction_test.exe": "0b3ffdad20a135c2fdc49ccf9b914ccb39711b2b9a850d7f564348385be0420d" + }, + "red": { + "test": "resize_hidden_height_test before 6158f1b", + "failures": [ + "hidden live row lost its retained tail across height-only changes", + "returning to the primary frame lost retained source text" + ] + }, + "green": { + "hidden_height_scenarios": 3, + "hidden_height_failures": 0, + "combined_literal_phases": 20, + "combined_literal_failures": 0, + "combined_literal_assertions": 560, + "combined_mapping_phases": 20, + "combined_mapping_failures": 0, + "combined_mapping_assertions": 744, + "transaction_test": "passed", + "core_ctest_passed": 37, + "core_ctest_failed": 0, + "core_ctest_seconds": 4.78, + "qt_ctest_passed": 5, + "qt_ctest_failed": 0, + "qt_ctest_seconds": 9.72, + "hidden_probe_ctest_passed": 3, + "hidden_probe_ctest_failed": 0, + "hidden_probe_ctest_seconds": 65.76, + "old_six_stage_resize_diagnostic_exit": 1 + }, + "limitations": [ + "Engineering invariant under the confirmed retained-body direction, not an original hidden-height behavior oracle.", + "The preserved hidden-height original recording still fails its strict audit; the archived recording lacks the harness distribution/profile needed to complete a strict provenance re-verification, so no original run was repeated or reclassified.", + "Hidden frame still fits to the new protocol footprint when the width actually changes (separately recorded hidden-width policy).", + "No active-alternate output, saved-cursor cross, Unicode/wide/combining, history eviction, physical Qt presentation/DPI, selection/Find user flow or performance coverage.", + "Runs are limited to this worktree's directed build; Qt was offscreen and the old six-stage diagnostic remains an explicit non-CI failure." + ] +} diff --git a/experiments/core/screen.hpp b/experiments/core/screen.hpp index d018fd36..b7eb4211 100644 --- a/experiments/core/screen.hpp +++ b/experiments/core/screen.hpp @@ -509,9 +509,11 @@ class Screen { } // Resize retained source within the existing owner. A height transaction // prepares only live rows and their outgoing prefix; old history bodies are - // never traversed or copied. Active fragments retain their right tails. - // Hidden live fragments fit the protocol footprint, including on a height - // change. Neither frame pulls old history back into newly added bottom rows. + // never traversed or copied. Neither frame pulls old history back into + // newly added bottom rows. A width change fits the hidden frame to the new + // protocol footprint (the separately recorded hidden-width policy); a + // height-only change keeps hidden retained source cells instead of + // rewriting them to the unchanged current width. void resize_preserving(int rows, int columns) { if (rows < 1 || rows > 200 || columns < 1 || columns > 500) throw std::invalid_argument("invalid screen dimensions"); @@ -525,7 +527,8 @@ class Screen { int row = 0, column = 0; }; auto identity = next_id_; - const auto prepare = [&](const auto& state, size_t limit, bool hidden) { + const bool fit_hidden_width = columns != width(); + const auto prepare = [&](const auto& state, size_t limit, bool fit_width) { Prepared next; const auto shift = static_cast((std::max)(0, state.row - rows + 1)); const auto evicted = limit ? shift - (std::min)(shift, limit - state.history.size()) : shift; @@ -536,7 +539,7 @@ class Screen { next.row = state.row - static_cast(shift); next.column = (std::min)(state.column, columns - 1); const auto fit = [&](Line& line, bool live) { - if (hidden && line.size() > static_cast(columns)) { + if (fit_width && line.size() > static_cast(columns)) { stamp_line(line); materialize_columns(line, columns, Cell{}); line.wrap_columns = (std::min)(line.wrap_columns, static_cast(columns)); @@ -563,7 +566,8 @@ class Screen { auto next = prepare(*this, active_history_limit(), false); std::optional hidden; if (inactive_) - hidden = prepare(*inactive_, active_buffer_ == ScreenBuffer::primary ? 0 : history_limit, true); + hidden = prepare(*inactive_, active_buffer_ == ScreenBuffer::primary ? 0 : history_limit, + fit_hidden_width); // Only primary owns history. Deque growth can allocate blocks or its // pointer map. Append before publishing, and undo every appended row on diff --git a/research/ui/export_resize_hidden_height_reference.py b/research/ui/export_resize_hidden_height_reference.py new file mode 100644 index 00000000..9ff21181 --- /dev/null +++ b/research/ui/export_resize_hidden_height_reference.py @@ -0,0 +1,239 @@ +"""Strict publication of one seven-stage hidden-primary height observation.""" +from __future__ import annotations +import argparse +import hashlib +import json +from pathlib import Path, PureWindowsPath +import re +import sys +from types import FunctionType, SimpleNamespace + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from research.ui import export_mode_state_reference as common +from research.ui import export_resize_state_reference as resize_export +from research.ui import resize_hidden_height_reference as probe + +require, sha, digest, integer = common.require, common.sha, common.digest, common.integer +MARKERS = probe.MARKERS +EXPORT_SOURCES = ("research/ui/export_resize_hidden_height_reference.py", "research/ui/export_resize_state_reference.py", + "research/ui/export_mode_state_reference.py") + + +def raw_names(case): + return ("evidence.json", "run.json", "probe.js", "peer.json", probe.RESULT_FILE, "files.json", + "mechanics-check.json", "stderr.txt", "profile-check.json", "cleanup.json", "fixture-ready", + *(f"ack-{n}" for n in range(len(probe.phases(case)))), + *(f"resize-{kind}-{n}" for n in probe.resize_phases(case) for kind in ("request", "ready"))) + + +def read(path): + require(path.stat().st_size <= 4*1024*1024, "Recording exceeds byte bound") + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate recording field") + result[key] = value + return result + def nonfinite(_): + raise ValueError("Nonfinite JSON value") + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique, parse_constant=nonfinite) + + +def adapt(source, changes): + for old, new in changes.items(): + require(source.count(old) == 1, "Protected offline audit adapter changed: "+old) + source = source.replace(old, new) + return source + + +def extract(path, start, end): + source = path.read_text(encoding="utf-8") + require(source.count(start) == source.count(end) == 1, "Protected offline audit function boundary changed") + return start+source.split(start, 1)[1].split(end, 1)[0] + + +def expected_probe(plan, run, source_root): + integer(run["pid"], 2**32-1, 1); integer(run["tid"], 2**32-1, 1) + require(type(run["window_station"]) is str and re.fullmatch(r"TermForgeR1-[0-9]+", run["window_station"]), + "Owned station identity differs") + template = (source_root / "research/ui/qt_r1_text.js").read_text(encoding="utf-8").split("// Scenario driver", 1)[0] + driver = (source_root / "research/ui/resize_hidden_height_reference.js").read_text(encoding="utf-8") + require(driver.count("__RESIZE_HIDDEN_HEIGHT_CASE_JSON__") == 1, "Case template anchor differs") + template += driver.replace("__RESIZE_HIDDEN_HEIGHT_CASE_JSON__", json.dumps(probe.controller_contract(plan["case_id"]))) + require(hashlib.sha256(template.encode()).hexdigest() == run["template_sha256"], "Probe template differs") + generated = template.replace("__UI_THREAD_ID__", str(run["tid"])).replace("__OWNED_STATION__", run["window_station"]) + generated = generated.replace("__RUN_DIRECTORY_JSON__", json.dumps(PureWindowsPath(plan["out"]).as_posix())) + generated = generated.replace("__HOSTED_CI__", "false").replace("__SCENARIO_TIMEOUT_MS__", "43000") + return generated.encode().replace(b"\n", b"\r\n") + + +# The immutable existing helpers are adapted in fresh namespaces. Each textual +# boundary is checked, and all three offline source files are identified below. +_source = extract(Path(common.__file__), "def provenance_facts(", "\n\ndef export(") +_source = adapt(_source, { + "One original launch attempt; retain after failure and never retry this plan.": probe.ATTEMPT.rstrip("\n"), + "probe.source_identity(root=source_root, source_files=probe.SOURCE_FILES)": + "probe.common.source_identity(root=source_root, source_files=probe.SOURCE_FILES)", +}) +_PROVENANCE = {**vars(common), "ROOT": ROOT, "probe": probe, "read": read} +exec(compile(_source, str(Path(common.__file__)), "exec"), _PROVENANCE) + + +def provenance_facts(directory, plan_path, plan, source_root, reference_source): + function = FunctionType(_PROVENANCE["provenance_facts"].__code__, {**_PROVENANCE, "RAW_NAMES": raw_names(plan["case_id"])}) + return function(directory, plan_path, plan, source_root, reference_source) + + +_marker_projection = FunctionType(common.marker_projection.__code__, {**vars(common), "MARKERS": MARKERS}) + + +def marker_projection(terminal): + result = [] + for marker, raw in zip(_marker_projection(terminal), terminal["markers"]): + line = terminal["lines"][marker["document_line"]] + # The finite source alphabet is entirely ASCII. Python scalar offsets and + # JS UTF-16 offsets therefore coincide here, not for arbitrary Unicode. + require(len(line.encode("utf-16-le"))//2 == len(line), "Unexpected non-ASCII text") + start = integer(raw["line_start_api"], 262144) + require(marker["position"] == start+marker["column"], "Captured API-call arithmetic differs") + rect = marker["rect"] + if rect is not None: + require(0 <= rect["width"] <= 1360 and 0 <= rect["height"] <= 749 and + -1360 <= rect["x"] <= 2720 and -200*749 <= rect["y"] <= 2*749, + "Marker rectangle exceeds fixed window/document bounds") + result.append({"label": marker["label"], "document_line": marker["document_line"], + "line_text_offset_utf16": marker["column"], "line_text_offset_scalar": marker["column"], + "line_text_prefix_utf8_bytes": len(line[:marker["column"]].encode("utf-8")), + "line_start_api_value": start, "requested_position_api_value": marker["position"], "rect": rect}) + return result +geometry = FunctionType(resize_export.geometry.__code__, {**vars(resize_export), "probe": probe}) +_source = extract(Path(resize_export.__file__), "def capture_facts(", "\n\ndef export(") +_source = adapt(_source, { + "probe.audit_mechanics(directory)": 'probe.audit_mechanics(directory, plan["case_id"])', + '"resize-state-result.json"': 'probe.RESULT_FILE', + "6 <= len(messages) <= 69": 'len(probe.phases(plan["case_id"]))+3 <= len(messages) <= len(probe.phases(plan["case_id"]))+66', + '"scenario": "resize-state-v1"': '"scenario": "resize-hidden-height-v1", "case_id": plan["case_id"]', + "count = len(payloads)-5": 'count = len(payloads)-len(probe.phases(plan["case_id"]))-2', + '"resize_state_phase"': '"resize_hidden_height_phase"', + '"resize_state_result"': '"resize_hidden_height_result"', + "for phase in (1, 2):": 'for phase in probe.resize_phases(plan["case_id"]):', + "probe.phase_records(grids[0][1], grids[0][0])": 'probe.phase_records(plan["case_id"], grids[0][1], grids[0][0])', + "synthetic_lines(grids[0][0])": 'probe.synthetic_lines(plan["case_id"], source["phase"])', + '"dsr_request_bytes": 12': '"dsr_request_bytes": 4*len(probe.phases(plan["case_id"]))', + 'enumerate(peer["resize_boundaries"], 1)': 'zip(probe.RESIZE_PHASES, peer["resize_boundaries"])', + '"phase_messages": 3': '"phase_messages": len(probe.phases(plan["case_id"]))', +}) +_CAPTURE = {**vars(resize_export), "ROOT": ROOT, "probe": probe, "read": read, + "expected_probe": expected_probe, "geometry": geometry, "marker_projection": marker_projection} +exec(compile(_source, str(Path(resize_export.__file__)), "exec"), _CAPTURE) + + +def capture_facts(directory, plan, source_root=ROOT): + case = probe.validate_case(plan["case_id"]) + # A fresh binding supplies each immutable case's geometry. The imported + # runtime and frozen exporter namespaces are never mutated. + case_probe = SimpleNamespace(**{**vars(probe), "WINDOWS": probe.windows(case), "RESIZE_PHASES": probe.resize_phases(case)}) + function = FunctionType(_CAPTURE["capture_facts"].__code__, {**_CAPTURE, "probe": case_probe}) + result = function(directory, plan, source_root) + raw = read(directory / probe.RESULT_FILE) + # Publish the sampled geometry guard, not merely the requested endpoint. + # The runtime saved these scalars before asserting geometry; failed runs + # remain private and cannot reach this successful export path. + for index, check in enumerate(result["geometry_checks"]): + observed = raw["geometry_checks"][index] + require(type(observed["observed_grid"]) is list and len(observed["observed_grid"]) == 2, + "Observed geometry grid shape differs") + grid = [integer(observed["observed_grid"][0], 500, 1), integer(observed["observed_grid"][1], 200, 1)] + capacity = integer(observed["terminal_capacity"], 200, 1) + event_count = integer(observed["event_count"], 64, 1) + require(grid == result["frozen_grids"][index] and capacity == grid[1] and + event_count == result["freeze_event_counts"][index], "Observed geometry guard differs") + check.update(observed_grid=grid, terminal_capacity=capacity, event_count=event_count) + roles = [label for label, _ in probe.phases(case)] + for index, (phase, observed) in enumerate(zip(result["phases"], raw["observations"])): + phase["stage_role"] = roles[index] + lines = observed["terminal"]["lines"] + phase["document_projection"]["synthetic_lines_sha256"] = hashlib.sha256(json.dumps(lines, separators=(",", ":")).encode()).hexdigest() + # Validate exact source derivation before publishing any reconstructible + # text. The unrestricted original recording remains private. + probe.validate_document(case, index, observed["terminal"]) + catalog = sorted(probe.synthetic_lines(case, index)) + positions = {line: offset for offset, line in enumerate(catalog)} + phase["document_projection"]["synthetic_text"] = { + "encoding": "UTF-8", "catalog_source": "resize_hidden_height_reference.synthetic_lines(case_id, phase); sorted ASCII scalar order", + "catalog_count": len(catalog), + "catalog_sha256": hashlib.sha256(json.dumps(catalog, separators=(",", ":")).encode()).hexdigest(), + "lines": [{"catalog_index": positions[line], "utf8_hex": line.encode("utf-8").hex(), + "utf8_bytes": len(line.encode("utf-8")), "unicode_scalars": len(line), + "utf16_code_units": len(line.encode("utf-16-le"))//2} for line in lines]} + result["stateful_observation_count"] = len(roles) + result["post_initial_terminal_input"] = probe.fixed_contract(case)["post_initial_terminal_input"] + result["independent_dsr_bounds"] = {"row": [1, 200], "column": [1, 500]} + result["text_units"] = {"source": "UTF-8 bytes", "document_line": "original public API index", + "marker_text_offsets": "JS UTF-16 code units; scalar count coincides only for this ASCII source alphabet", + "position_api": "recorded lineStart API value plus JS UTF-16 offset, passed unchanged to visibleRectFromPosition", + "caret_api": "original API scalar values; numbering policy not inferred", + "rectangles": "actual public API pixel rectangles, kept separate from protocol cells", + "protocol_cursor": "DSR values kept independent of text offsets and pixel rectangles", + "cpp_cell_column_mapping": None, "document_position_unit_classification": None, + "ascii_only": True} + return result + + +def export(directory, plan_path, destination, expected_plan_sha256, source_root=ROOT, reference_source=None): + directory, plan_path, destination, source_root = [Path(path).resolve() for path in (directory, plan_path, destination, source_root)] + require(not destination.exists(), "Export destination must be new") + require(sha(plan_path) == digest(expected_plan_sha256), "Authorized plan bytes differ") + plan = read(plan_path) + require(all(type(plan.get(k)) is type(v) and plan[k] == v for k, v in probe.fixed_contract(plan.get("case_id")).items()), + "Fixed resize-hidden-height contract differs") + require(plan.get("execution_authorized") is True, "Plan did not authorize execution") + facts = capture_facts(directory, plan, source_root) + reference_source = Path(plan["source"]) if reference_source is None else Path(reference_source) + provenance = provenance_facts(directory, plan_path, plan, source_root, reference_source.resolve()) + basis = probe.fixed_contract(plan["case_id"])["geometry_basis"] + public_basis = {"hidden_primary_height_previously_observed": False} + for key in ("combined",): + entry = basis[key] + require(type(entry["execution_source_base_commit"]) is str and + re.fullmatch(r"[0-9a-f]{40}", entry["execution_source_base_commit"]), "Basis source commit differs") + public_basis[key] = {"reference": entry["reference"], + "strict_export_sha256": digest(entry["strict_export_sha256"]), + "execution_source_base_commit": entry["execution_source_base_commit"]} + result = {"schema": "termforge-original-resize-hidden-height-reference-v1", "issue": 29, "related_issue": 27, + "case_id": plan["case_id"], "authorized_plan_sha256": expected_plan_sha256, + "geometry_basis": public_basis, + "offline_export_source_files_sha256": {name: sha(ROOT / name) for name in EXPORT_SOURCES}, + "status": "observation", "observation_kind": "bounded-direct-protocol-and-hidden-height", "actual_original_runs": 1, "maximum_original_runs": 1, + "semantic_acceptance": False, "hidden_height_policy_classification": None, "full_active_grid_verified": False, + "terminal_styles_verified": False, "local_shell_transport_verified": False, "presented_done": None, + "performance_gate": "not_evaluated", "r2_complete": False, "rea_capture_seconds": 45, + "cli_wait_timeout_seconds": 60, "whole_lifecycle_bound_seconds": None, + "deliberate_original_process_termination": True, "natural_gui_exit_verified": False, + "capture": facts, "provenance": provenance, "limitations": [ + "One fresh direct Telnet process executes seven immutable stages: primary91x20 -> primary61x20 -> alternate61x20 -> alternate61x14 -> alternate61x20 -> primary61x20 -> primary91x20; every stage is observed before the next action.", + "After stage zero, terminal input is exactly one 1049h, one 1049l and seven-stage DSR observations: no CUP, CR/LF, redraw, replacement text or post-resize write.", + "Stage zero requires every preloaded sentinel and DSR19;7; later missing markers are observations, never semantic acceptance.", + "The preceding combined observation supplies measured 61x14 geometry only. Hidden-primary height behavior and both buffer transitions are observations of this run. Exact geometry and NAWS remain mandatory, including non-resize stages, with no calibration or retries.", + "Document indices, API positions, ASCII byte/UTF16 offsets, DSR and pixel rectangles are recorded separately; no general original position-unit or C++ Cell mapping is inferred.", + "DSR safety bounds remain row1..200/column1..500 independently of the fourteen-row viewport.", + "Only finite ASCII source-derived catalog strings may be published, with complete reconstructible document arrays. Unrestricted recording stays private.", + "This single schedule does not establish alternate-screen text preservation, Unicode, arbitrary dimensions or resize order, post-resize editing, ConPTY, performance or R2 acceptance."]} + payload = (json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False)+"\n").encode() + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("xb") as stream: + stream.write(payload) + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + for name in ("directory", "plan", "destination"): + parser.add_argument("--"+name, type=Path, required=True) + parser.add_argument("--expected-plan-sha256", required=True) + parser.add_argument("--source-root", type=Path, default=ROOT) + parser.add_argument("--reference-source", type=Path) + args = parser.parse_args() + result = export(args.directory, args.plan, args.destination, args.expected_plan_sha256, args.source_root, args.reference_source) + print(json.dumps({"exported": True, "case_id": result["case_id"], "phases": len(result["capture"]["phases"]), "sha256": sha(args.destination)})) diff --git a/research/ui/resize_hidden_height_reference.js b/research/ui/resize_hidden_height_reference.js new file mode 100644 index 00000000..00b7f88f --- /dev/null +++ b/research/ui/resize_hidden_height_reference.js @@ -0,0 +1,132 @@ +// Observe every resize and 1049 action; only the initial ASCII body contains printable text. +let stage=0,busy=false,finished=false,due=0,stable=null,stableSince=0,lastPhase=-1; +let gridKey=null,gridSince=0,nawsSeen=0,lastNaws=null,requested=0,resized=0,committed=0; +const contract=__RESIZE_HIDDEN_HEIGHT_CASE_JSON__,caseId=contract.case_id; +const counts={'hidden-primary-height':7}; +const phaseCount=counts[caseId],lastStage=phaseCount-1; +const windows=contract.windows,grids=contract.grids,resizePhases=contract.resize_phases; +if(!phaseCount||windows.length!==phaseCount||grids.length!==phaseCount||contract.labels.length!==phaseCount||JSON.stringify(resizePhases)!==JSON.stringify([1,3,4,6])||!Array.isArray(contract.marker_labels))throw Error('fixed hidden-primary height contract'); +const started=Date.now(),observations=[],geometryChecks=[]; +for(const module of [wind,core,gui,widgets]){ + const expected=(__RUN_DIRECTORY_JSON__+'/app/'+module.name).replace(/\\/g,'/').toLowerCase(); + if(module.path.replace(/\\/g,'/').toLowerCase()!==expected)throw Error('pinned Qt module path guard'); +} +function currentTerminal(){ + const raw=evalText(`JSON.stringify((function(){var t=window.activeText;if(!t)return null;function get(k){try{var v=typeof t[k]==='function'?t[k]():t[k];return v===undefined?null:v;}catch(e){return null;}}var n=get('visibleLineCount'),lines=[],markers=[],labels=${JSON.stringify(contract.marker_labels)};if(typeof n!=='number'||n<0||n>200)throw Error('document line bound');for(var i=0;i1024)throw Error('document text bound');lines.push(line);for(var j=0;j=256)throw Error('marker bound');var start=t.lineStart(i),p=start+offset;markers.push({label:labels[j],document_line:i,column:offset,line_start_api:start,position:p,rect:t.visibleRectFromPosition(p)});}}}return {protocol:get('protocolName'),connected:get('isConnected'),top:get('topLine'),scroll:get('scrollTop'),line_count:n,capacity:get('screenLineCapacity'),lines:lines,markers:markers,caret_api:{caretLine:get('caretLine'),caretColumn:get('caretColumn'),caretPosition:get('caretPosition')}};})())`); + return raw?JSON.parse(raw):null; +} +function write(name,value){const f=new File(__RUN_DIRECTORY_JSON__+'/'+name,'w');f.write(value);f.close();} +function windowGeometry(){ + const value=rect(main); + if(!Array.isArray(value)||value.length!==4||!value.every(Number.isInteger)||value[2]Number.isInteger(value)?value:null; + return {phase,requested_window:windows[phase],...windowGeometry(), + observed_grid:peer?[scalar(peer.columns),scalar(peer.rows)]:null, + terminal_capacity:terminal?scalar(terminal.capacity):null, + event_count:peer&&Array.isArray(peer.naws_history)?peer.naws_history.length:null}; +} +function checkGeometry(phase,peer,terminal){ + if(geometryChecks.length!==phase)throw Error('window geometry check order'); + const sample=geometrySample(phase,peer,terminal); + const measured={phase,window_rect:sample.window_rect,window_size:sample.window_size,expected_size:windows[phase], + observed_grid:sample.observed_grid,terminal_capacity:sample.terminal_capacity,event_count:sample.event_count}; + // Persist all measured scalars before any prediction can reject this endpoint. + geometryChecks.push(measured); + if(JSON.stringify(measured.window_size)!==JSON.stringify(windows[phase]))throw Error('public window dimensions differ'); + if(JSON.stringify(measured.observed_grid)!==JSON.stringify(grids[phase]))throw Error('fixed measured grid differs'); + if(measured.terminal_capacity!==grids[phase][1])throw Error('fixed measured capacity differs'); +} +function finish(status,error){ + if(finished)return;finished=true; + const result={status,case_id:caseId,error:error||null,observations,window_rect_format:'qt-qrect-inclusive-ltrb',geometry_checks:geometryChecks, + full_active_grid_verified:false,local_shell_transport_verified:false,presented_done:null}; + if(status==='failed'){ + result.failure_geometry=null; + const phase=Math.min(requested||observations.length,lastStage); + try{result.failure_geometry=geometrySample(phase,null,null);}catch(_){} + // Independent window evidence survives a failing terminal text binding. + if(result.failure_geometry){ + try{ + const peer=peerState(),scalar=value=>Number.isInteger(value)?value:null; + if(peer){result.failure_geometry.observed_grid=[scalar(peer.columns),scalar(peer.rows)]; + result.failure_geometry.event_count=Array.isArray(peer.naws_history)?peer.naws_history.length:null;} + }catch(_){} + try{const terminal=currentTerminal();result.failure_geometry.terminal_capacity=terminal&&Number.isInteger(terminal.capacity)?terminal.capacity:null;}catch(_){} + } + } + write('resize-hidden-height-result.json',JSON.stringify(result));send({kind:'resize_hidden_height_result',...result}); +} +function resetGrid(){gridKey=null;gridSince=0;} +function stableGrid(peer,terminal){ + const history=peer.naws_history; + if(!Array.isArray(history)||history.length64)throw Error('NAWS history bound'); + for(let i=nawsSeen;i!Number.isInteger(n)||n<0||n>65535))throw Error('NAWS event bound'); + const value=JSON.stringify(event);if(lastNaws!==null&&lastNaws!==value)resetGrid();lastNaws=value; + } + nawsSeen=history.length; + const value=JSON.stringify([peer.columns,peer.rows,terminal.capacity]); + if(!peer.columns||peer.rows!==terminal.capacity||JSON.stringify([peer.columns,peer.rows])!==lastNaws){resetGrid();return false;} + if(value!==gridKey){gridKey=value;gridSince=Date.now();return false;} + return Date.now()-gridSince>=600; +} +function requestResize(phase){requested=phase;write('resize-request-'+phase,JSON.stringify({phase,window:windows[phase]}));} +send({kind:'probe_ready',station_isolated:true,scenario:'resize-hidden-height-v1',case_id:caseId}); +Interceptor.attach(user.getExportByName('DispatchMessageW'),{onEnter(){ + if(busy||finished||Process.getCurrentThreadId()!==uiThread||Date.now()41000)throw Error('bounded scenario deadline'); + if(stage===0){if(!ready('ui'))return;resize(main,...windows[0]);stage=1;} + else if(stage===1){scan();expandAll(tree);focus(tree,7);key(tree,0x1000011);stage=2;} + else if(stage===2){key(tree,0x1000004);stage=3;} + else{ + const peer=peerState();if(!peer||!peer.connected){resetGrid();return;} + if(peer.error)throw Error('peer '+peer.error); + if(peer.complete){if(observations.length!==phaseCount)throw Error('incomplete resize-hidden-height observations');finish('observed-fixed-resize-hidden-height-states',null);return;} + const terminal=currentTerminal();if(!terminal||!terminal.connected||terminal.protocol!=='Telnet'){resetGrid();return;} + if(stage===3){ + if(!stableGrid(peer,terminal))return; + checkGeometry(0,peer,terminal);write('fixture-ready',JSON.stringify({grid:[peer.columns,peer.rows],event_count:peer.naws_history.length}));stage=4;return; + } + if(requested>committed&&peer.resize_armed===requested){ + if(resized!==requested){resize(main,...windows[requested]);resized=requested;resetGrid();return;} + if(!stableGrid(peer,terminal))return; + checkGeometry(requested,peer,terminal); + write('resize-ready-'+requested,JSON.stringify({phase:requested,grid:[peer.columns,peer.rows],event_count:peer.naws_history.length}));committed=requested;return; + } + if(peer.phase===null||observations.length>peer.phase)return; + if(peer.case_id!==caseId)throw Error('peer case differs'); + if(peer.phase_label!==contract.labels[peer.phase])throw Error('peer phase label differs'); + if(peer.phase!==lastPhase){lastPhase=peer.phase;stable=null;stableSince=Date.now();} + const record=peer.records[peer.phase];if(!record.cursor_reply)return; + if(terminal.capacity!==record.frozen_grid[1]||JSON.stringify(record.frozen_grid)!==JSON.stringify(grids[peer.phase]))throw Error('phase grid differs'); + if(geometryChecks.length===peer.phase)checkGeometry(peer.phase,peer,terminal); + const geometry=windowGeometry(),state=JSON.stringify([terminal,geometry]); + if(JSON.stringify(geometry.window_size)!==JSON.stringify(windows[peer.phase]))throw Error('phase window dimensions differ'); + if(state!==stable){stable=state;stableSince=Date.now();return;} + if(Date.now()-stableSince<400)return; + if(peer.phase===0){ + if(JSON.stringify(record.cursor_reply)!==JSON.stringify([19,7]))throw Error('initial fixture DSR differs'); + if(!Array.isArray(terminal.lines))throw Error('initial fixture text missing'); + for(const label of contract.marker_labels){ + const count=terminal.lines.reduce((total,line)=>total+line.split(label).length-1,0); + if(count!==1)throw Error('initial fixture sentinel missing or repeated '+label); + } + } + if(observations.length!==peer.phase||observations.length>=phaseCount)throw Error('resize-hidden-height phase order'); + const observation={phase:peer.phase,label:peer.phase_label,terminal,...geometry,cursor_reply:record.cursor_reply, + cursor_reply_private:record.cursor_reply_private,response_hex:record.response_hex,frozen_grid:record.frozen_grid}; + observations.push(observation);send({kind:'resize_hidden_height_phase',...observation}); + write('ack-'+peer.phase,'observed'); + if(resizePhases.includes(peer.phase+1))requestResize(peer.phase+1); + } + due=Date.now()+75; + }catch(error){finish('failed',String(error));}finally{busy=false;} +}}); +setTimeout(()=>finish('failed','bounded observation timer'),42000); diff --git a/research/ui/resize_hidden_height_reference.md b/research/ui/resize_hidden_height_reference.md new file mode 100644 index 00000000..157e0b68 --- /dev/null +++ b/research/ui/resize_hidden_height_reference.md @@ -0,0 +1,52 @@ +# WindTerm 2.7.0:主屏隐藏期间的高度变化 + +本工具在主屏先写入正文并缩宽,再进入备用屏完成高度缩小和恢复,最后返回主屏并恢复宽度。调查目标是**隐藏主屏的保留右尾、saved cursor 与高度变化的交叉**。这是独立 namespace、源码归档和新计划;已有 active-frame 观察不能代填 hidden-primary 策略。 + +## 已知依据与本次合同 + +[组合尺寸观察](results/2026-11-02-resize-combined-reference/README.md)已经实际验证本次使用的五组窗口/NAWS 几何,并在始终活动的主屏中保留 75 B 正文。其执行源为 `2c7c5929e11d911ad69a7e2b7c420120591862ed`,严格公开件 SHA256 为 `6a1561f3e6f6bc480c6a2f086adf7ed4798d5007b04a7bc71c7ae9deba90cfe6`。本工具的 `geometry_basis.hidden_primary_height_previously_observed=false` 明确隐藏主屏语义仍未知;不能由活动主屏的 DSR 或文档恢复结果推出它。 + +唯一 case `hidden-primary-height`,独立新进程最多运行一次: + +| 阶段 | 窗口,px | NAWS,列×行 | 本阶段源,再 DSR | +| --- | --- | --- | --- | +| primary-initial-body | 1360×749 | 91×20 | 固定 75 B 主屏正文 | +| primary-width-shrunk | 1000×749 | 61×20 | 无 | +| alternate-entered | 1000×749 | 61×20 | `ESC[?1049h`,8 B | +| alternate-height-shrunk | 1000×569 | 61×14 | 无 | +| alternate-height-restored | 1000×749 | 61×20 | 无 | +| primary-restored | 1000×749 | 61×20 | `ESC[?1049l`,8 B | +| primary-width-restored | 1360×749 | 91×20 | 无 | + +只有阶段 `1,3,4,6` 改变窗口;阶段 2、5 不改变窗口,却必须各自完成输入、DSR、稳定正文观察和 ack,不能跳过。各阶段都有独立冻结的 grid;非 resize 阶段复用当前 NAWS 事件数,不能伪造一次尺寸事件。 + +初始源与组合尺寸样本相同: + +```text +1b5b3f366c1b5b3f37681b5b721b5b306d1b5b324a1b5b323b314848544f501b5b363b3148484152445f410d0a484152445f421b5b31323b3930487576771b5b31393b31484c4f57454e44 +``` + +75 B SHA256 为 `ff48cd41220a72e5da2350dc97c3c8c8c884d48cc4f7fa2328609bf8bc0cb09e`。两条 1049 源分别为 `1b5b3f3130343968`、`1b5b3f313034396c`,合计源 91 B;七次 DSR 共 28 B。除这两条明确的模式切换外,初始之后不发送可打印正文、CUP、CR/LF、清屏或重绘。 + +## 观察与停止边界 + +- 初始稳定阶段要求 `HTOP`、`HARD_A`、`HARD_B`、`u`、`v`、`w`、`LOWEND` 各一次和 DSR `19;7`,排除无效预置。后续正文、marker、saved cursor 和返回主屏结果均为待观察,不预设恢复结果。 +- 备用屏的空文档或零 marker 可以记录。它们不能当作整个语义通过,也不允许用假造的主屏内容填充空观察;返回主屏仍采完整有限文档及 marker 列表,包括可能缺失的右尾。 +- 每阶段分别记录 API 文档行、完整合成字符串、source/API marker 位置、像素矩形、capacity/scroll/top 与 DSR。DSR 独立安全界限是行 1..200、列 1..500,不按 14 行 viewport 夹断。ASCII 文本 offset 重合不推广为一般 API 位置单位。 +- resize 仅接受 armed 单轴区间,准确末端 NAWS/capacity 稳定 600 ms 后才释放该阶段输入;正文/窗口稳定 400 ms 后才确认。非 resize 的 1049 阶段也完整等待 400 ms 观察和 ack,期间不授权 NAWS 变化。 +- 先保存实际窗口、NAWS、capacity、事件数再检查固定几何。失败快照保留独立可取得的窗口与 peer 字段,terminal 绑定失败不能删除它们;不可取得的字段保留 null,不自动校准。 +- 完整正文只允许固定 ASCII 源及 61/91 列切分、空白 padding 的有限目录。重复 marker、私有文本、目录外内容、越界、陈旧事件、缺少 ack、来源变化或期限到达均拒绝并保留失败,不修改旧源码或目录取得通过。 + +严格成功出口仍固定为 `status=observation`、`semantic_acceptance=false`。空备用屏、保留或丢失右尾、DSR 变化都是调查结果;如何实现只能依据实际结果另作字面回放。 + +## 独立身份与离线验证 + +运行源码固定 10 个文件,出口身份固定 3 个文件;全部使用新文件名和 schema,旧 height、geometry、measured、combined 源不修改。复用既有 protected launcher、合成 profile、原包/模块、manifest、station/Job、单次 `.attempted` 守卫;保留 41/42/43/45/60 s 各层期限,全生命周期界限仍为 null,不自动重试或延时。 + +```powershell +python -B -X utf8 -m unittest research.ui.test_resize_hidden_height_reference research.ui.test_export_resize_hidden_height_reference -v +python -B -O -X utf8 -m unittest research.ui.test_resize_hidden_height_reference research.ui.test_export_resize_hidden_height_reference -v +node research/ui/test_resize_hidden_height_stages.js research/ui/resize_hidden_height_reference.js +``` + +Python 使用内存连接驱动真实 peer,Node 使用实际 controller;离线检查不启动原版、socket、GUI 或 native 构建。源码提交和归档由执行者统一冻结,之后建立一次新计划。未运行原版前,不能把离线测试或活动主屏结果写成隐藏主屏已验收;本工具也不覆盖 Unicode、其他 margin/origin、后续编辑、任意尺寸、历史淘汰、ConPTY、性能或完整 R2。 diff --git a/research/ui/resize_hidden_height_reference.py b/research/ui/resize_hidden_height_reference.py new file mode 100644 index 00000000..7c0397ae --- /dev/null +++ b/research/ui/resize_hidden_height_reference.py @@ -0,0 +1,380 @@ +"""Observe hidden-primary height retention across 1049 without inferring its policy.""" +from __future__ import annotations +import argparse +import hashlib +import json +from functools import lru_cache +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from research.ui import mode_state_reference as common +from research.ui import resize_state_reference as baseline + +require, sha = common.require, common.sha +qrect_size = baseline.qrect_size +EXE_SHA256, MANIFEST_SHA256 = common.EXE_SHA256, common.MANIFEST_SHA256 +RESET = b"\x1b[?6l\x1b[?7h\x1b[r\x1b[0m\x1b[2J" +BODY = (RESET + b"\x1b[2;1HHTOP\x1b[6;1HHARD_A\r\nHARD_B" + b"\x1b[12;90Huvw\x1b[19;1HLOWEND") +MARKERS = ("HTOP", "HARD_A", "HARD_B", "u", "v", "w", "LOWEND") +CASES = { + "hidden-primary-height": (("primary-initial-body", 91, 20, BODY), + ("primary-width-shrunk", 61, 20, b""), + ("alternate-entered", 61, 20, b"\x1b[?1049h"), + ("alternate-height-shrunk", 61, 14, b""), + ("alternate-height-restored", 61, 20, b""), + ("primary-restored", 61, 20, b"\x1b[?1049l"), + ("primary-width-restored", 91, 20, b"")), +} +RESULT_FILE = "resize-hidden-height-result.json" +ATTEMPT = "One original resize-hidden-height launch attempt; preserve failures and never retry this plan.\n" +SOURCE_FILES = ("research/ui/resize_hidden_height_reference.py", "research/ui/resize_hidden_height_reference.js", + "research/ui/resize_state_reference.py", "research/ui/mode_state_reference.py", + "research/ui/alternate_reference.py", "research/ui/r1_launch.py", "research/ui/launch.py", + "research/ui/qt_r1_text.js", "research/ui/r1_vt_fixture.py", "experiments/conpty/run.py") + + +def validate_case(case): + require(type(case) is str and case in CASES, "Unknown fixed resize-hidden-height case") + return case + + +def windows(case): + validate_case(case) + return [(1360 if columns == 91 else 1000, 749 if rows == 20 else 569) + for _, columns, rows, _ in CASES[case]] + + +def required_grids(case): + validate_case(case) + return [(columns, rows) for _, columns, rows, _ in CASES[case]] + + +def resize_phases(case): + values = required_grids(case) + return tuple(index for index in range(1, len(values)) if values[index] != values[index-1]) + + +def controller_contract(case): + validate_case(case) + return {"case_id": case, "labels": [label for label, _, _, _ in CASES[case]], + "windows": [list(v) for v in windows(case)], "grids": [list(v) for v in required_grids(case)], + "resize_phases": list(resize_phases(case)), "marker_labels": list(MARKERS), + "initial_dsr": [19, 7]} + + +def phases(case, rows=20, columns=91): + validate_case(case) + require(type(rows) is int and type(columns) is int and [columns, rows] == [91, 20], "Fixed initial grid differs") + return [(label, value) for label, _, _, value in CASES[case]] + + +def reply_observation(data): + result = common.reply_observation(data) + if result is not None: + row, column = result["cursor_reply"] + require(1 <= row <= 200 and 1 <= column <= 500, "DSR exceeds independent safety bounds") + return result + + +def phase_records(case, rows=20, columns=91): + return [{"phase": index, "label": label, "source_hex": value.hex(), "source_bytes": len(value), + "source_sha256": hashlib.sha256(value).hexdigest(), "dsr_suffix_hex": "1b5b366e"} + for index, (label, value) in enumerate(phases(case, rows, columns))] + + +def allowed_grid(before, after, value): + if not all(type(v) in (list, tuple) and len(v) == 2 and + all(type(n) is int and 1 <= n <= 65535 for n in v) for v in (before, after, value)): + return False + changed = [axis for axis in (0, 1) if before[axis] != after[axis]] + return len(changed) == 1 and all( + min(before[axis], after[axis]) <= value[axis] <= max(before[axis], after[axis]) + if axis == changed[0] else value[axis] == before[axis] for axis in (0, 1)) + + +def peer_class(case): + validate_case(case) + base = baseline.peer_class() + # baseline creates a fresh module namespace for each class, not a shared peer. + base._serve.__globals__["phases"] = lambda rows, columns: phases(case, rows, columns) + base._serve.__globals__["reply_observation"] = reply_observation + + class HiddenHeightPeer(base): + def _freeze_initial(self): + super()._freeze_initial() + require(self.state["frozen_grid"] == [91, 20], "Fixed initial grid differs") + self.state["case_id"] = case + + def _on_naws(self): + grid = [self.state["columns"], self.state["rows"]] + history = self.state.setdefault("naws_history", []) + require(len(history) < 64 and all(type(n) is int and 0 <= n <= 65535 for n in grid), "NAWS event bound") + history.append(grid) + self._record() + if self.state.get("frozen_grid"): + target = self.state.get("resize_armed") + if target is None: + require(grid == self.state["frozen_grid"], "NAWS changed outside an armed resize") + else: + require(target in resize_phases(case) and + allowed_grid(self.state["frozen_grid"], required_grids(case)[target], grid), + "NAWS changed an unarmed axis or exceeded the fixed interval") + + def _phase_ready(self, index): + if index == -1: + return True + ack = self.directory / f"ack-{index}" + if not ack.exists() or self.state["records"][index]["cursor_reply"] is None: + return False + require(ack.read_text(encoding="utf-8") == "observed", "UI acknowledgement differs") + if index == 0: + require(self.state["records"][0]["cursor_reply"] == [19, 7], "Initial fixture DSR differs") + if index == len(phases(case))-1: + return True + target = index + 1 + if target not in resize_phases(case): + require(self.state["resize_armed"] is None and self.state["frozen_grid"] == list(required_grids(case)[target]), + "Action was not released from its fixed per-stage grid") + self.state["frozen_grids"].append(list(self.state["frozen_grid"])) + self.state["freeze_event_counts"].append(len(self.state["naws_history"])) + return True + request = self.directory / f"resize-request-{target}" + if not request.exists(): + return False + require(json.loads(request.read_text(encoding="utf-8")) == {"phase": target, "window": list(windows(case)[target])}, + "Resize request differs from immutable window sequence") + if self.state["resize_armed"] is None: + self.state["resize_armed"] = target + self.state["resize_boundaries"].append({"phase": target, "before_event_count": len(self.state["naws_history"]), + "requested_window": list(windows(case)[target])}) + self._record() + return False + require(self.state["resize_armed"] == target, "Resize phase differs") + ready = self.directory / f"resize-ready-{target}" + if not ready.exists(): + return False + grid, history = [self.state["columns"], self.state["rows"]], self.state["naws_history"] + require(json.loads(ready.read_text(encoding="utf-8")) == + {"phase": target, "grid": grid, "event_count": len(history)}, "Resize ready snapshot is stale") + boundary = self.state["resize_boundaries"][-1] + require(len(history) > boundary["before_event_count"] and history[-1] == grid == list(required_grids(case)[target]), + "Fixed resized grid or NAWS boundary differs") + boundary["after_event_count"] = len(history) + self.state.update(frozen_grid=grid, resize_armed=None) + self.state["frozen_grids"].append(grid) + self.state["freeze_event_counts"].append(len(history)) + return True + return HiddenHeightPeer + + +def source_identity(): + return common.source_identity(root=ROOT, source_files=SOURCE_FILES) + + +def probe_fragment(case): + validate_case(case) + source = (ROOT / "research/ui/resize_hidden_height_reference.js").read_text(encoding="utf-8") + require(source.count("__RESIZE_HIDDEN_HEIGHT_CASE_JSON__") == 1, "Protected case template changed") + return source.replace("__RESIZE_HIDDEN_HEIGHT_CASE_JSON__", json.dumps(controller_contract(case))) + + +def launcher_source(case): + validate_case(case) + source = (ROOT / "research/ui/r1_launch.py").read_text(encoding="utf-8") + replacements = { + "from research.ui.r1_vt_fixture import Peer": + "from research.ui.resize_hidden_height_reference import peer_class, probe_fragment\n Peer = peer_class("+repr(case)+")", + 'Path(__file__).with_name("r1_menu_wrap.js").read_text(encoding="utf-8")': "probe_fragment("+repr(case)+")", + } + for old, new in replacements.items(): + require(source.count(old) == 1, "Protected launcher source changed") + source = source.replace(old, new) + return source + + +def fixed_contract(case): + validate_case(case) + return {"schema": "termforge-resize-hidden-height-plan-v1", "issue": 29, "related_issue": 27, "case_id": case, + "maximum_original_runs": 1, "actual_original_runs": 0, "phase_count": len(phases(case)), + "windows": [list(v) for v in windows(case)], "required_grids": [list(v) for v in required_grids(case)], + "resize_phases": list(resize_phases(case)), "window_rect_format": "qt-qrect-inclusive-ltrb", + "rea_capture_seconds": 45, "cli_wait_timeout_seconds": 60, "whole_lifecycle_bound_seconds": None, + "phases": phase_records(case), "reference_exe_sha256": EXE_SHA256, + "reference_files_manifest_sha256": MANIFEST_SHA256, "semantic_acceptance": False, + "controller_contract": controller_contract(case), "execution_group": "one-hidden-primary-height-case", + "independent_dsr_bounds": {"row": [1, 200], "column": [1, 500]}, + "initial_fixture_dsr": [19, 7], "initial_fixture_markers": list(MARKERS), + "geometry_basis": { + "combined": {"reference": "research/ui/results/2026-11-02-resize-combined-reference/strict-export.json", + "strict_export_sha256": "6a1561f3e6f6bc480c6a2f086adf7ed4798d5007b04a7bc71c7ae9deba90cfe6", + "execution_source_base_commit": "2c7c5929e11d911ad69a7e2b7c420120591862ed"}, + "hidden_primary_height_previously_observed": False}, + "hidden_height_status": "The window/grid sequence is based on prior active-frame geometry; hidden-primary text and saved-cursor behavior remain unknown", + "post_initial_terminal_input": "1049h atphase2 and1049l atphase5, each followed by DSR; otherwise DSR only, no replacement text or redraw", + "scope": "Fresh direct Telnet process per case; document/API positions, DSR and pixel rectangles remain separate observations", + "stop": "Wrong fixed geometry/grid, unarmed or wrong-axis NAWS, stale snapshots, initial fixture/source/reply/phase mismatch, bounds or deadline failure; no retries"} + + +def bounded_fragments(lines, split): + result = set(lines) + for line in lines: + result.add(line.rstrip(" ")) + for width in ((61, 91) if split else (91,)): + if len(line) <= width: + result.add(line.ljust(width)) + for edge in ((61, 91) if split else (91,)): + if len(line) > edge: + result.update((line[:edge], line[edge:], line[:edge].rstrip(" "), line[edge:].rstrip(" "))) + return result + + +@lru_cache(maxsize=16) +def synthetic_lines(case, phase): + """Finite ASCII source-derived words; loss is observation, not acceptance.""" + validate_case(case) + require(type(phase) is int and 0 <= phase < len(phases(case)), "Phase bound") + base = {"", "HTOP", "HARD_A", "HARD_B", "LOWEND", " "*89+"uvw"} + return frozenset(bounded_fragments(base, phase > 0)) + + +def validate_document(case, phase, terminal): + require(type(terminal.get("line_count")) is int and 0 <= terminal["line_count"] <= 200 and + type(terminal.get("lines")) is list and len(terminal["lines"]) == terminal["line_count"], "Document bound") + lines = terminal["lines"] + require(all(type(line) is str and line.isascii() and line in synthetic_lines(case, phase) for line in lines), + "Only fixed hidden-height-source synthetic document lines are allowed") + counts = {marker: sum(line.count(marker) for line in lines) for marker in MARKERS} + require(all(count <= 1 for count in counts.values()), "Repeated hidden-height-source marker") + if phase == 0: + require(all(count == 1 for count in counts.values()), "Initial fixture sentinel missing") + + +def audit_mechanics(directory, case): + validate_case(case) + count = len(phases(case)) + expected_grids, expected_windows = [list(v) for v in required_grids(case)], [list(v) for v in windows(case)] + read = lambda name: json.loads((directory / name).read_text(encoding="utf-8")) + require((directory / "stderr.txt").read_bytes() == b"" and not (directory / "launcher-failure.json").exists(), + "REA stderr/launcher failure retained") + peer, result, cleanup, profile = [read(name) for name in ("peer.json", RESULT_FILE, "cleanup.json", "profile-check.json")] + require(all(peer.get(k) is True for k in ("connected", "closed", "complete")) and not peer.get("error") and peer.get("case_id") == case, + "Hidden-primary peer did not complete cleanly") + require(result.get("status") == "observed-fixed-resize-hidden-height-states" and result.get("case_id") == case and result.get("error") is None and + result.get("window_rect_format") == "qt-qrect-inclusive-ltrb" and result.get("presented_done") is None and + result.get("full_active_grid_verified") is False and result.get("local_shell_transport_verified") is False and + "failure_geometry" not in result, + "Hidden-primary UI observation incomplete") + require(type(cleanup.get("active")) is int and cleanup["active"] == 0 and + all(type(cleanup.get(k)) is int and cleanup[k] >= 0 for k in ("total", "terminated")) and + profile == {"only_synthetic_session": True, "session_count": 1}, "Owned cleanup/profile differs") + grids, freezes, history = peer["frozen_grids"], peer["freeze_event_counts"], peer["naws_history"] + require(grids == expected_grids and len(freezes) == count and type(history) is list and 1 <= len(history) <= 64 and + all(type(grid) is list and len(grid) == 2 and all(type(n) is int and 0 <= n <= 65535 for n in grid) for grid in history) and + all(type(n) is int and 1 <= n <= len(history) for n in freezes), "Fixed grids/freeze/NAWS bounds differ") + require(len(peer["resize_boundaries"]) == len(resize_phases(case)) and freezes == sorted(freezes), "Resize boundary count differs") + for index, boundary in zip(resize_phases(case), peer["resize_boundaries"]): + before, after = boundary["before_event_count"], boundary["after_event_count"] + require(type(before) is int and type(after) is int and boundary["phase"] == index and + boundary["requested_window"] == expected_windows[index] and freezes[index-1] <= before < after == freezes[index] <= len(history) and + all(grid == grids[index-1] for grid in history[freezes[index-1]-1:before]) and + all(allowed_grid(grids[index-1], grids[index], grid) for grid in history[before:after]) and history[after-1] == grids[index], "NAWS resize boundary differs") + for index in range(1, count): + if index not in resize_phases(case): + require(grids[index] == grids[index-1] and + all(grid == grids[index] for grid in history[freezes[index-1]-1:freezes[index]]), + "Unarmed per-stage NAWS change retained") + require(all(grid == grids[-1] for grid in history[freezes[-1]-1:]), "Unarmed final NAWS change retained") + records, observations, checks = peer["records"], result["observations"], result["geometry_checks"] + require(len(records) == len(observations) == len(checks) == count and peer["phase"] == count-1 and peer["phase_count"] == count, + "Fixed hidden-primary height phases incomplete") + replies = bytearray() + for index, (expected, actual, observed, check) in enumerate(zip(phase_records(case), records, observations, checks)): + grid, window = expected_grids[index], expected_windows[index] + require(all(actual.get(k) == v for k, v in expected.items()) and actual["frozen_grid"] == grid, "Exact action source differs") + raw = bytes.fromhex(actual["response_hex"]); reply = reply_observation(raw) + if index == 0: + require(reply is not None and reply["cursor_reply"] == [19, 7], "Initial fixture DSR differs") + require(reply is not None and all(actual.get(k) == v for k, v in reply.items()), "DSR differs") + require(all(observed.get(k) == actual[k] for k in ("phase", "label", "frozen_grid", *reply)), "Peer/UI phase differs") + require(check.get("phase") == index and check.get("expected_size") == window and check.get("window_size") == window and + baseline.qrect_size(check.get("window_rect")) == window and observed.get("window_size") == window and + baseline.qrect_size(observed.get("window_rect")) == window, "Exact geometry differs") + terminal = observed["terminal"] + require(check.get("observed_grid") == grid and type(check.get("terminal_capacity")) is int and + check["terminal_capacity"] == grid[1] and type(check.get("event_count")) is int and + check["event_count"] == freezes[index], "Saved actual geometry differs") + require(terminal.get("connected") is True and terminal.get("protocol") == "Telnet" and + type(terminal.get("capacity")) is int and terminal["capacity"] == grid[1], "UI binding differs") + validate_document(case, index, terminal) + require(type(terminal.get("markers")) is list and len(terminal["markers"]) <= 256, "Marker bound") + require((directory / f"ack-{index}").read_text(encoding="utf-8") == "observed", "Phase acknowledgment missing") + replies.extend(raw) + require(peer["received_hex"] == replies.hex(), "Unassigned reply bytes retained") + return {"schema": "termforge-resize-hidden-height-mechanics-v1", "case_id": case, "stage_count": count, "dsr_count": count, + "stateful_observations_preserved": True, "frozen_grids": grids, "naws_events": len(history), "job_active": 0, + "semantic_acceptance": False, "presented_done": None} + + +def prepare_plan(source, out, rea, destination, case, execution_authorized=False): + validate_case(case) + source, out, rea, destination = [Path(path).resolve() for path in (source, out, rea, destination)] + require(type(execution_authorized) is bool, "Execution authorization must be boolean") + require(not out.exists() and not destination.exists(), "Plan/output must be new") + require(out != ROOT / "local" and out.is_relative_to(ROOT / "local") and destination.is_relative_to(ROOT / "local"), + "Plan/output must belong to this worktree local directory") + require(not out.is_relative_to(source) and not source.is_relative_to(out), "Source/output overlap") + require(sha(source / "WindTerm.exe") == EXE_SHA256, "Pinned original executable differs") + plan = {**fixed_contract(case), **source_identity(), **common.host_identity(), "execution_authorized": execution_authorized, + "source": str(source), "out": str(out), "rea": str(rea), "rea_prepared_identity": common.rea_identity(rea), + "launcher_source_sha256": hashlib.sha256(launcher_source(case).encode()).hexdigest()} + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("x", encoding="utf-8", newline="\n") as stream: + stream.write(json.dumps(plan, indent=2)+"\n") + return plan + + +def run_plan(path): + path = Path(path).resolve(); plan = json.loads(path.read_text(encoding="utf-8")) + require(plan.get("execution_authorized") is True, "Offline proposal only; execution is not authorized") + case = validate_case(plan.get("case_id")) + require(all(type(plan.get(k)) is type(v) and plan[k] == v for k, v in fixed_contract(case).items()), "Fixed hidden-primary height contract differs") + require(all(plan.get(k) == v for k, v in source_identity().items()), "Hidden-primary source identity changed") + require(all(plan.get(k) == v for k, v in common.host_identity().items()), "Execution host/runtime differs") + require(plan["launcher_source_sha256"] == hashlib.sha256(launcher_source(case).encode()).hexdigest(), "Launcher source differs") + source, out, rea = [Path(plan[k]).resolve() for k in ("source", "out", "rea")] + require(path.is_relative_to(ROOT / "local") and out != ROOT / "local" and out.is_relative_to(ROOT / "local") and + not out.exists(), "Hidden-primary run plan/output is not new/local") + require(not out.is_relative_to(source) and not source.is_relative_to(out), "Source/output overlap") + require(plan["rea_prepared_identity"] == common.rea_identity(rea), "Prepared REA entry changed") + require(sha(source / "WindTerm.exe") == EXE_SHA256, "Pinned original executable differs") + with path.with_suffix(".attempted").open("x", encoding="utf-8") as marker: + marker.write(ATTEMPT) + namespace = {"__file__": str(ROOT / "research/ui/r1_launch.py"), "__name__": "termforge_resize_hidden_height_launcher"} + exec(compile(launcher_source(case), namespace["__file__"], "exec"), namespace) + namespace["cli_command"] = lambda _: [plan["rea_prepared_identity"]["node_executable"], str(rea.parent.parent / "rea-agents/scripts/rea.mjs")] + namespace["run"](source, out, rea, 45, False, "menu-wrap") + require(all(plan.get(k) == v for k, v in source_identity().items()), "Hidden-primary source identity changed during run") + mechanics = audit_mechanics(out, case) + (out / "mechanics-check.json").write_text(json.dumps(mechanics, indent=2)+"\n", encoding="utf-8", newline="\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--plan", type=Path, required=True) + parser.add_argument("--prepare", action="store_true") + parser.add_argument("--case", choices=list(CASES)) + parser.add_argument("--authorize-execution", action="store_true") + for name in ("source", "out", "rea"): + parser.add_argument("--"+name, type=Path) + args = parser.parse_args() + if args.prepare: + require(args.source and args.out and args.rea and args.case, "Source/output/REA/case required") + prepare_plan(args.source, args.out, args.rea, args.plan, args.case, args.authorize_execution) + print(json.dumps({"prepared": True, "case_id": args.case, "phase_count": len(phases(args.case)), "execution_authorized": args.authorize_execution})) + else: + require(not args.authorize_execution and args.case is None and args.source is None and args.out is None and args.rea is None, + "Execution uses only the immutable plan") + run_plan(args.plan) diff --git a/research/ui/test_export_resize_hidden_height_reference.py b/research/ui/test_export_resize_hidden_height_reference.py new file mode 100644 index 00000000..bb413584 --- /dev/null +++ b/research/ui/test_export_resize_hidden_height_reference.py @@ -0,0 +1,408 @@ +"""Synthetic seven-stage hidden-height publication guards; no target, sockets or native UI.""" +from __future__ import annotations +import copy +from contextlib import contextmanager +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from research.ui import export_resize_hidden_height_reference as exporter +from research.ui import resize_hidden_height_reference as probe +from research.ui import mode_state_reference as original_probe +from research.ui import export_mode_state_reference as original_exporter +from research.ui.test_resize_hidden_height_reference import recording +from research.ui.test_export_mode_state_reference import fixture as mode_fixture, complete_fixture as mode_complete_fixture + + +def save(directory, data, synchronize=True): + if synchronize: + result = data[probe.RESULT_FILE] + payloads = [{"kind": "probe_ready", "station_isolated": True, "scenario": "resize-hidden-height-v1", "case_id": result["case_id"]}, + {"kind": "readiness", "phase": "ui", "ready": True, "has_tree": True, + "has_engine": True, "has_editor": False, "peer_ready": False}] + payloads.extend({"kind": "resize_hidden_height_phase", **copy.deepcopy(row)} for row in result["observations"]) + payloads.append({"kind": "resize_hidden_height_result", **copy.deepcopy(result)}) + data["evidence.json"]["data"]["evidence"]["normalized_result"]["messages"] = [ + {"type": "send", "script_id": "synthetic-script", "payload": value} for value in payloads] + for name, value in data.items(): + (directory / name).write_text(json.dumps(value), encoding="utf-8") + + +def marker_rows(lines): + result, offset = [], 0 + for row, line in enumerate(lines): + for label in exporter.MARKERS: + column = line.find(label) + while column >= 0: + result.append({"label": label, "document_line": row, "column": column, "position": offset+column, "line_start_api": offset, + "rect": {"x": column*8, "y": row*18, "width": 8, "height": 18}}) + column = line.find(label, column+1) + offset += len(line)+1 + return result + + +def fixture(root, case="hidden-primary-height"): + directory, data, _ = mode_fixture(root) + data.pop("mode-state-result.json") + # A complete fixture contains only this protocol's declared acknowledgments. + for path in directory.glob("ack-*"): + path.unlink() + state, result = recording(case) + for observation in result["observations"]: + terminal = observation["terminal"] + terminal["markers"] = marker_rows(terminal["lines"]) + data["peer.json"], data[probe.RESULT_FILE] = state, result + plan = {**probe.fixed_contract(case), "execution_authorized": True, "out": str(directory), "source": str(root / "official")} + run = data["run.json"] + run.update(sha256=probe.EXE_SHA256, files_sha256=probe.MANIFEST_SHA256) + template = (probe.ROOT / "research/ui/qt_r1_text.js").read_text(encoding="utf-8").split("// Scenario driver", 1)[0] + template += probe.probe_fragment(case) + run["template_sha256"] = hashlib.sha256(template.encode()).hexdigest() + generated = exporter.expected_probe(plan, run, probe.ROOT) + (directory / "probe.js").write_bytes(generated) + run["script_sha256"] = hashlib.sha256(generated).hexdigest() + data["evidence.json"]["data"]["evidence"]["normalized_result"]["source_sha256"] = run["script_sha256"] + (directory / "fixture-ready").write_text(json.dumps({"grid": state["frozen_grids"][0], "event_count": state["freeze_event_counts"][0]})) + for index in range(len(probe.phases(case))): + (directory / f"ack-{index}").write_text("observed") + for index in probe.resize_phases(case): + (directory / f"resize-request-{index}").write_text(json.dumps({"phase": index, "window": list(probe.windows(case)[index])})) + (directory / f"resize-ready-{index}").write_text(json.dumps({"phase": index, + "grid": state["frozen_grids"][index], "event_count": state["freeze_event_counts"][index]})) + save(directory, data) + (directory / "mechanics-check.json").write_text(json.dumps(probe.audit_mechanics(directory, case))) + return directory, data, plan + + +@contextmanager +def complete_fixture(root, case="hidden-primary-height"): + with mode_complete_fixture(root) as original: + old_directory, _, old_plan, _, source_root, official = original + with patch.object(probe, "EXE_SHA256", original_probe.EXE_SHA256), \ + patch.object(probe, "MANIFEST_SHA256", original_probe.MANIFEST_SHA256), \ + patch.dict(exporter._PROVENANCE, FILE_COUNT=original_exporter.FILE_COUNT): + new_root = root / "hidden-height" + new_root.mkdir() + directory, data, case_plan = fixture(new_root, case) + plan = {**old_plan, **case_plan, "source": str(official)} + (directory / "app").mkdir() + for name in original_exporter.MODULES: + (directory / "app" / name).write_bytes((official / name).read_bytes()) + (directory / "files.json").write_bytes((old_directory / "files.json").read_bytes()) + profile = "profile/.wind/profiles/default.v10/terminal/user.sessions" + (directory / profile).parent.mkdir(parents=True) + (directory / profile).write_bytes((old_directory / profile).read_bytes()) + for name in probe.SOURCE_FILES: + (source_root / name).parent.mkdir(parents=True, exist_ok=True) + (source_root / name).write_bytes((probe.ROOT / name).read_bytes()) + archive = {"schema": probe.common.ARCHIVE_SCHEMA, "source_base_commit": "b"*40, + "source_files_sha256": {name: probe.sha(source_root / name) for name in probe.SOURCE_FILES}} + (source_root / probe.common.ARCHIVE_MANIFEST).write_text(json.dumps(archive), encoding="utf-8") + plan.update(probe.common.source_identity(root=source_root, source_files=probe.SOURCE_FILES)) + plan["launcher_source_sha256"] = hashlib.sha256(probe.launcher_source(case).encode()).hexdigest() + plan_path = new_root / "plan.json" + plan_path.write_text(json.dumps(plan), encoding="utf-8") + plan_path.with_suffix(".attempted").write_text(probe.ATTEMPT) + yield directory, data, plan, plan_path, source_root, official + + +class ResizeHiddenHeightExportTests(unittest.TestCase): + def test_fixed_seven_stage_contract_has_exact_1049_inputs_and_nonresize_stages(self): + with tempfile.TemporaryDirectory() as temporary: + directory, _, plan = fixture(Path(temporary).resolve()) + result = exporter.capture_facts(directory, plan) + source = (b"\x1b[?6l\x1b[?7h\x1b[r\x1b[0m\x1b[2J\x1b[2;1HHTOP\x1b[6;1HHARD_A\r\nHARD_B" + b"\x1b[12;90Huvw\x1b[19;1HLOWEND") + self.assertEqual(len(source), 75) + self.assertEqual(result["stateful_observation_count"], 7) + self.assertEqual([value["frozen_grid"] for value in result["phases"]], [[91,20],[61,20],[61,20],[61,14],[61,20],[61,20],[91,20]]) + self.assertEqual([value["window_size"] for value in result["phases"]], [[1360,749],[1000,749],[1000,749],[1000,569],[1000,749],[1000,749],[1360,749]]) + inputs = (source, b"", b"\x1b[?1049h", b"", b"", b"\x1b[?1049l", b"") + self.assertEqual([value["source_hex"] for value in result["phases"]], [value.hex() for value in inputs]) + self.assertEqual([value["source_sha256"] for value in result["phases"]], + [hashlib.sha256(value).hexdigest() for value in inputs]) + self.assertEqual(result["source_bytes"], 91) + self.assertEqual(result["dsr_request_bytes"], 28) + self.assertEqual([phase["stage_role"] for phase in result["phases"]], [ + "primary-initial-body", "primary-width-shrunk", "alternate-entered", "alternate-height-shrunk", + "alternate-height-restored", "primary-restored", "primary-width-restored"]) + self.assertEqual([item["observed_grid"] for item in result["geometry_checks"]], + [[91,20],[61,20],[61,20],[61,14],[61,20],[61,20],[91,20]]) + self.assertEqual([item["terminal_capacity"] for item in result["geometry_checks"]], [20,20,20,14,20,20,20]) + self.assertEqual([item["event_count"] for item in result["geometry_checks"]], result["freeze_event_counts"]) + self.assertEqual([item["phase"] for item in result["resize_boundaries"]], [1,3,4,6]) + + def test_later_DSR_above_fourteen_rows_is_observed_without_clamping(self): + with tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + peer = data["peer.json"] + reply = b"\x1b[200;500R" + for record in (peer["records"][3], data[probe.RESULT_FILE]["observations"][3]): + record.update(cursor_reply=[200,500], cursor_reply_private=False, response_hex=reply.hex()) + peer["received_hex"] = "".join(record["response_hex"] for record in peer["records"]) + save(directory,data) + result = exporter.capture_facts(directory,plan) + self.assertEqual(result["phases"][3]["cursor_reply"],[200,500]) + self.assertEqual(result["phases"][3]["document_projection"]["capacity"],14) + + def test_old_case_names_and_ten_row_endpoint_are_not_accepted(self): + for changed in ("height-only", "height-retention-measured", "width-height-retention", "width-then-height", "ten-row"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + if changed == "ten-row": + data["peer.json"]["frozen_grids"][3] = [61,10] + data[probe.RESULT_FILE]["observations"][3]["terminal"]["capacity"] = 10 + else: + plan["case_id"] = changed + save(directory,data) + with self.assertRaises(ValueError): + exporter.capture_facts(directory,plan) + + def test_fixed_case_reconstructs_complete_ASCII_documents_and_exact_sources(self): + for case in probe.CASES: + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve(), case) + result = exporter.capture_facts(directory, plan) + self.assertEqual(result["stateful_observation_count"], len(probe.phases(case))) + self.assertEqual([p["source_hex"] for p in result["phases"]], [v.hex() for _,v in probe.phases(case)]) + self.assertEqual([p["phase"] for p in result["resize_boundaries"]], list(probe.resize_phases(case))) + self.assertEqual(result["post_initial_terminal_input"], + "1049h atphase2 and1049l atphase5, each followed by DSR; otherwise DSR only, no replacement text or redraw") + self.assertEqual(result["independent_dsr_bounds"], {"row":[1,200],"column":[1,500]}) + self.assertIsNone(result["text_units"]["cpp_cell_column_mapping"]) + self.assertIsNone(result["text_units"]["document_position_unit_classification"]) + for index, phase in enumerate(result["phases"]): + actual = data[probe.RESULT_FILE]["observations"][index]["terminal"]["lines"] + public = phase["document_projection"]["synthetic_text"] + catalog = sorted(probe.synthetic_lines(case,index)) + self.assertEqual(public["catalog_sha256"], hashlib.sha256(json.dumps(catalog,separators=(',',':')).encode()).hexdigest()) + rebuilt = [bytes.fromhex(row["utf8_hex"]).decode('ascii') for row in public["lines"]] + self.assertEqual(rebuilt, actual) + for row,line in zip(public["lines"],rebuilt): + self.assertEqual(catalog[row["catalog_index"]],line) + self.assertEqual([row["utf8_bytes"],row["unicode_scalars"],row["utf16_code_units"]],[len(line)]*3) + self.assertEqual(phase["document_projection"]["synthetic_lines_sha256"],hashlib.sha256(json.dumps(rebuilt,separators=(',',':')).encode()).hexdigest()) + self.assertEqual(phase["cursor_reply"],[19,7]) + for marker in phase["document_projection"]["markers"]: + self.assertEqual(marker["line_text_offset_scalar"],marker["line_text_offset_utf16"]) + self.assertEqual(marker["line_text_prefix_utf8_bytes"],marker["line_text_offset_utf16"]) + self.assertEqual(marker["requested_position_api_value"],marker["line_start_api_value"]+marker["line_text_offset_utf16"]) + self.assertNotIn("column",marker) + self.assertNotIn(str(directory),json.dumps(result)) + + def test_later_missing_markers_and_split_text_are_observations(self): + for case, phase, lines in (("hidden-primary-height",1,[""]), ("hidden-primary-height",2,["LOWEND"]), + ("hidden-primary-height",3,[" "*89+"uv","w"]), ("hidden-primary-height",4,["HARD_A","HARD_B"])): + with self.subTest(case=case,phase=phase), tempfile.TemporaryDirectory() as temporary: + directory,data,plan = fixture(Path(temporary).resolve(),case) + terminal=data[probe.RESULT_FILE]["observations"][phase]["terminal"] + terminal.update(lines=lines,line_count=len(lines),markers=marker_rows(lines));save(directory,data) + result=exporter.capture_facts(directory,plan) + self.assertEqual(result["phases"][phase]["document_projection"]["line_count"],len(lines)) + self.assertEqual(result["phases"][phase]["cursor_reply"],[19,7]) + + def test_empty_alternate_stages_and_independent_return_DSR_are_observations(self): + with tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + for phase in (2,3,4): + terminal = data[probe.RESULT_FILE]["observations"][phase]["terminal"] + terminal.update(lines=[""], line_count=1, markers=[]) + for phase in (2,5): + for record in (data["peer.json"]["records"][phase], data[probe.RESULT_FILE]["observations"][phase]): + record.update(cursor_reply=[1,1], cursor_reply_private=False, response_hex=b"\x1b[1;1R".hex()) + data["peer.json"]["received_hex"] = "".join(row["response_hex"] for row in data["peer.json"]["records"]) + save(directory, data) + facts = exporter.capture_facts(directory, plan) + self.assertEqual([facts["phases"][i]["document_projection"]["markers"] for i in (2,3,4)], [[],[],[]]) + self.assertEqual([facts["phases"][i]["cursor_reply"] for i in (2,5)], [[1,1],[1,1]]) + self.assertEqual(facts["freeze_event_counts"][1], facts["freeze_event_counts"][2]) + self.assertEqual(facts["freeze_event_counts"][4], facts["freeze_event_counts"][5]) + + def test_transition_bytes_and_nonresize_grid_or_ack_cannot_be_substituted(self): + for changed in ("enter", "exit", "duplicate-enter", "grid", "ack-2", "ack-5"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + if changed in ("enter", "exit", "duplicate-enter"): + index = 5 if changed == "exit" else 2 + data["peer.json"]["records"][index]["source_hex"] = ( + b"\x1b[?1049h\x1b[?1049h" if changed == "duplicate-enter" else b"\x1b[H").hex() + elif changed == "grid": data["peer.json"]["frozen_grids"][2] = [60,20] + else: (directory / changed).write_text("not observed") + save(directory, data) + with self.assertRaises(ValueError): exporter.capture_facts(directory, plan) + + def test_missing_initial_fixture_private_text_nonASCII_and_duplicates_fail(self): + for phase,lines in ((0,[""]),(1,["SECRET"]),(1,["é"]),(1,["HTOP","HTOP"]), + (1,[" "*92]),(1,["LOWENDQ"]),(1,["uvwuvw"])): + with self.subTest(phase=phase,lines=lines), tempfile.TemporaryDirectory() as temporary: + directory,data,plan=fixture(Path(temporary).resolve()) + terminal=data[probe.RESULT_FILE]["observations"][phase]["terminal"] + terminal.update(lines=lines,line_count=len(lines),markers=marker_rows(lines));save(directory,data) + with self.assertRaises(ValueError):exporter.capture_facts(directory,plan) + + def test_sampled_geometry_cannot_be_missing_stale_or_disagree_with_frozen_stage(self): + for changed in ("missing", "grid", "capacity", "event", "bool-grid", "failed-result"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary: + directory, data, plan = fixture(Path(temporary).resolve()) + result = data[probe.RESULT_FILE] + measured = result["geometry_checks"][3] + if changed == "missing": measured.pop("observed_grid") + elif changed == "grid": measured["observed_grid"] = [91,14] + elif changed == "capacity": measured["terminal_capacity"] = 20 + elif changed == "event": measured["event_count"] = 1 + elif changed == "bool-grid": measured["observed_grid"][0] = True + else: + result["error"] = "Geometry prediction differed" + result["failure_geometry"] = {"window_rect": [0,0,999,568], "observed_grid": [61,13]} + save(directory, data) + with self.assertRaises((ValueError, KeyError)): exporter.capture_facts(directory, plan) + + def test_marker_API_arithmetic_and_bounds_are_not_guessed(self): + for kind in ("missing-start","bad-start","bad-sum","wide-overflow","negative-height","position-bound","label"): + with self.subTest(kind=kind), tempfile.TemporaryDirectory() as temporary: + directory,data,plan=fixture(Path(temporary).resolve()) + marker=data[probe.RESULT_FILE]["observations"][1]["terminal"]["markers"][0] + if kind=="missing-start":marker.pop("line_start_api") + elif kind=="bad-start":marker["line_start_api"]="SECRET" + elif kind=="bad-sum":marker["line_start_api"]+=1 + elif kind=="wide-overflow":marker["rect"]["width"]=1361 + elif kind=="negative-height":marker["rect"]["height"]=-1 + elif kind=="position-bound":marker["position"]=262145 + else:marker["label"]="SECRET" + save(directory,data) + with self.assertRaises((ValueError,KeyError)):exporter.capture_facts(directory,plan) + + def test_source_geometry_capacity_phase_DSR_and_NAWS_tampering_fail(self): + for changed in ("input","case","missing-stage","ack","ready","reply","unarmed","wrong-axis","outside-interval", + "window","capacity","initial-DSR","DSR-overflow"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary: + directory,data,plan=fixture(Path(temporary).resolve()) + result,peer=data[probe.RESULT_FILE],data["peer.json"] + if changed=="input":peer["records"][1]["source_hex"]="0d51" + elif changed=="case":result["case_id"]="width-then-height" + elif changed=="missing-stage":result["observations"].pop(1) + elif changed=="ack":(directory/"ack-1").write_text("not observed") + elif changed=="ready":(directory/"resize-ready-1").write_text('{}') + elif changed=="reply":peer["received_hex"]+="1b5b313b3152" + elif changed=="unarmed":peer["naws_history"].append([90,20]) + elif changed in ("wrong-axis","outside-interval"): + index=peer["resize_boundaries"][0]["before_event_count"] + peer["naws_history"][index]=[90,15] if changed=="wrong-axis" else [91,9] + elif changed=="window":result["observations"][1]["window_rect"][3]+=1 + elif changed=="capacity":result["observations"][1]["terminal"]["capacity"]=14 + else:peer["records"][0 if changed=="initial-DSR" else 1]["response_hex"]="1b5b31383b3752" if changed=="initial-DSR" else "1b5b3230313b3752" + save(directory,data) + with self.assertRaises(ValueError):exporter.capture_facts(directory,plan) + + def test_provider_source_identity_partial_results_and_cleanup_fail(self): + for changed in ("exe","manifest","template","script","evidence","truncated","cleanup","source-path","provider-observations","provider-ready"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary: + directory,data,plan=fixture(Path(temporary).resolve()) + evidence=data["evidence.json"]["data"]["evidence"] + if changed=="exe":data["run.json"]["sha256"]="0"*64 + elif changed=="manifest":data["run.json"]["files_sha256"]="0"*64 + elif changed=="template":data["run.json"]["template_sha256"]="0"*64 + elif changed=="script":(directory/"probe.js").write_bytes(b"changed") + elif changed=="evidence":evidence["evidence_id"]="SECRET" + elif changed=="truncated":evidence["normalized_result"]["messages_truncated"]=True + elif changed=="cleanup":data["cleanup.json"]["active"]=1 + elif changed=="source-path":evidence["normalized_result"]["source_path"]="C:/private/other.js" + elif changed=="provider-observations":evidence["normalized_result"]["messages"].pop(3) + else:evidence["normalized_result"]["messages"][0]["payload"]["case_id"]="wrong" + save(directory,data,synchronize=not changed.startswith("provider-")) + with self.assertRaises(ValueError):exporter.capture_facts(directory,plan) + + def test_unlisted_metadata_is_not_forwarded(self): + with tempfile.TemporaryDirectory() as temporary: + directory,data,plan=fixture(Path(temporary).resolve()) + before=exporter.capture_facts(directory,plan) + data[probe.RESULT_FILE]["observations"][1]["terminal"]["private_note"]="SECRET" + data["peer.json"]["resize_boundaries"][0]["private_path"]="C:/private" + data[probe.RESULT_FILE]["geometry_checks"][2]["private_note"]="SECRET" + save(directory,data) + self.assertEqual(exporter.capture_facts(directory,plan),before) + + def test_complete_ten_source_archive_and_package_identity(self): + for case in probe.CASES: + with self.subTest(case=case), tempfile.TemporaryDirectory() as temporary, complete_fixture(Path(temporary).resolve(), case) as values: + directory, _, _, plan_path, source_root, official = values + result = exporter.export(directory, plan_path, Path(temporary) / "public/result.json", + exporter.sha(plan_path), source_root, official) + self.assertEqual(result["schema"], "termforge-original-resize-hidden-height-reference-v1") + self.assertEqual(result["case_id"], case) + self.assertEqual(set(result["provenance"]["execution_source_files_sha256"]), set(probe.SOURCE_FILES)) + self.assertEqual(set(result["offline_export_source_files_sha256"]), set(exporter.EXPORT_SOURCES)) + self.assertIsNone(result["hidden_height_policy_classification"]) + self.assertFalse(result["semantic_acceptance"]) + self.assertEqual(result["status"],"observation") + self.assertEqual(result["geometry_basis"], { + "combined": {"reference": "research/ui/results/2026-11-02-resize-combined-reference/strict-export.json", + "strict_export_sha256": "6a1561f3e6f6bc480c6a2f086adf7ed4798d5007b04a7bc71c7ae9deba90cfe6", + "execution_source_base_commit": "2c7c5929e11d911ad69a7e2b7c420120591862ed"}, + "hidden_primary_height_previously_observed": False}) + + def test_geometry_basis_tampering_is_rejected_even_with_new_plan_hash(self): + for changed in ("combined", "source-commit", "previously-observed"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary, complete_fixture(Path(temporary).resolve()) as values: + directory, _, plan, plan_path, source_root, official = values + if changed == "combined": + plan["geometry_basis"][changed]["strict_export_sha256"] = "0"*64 + elif changed == "source-commit": + plan["geometry_basis"]["combined"]["execution_source_base_commit"] = "0"*40 + else: plan["geometry_basis"]["hidden_primary_height_previously_observed"] = True + plan_path.write_text(json.dumps(plan), encoding="utf-8") + destination = Path(temporary)/"public/result.json" + with self.assertRaisesRegex(ValueError, "contract differs"): + exporter.export(directory,plan_path,destination,exporter.sha(plan_path),source_root,official) + self.assertFalse(destination.exists()) + + def test_changed_package_source_profile_attempt_plan_and_destination_are_rejected(self): + for changed in ("module", "source", "manifest", "profile", "attempt", "plan", "destination"): + with self.subTest(changed=changed), tempfile.TemporaryDirectory() as temporary, complete_fixture(Path(temporary).resolve()) as values: + directory, _, _, plan_path, source_root, official = values + expected = exporter.sha(plan_path) + destination = Path(temporary) / "public/result.json" + if changed == "module": (directory / "app/Qt5Core.dll").write_bytes(b"changed") + elif changed == "source": (source_root / "research/ui/resize_hidden_height_reference.py").write_bytes(b"changed") + elif changed == "manifest": (directory / "files.json").write_text('{}') + elif changed == "profile": (directory / "profile/.wind/profiles/default.v10/terminal/user.sessions").write_text('[]') + elif changed == "attempt": plan_path.with_suffix('.attempted').write_text('not hidden height') + elif changed == "plan": plan_path.write_bytes(plan_path.read_bytes()+b' ') + elif changed == "destination": destination.parent.mkdir(); destination.write_text('preserved') + with self.assertRaises(ValueError): exporter.export(directory, plan_path, destination, expected, source_root, official) + if changed == "destination": self.assertEqual(destination.read_text(), 'preserved') + else: self.assertFalse(destination.parent.exists()) + + def test_duplicate_fields_and_nonfinite_metadata_fail_closed(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "record.json" + for content in ('{"phase":1,"phase":2}', '{"x":NaN}', '{"x":Infinity}', '{"x":-Infinity}'): + path.write_text(content) + with self.subTest(content=content), self.assertRaises(ValueError): exporter.read(path) + + def test_cli_refuses_private_text_normally_and_under_optimization(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary).resolve() + directory, data, plan = fixture(root) + data[probe.RESULT_FILE]["observations"][1]["terminal"]["lines"][1] = "SECRET" + save(directory, data) + plan_path = root / "plan.json" + plan_path.write_text(json.dumps(plan)) + for optimize in (False, True): + destination = root / ("optimized/out.json" if optimize else "normal/out.json") + command = [sys.executable, "-B", *(["-O"] if optimize else []), "-X", "utf8", "-m", + "research.ui.export_resize_hidden_height_reference", "--directory", str(directory), "--plan", str(plan_path), + "--expected-plan-sha256", exporter.sha(plan_path), "--destination", str(destination)] + completed = subprocess.run(command, cwd=probe.ROOT, capture_output=True, text=True, encoding="utf-8", timeout=10) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("Only fixed hidden-height-source synthetic document lines", completed.stderr) + self.assertFalse(destination.parent.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/research/ui/test_resize_hidden_height_reference.py b/research/ui/test_resize_hidden_height_reference.py new file mode 100644 index 00000000..1028ded9 --- /dev/null +++ b/research/ui/test_resize_hidden_height_reference.py @@ -0,0 +1,287 @@ +"""Fixed hidden-primary height sources, actual protected peer and plan guards; no sockets/target.""" +import hashlib +import json +from pathlib import Path +import shutil +import socket +import tempfile +import unittest +from unittest.mock import patch + +from research.ui import resize_hidden_height_reference as probe + + +def naws(columns, rows): + return bytes([255,250,31])+columns.to_bytes(2,"big")+rows.to_bytes(2,"big")+bytes([255,240]) + + +class Connection: + def __init__(self, peer, mode): + self.peer, self.mode, self.case = peer, mode, peer.case_id + self.initial, self.fragment, self.sent, self.resize_sent, self.calls = True, None, [], set(), 0 + def settimeout(self, _): pass + def close(self): pass + def sendall(self, data): self.sent.append(data) + def recv(self, _): + self.calls += 1 + if self.calls > 100: raise RuntimeError("Synthetic receive bound") + if self.initial: + self.initial = False + return naws(92 if self.mode == "stale-initial" else 91, 20) + state, directory = self.peer.state, self.peer.directory + armed = state.get("resize_armed") + if armed and armed not in self.resize_sent: + self.resize_sent.add(armed) + before = probe.required_grids(self.case)[armed-1] + grid = list(probe.required_grids(self.case)[armed]) + axis = 0 if before[0] != grid[0] else 1 + middle = list(grid); middle[axis] = (before[axis]+grid[axis])//2 + if self.mode == "wrong-axis": grid[1-axis] += 1 + if self.mode == "outside-interval": grid[axis] = max(before[axis],grid[axis])+1 + if self.mode == "wrong-endpoint": grid = middle + events = [middle, grid] if self.mode == "batched-resize" else [grid] + count = len(state["naws_history"])+(0 if self.mode == "stale-ready" else len(events)) + (directory/f"resize-ready-{armed}").write_text(json.dumps({"phase":armed,"grid":grid,"event_count":count})) + return b"".join(naws(*event) for event in events) + record = state["records"][-1] + if self.mode == "unarmed": return naws(91,19) + if self.mode == "unarmed-switch-"+str(record["phase"]): return naws(60,20) + if record["cursor_reply"] is not None: + if self.mode == "no-ack-"+str(record["phase"]): + raise RuntimeError("Synthetic withheld acknowledgment") + (directory/f'ack-{record["phase"]}').write_text("observed") + target = record["phase"]+1 + if target in probe.resize_phases(self.case): + window = list(probe.windows(self.case)[target]) + if self.mode == "wrong-request": window[1] += 1 + (directory/f"resize-request-{target}").write_text(json.dumps({"phase":target,"window":window})) + raise socket.timeout() + if self.mode == "duplicate-reply": return b"\x1b[19;7R\x1b[19;7R" + if self.mode == "foreign-input": return b"SECRET" + if self.fragment is not None: + result, self.fragment = self.fragment, None + return result + self.fragment = (b"8;501R" if self.mode == "dsr-overflow" else + b"0;7R" if self.mode == "dsr-zero" else + b"18;7R" if self.mode == "wrong-initial-dsr" else b"19;7R") + return b"\x1b[" + + +def receive(case="hidden-primary-height", mode="normal"): + with tempfile.TemporaryDirectory() as temporary: + peer_type = probe.peer_class(case); peer = peer_type.__new__(peer_type) + peer.directory = Path(temporary).resolve() + peer.case_id = case + (peer.directory/"fixture-ready").write_text(json.dumps({"grid":[91,20],"event_count":1})) + peer.state = {"connected":False,"columns":None,"rows":None,"phase":None,"records":[]} + peer.stop = type("NeverStopped",(),{"is_set":lambda _:False})() + connection = Connection(peer,mode) + peer.listener = type("Listener",(),{"accept":lambda _:(connection,("127.0.0.1",12345))})() + peer._serve() + return peer.state, connection.sent + + + +def initial_lines(): + lines = [""]*20 + lines[1]="HTOP";lines[5]="HARD_A";lines[6]="HARD_B" + lines[11]=" "*89+"uvw";lines[18]="LOWEND" + return lines + + +def recording(case="hidden-primary-height"): + state, _ = receive(case, "batched-resize") + result={"status":"observed-fixed-resize-hidden-height-states","case_id":case,"error":None,"observations":[], + "window_rect_format":"qt-qrect-inclusive-ltrb","geometry_checks":[],"full_active_grid_verified":False, + "local_shell_transport_verified":False,"presented_done":None} + for record,window in zip(state["records"],probe.windows(case)): + phase=record["phase"];lines=initial_lines() + rect=[113,-27,112+window[0],-28+window[1]] + terminal={"connected":True,"protocol":"Telnet","capacity":probe.required_grids(case)[phase][1], + "line_count":len(lines),"lines":lines,"markers":[],"top":None,"scroll":0, + "caret_api":{"caretLine":18,"caretColumn":6,"caretPosition":200}} + result["observations"].append({**{k:record[k] for k in ("phase","label","frozen_grid","cursor_reply","cursor_reply_private","response_hex")}, + "terminal":terminal,"window_rect":rect,"window_size":list(window)}) + result["geometry_checks"].append({"phase":phase,"window_rect":rect,"window_size":list(window),"expected_size":list(window), + "observed_grid":list(probe.required_grids(case)[phase]),"terminal_capacity":terminal["capacity"], + "event_count":state["freeze_event_counts"][phase]}) + return state,result + + +def save_mechanics(directory, state, result): + for name,value in (("peer.json",state),(probe.RESULT_FILE,result),("cleanup.json",{"active":0,"total":1,"terminated":1}), + ("profile-check.json",{"only_synthetic_session":True,"session_count":1})): + (directory/name).write_text(json.dumps(value),encoding="utf-8") + (directory/"stderr.txt").write_bytes(b"") + for phase in range(len(result["observations"])):(directory/f"ack-{phase}").write_text("observed") + + +class HiddenHeightReferenceTests(unittest.TestCase): + def test_fixed_hidden_height_geometry_and_exact_1049_actions(self): + self.assertEqual([len(probe.phases(c)) for c in probe.CASES],[7]) + expected=(b"\x1b[?6l\x1b[?7h\x1b[r\x1b[0m\x1b[2J\x1b[2;1HHTOP\x1b[6;1HHARD_A\r\nHARD_B" + b"\x1b[12;90Huvw\x1b[19;1HLOWEND") + for case in probe.CASES: + self.assertEqual(probe.phases(case)[0][1],expected) + self.assertEqual([value for _,value in probe.phases(case)[1:]], + [b"",b"\x1b[?1049h",b"",b"",b"\x1b[?1049l",b""]) + self.assertEqual(sum(len(value) for _,value in probe.phases(case)),91) + self.assertEqual(probe.resize_phases(case),(1,3,4,6)) + self.assertEqual(probe.fixed_contract(case)["semantic_acceptance"],False) + self.assertEqual(probe.windows("hidden-primary-height"),[(1360,749),(1000,749),(1000,749),(1000,569),(1000,749),(1000,749),(1360,749)]) + self.assertEqual(probe.required_grids("hidden-primary-height"),[(91,20),(61,20),(61,20),(61,14),(61,20),(61,20),(91,20)]) + self.assertEqual(probe.fixed_contract("hidden-primary-height")["geometry_basis"]["combined"]["strict_export_sha256"],"6a1561f3e6f6bc480c6a2f086adf7ed4798d5007b04a7bc71c7ae9deba90cfe6") + + def test_real_peer_fragmented_DSR_and_bounded_single_axis_NAWS(self): + for case in probe.CASES: + state,sent=receive(case,"batched-resize") + self.assertTrue(state["complete"],state.get("error"));self.assertIsNone(state.get("error")) + self.assertEqual(state["frozen_grids"],[list(g) for g in probe.required_grids(case)]) + self.assertEqual([r["cursor_reply"] for r in state["records"]],[[19,7]]*len(probe.phases(case))) + self.assertEqual(sent[-len(probe.phases(case)):],[value+b"\x1b[6n" for _,value in probe.phases(case)]) + + def test_wrong_axis_interval_endpoint_or_handshake_stops(self): + for case in probe.CASES: + for mode in ("wrong-axis","outside-interval","wrong-endpoint","stale-ready","wrong-request", + "stale-initial","unarmed","wrong-initial-dsr","duplicate-reply","foreign-input","dsr-overflow","dsr-zero"): + with self.subTest(case=case,mode=mode): + state,_=receive(case,mode);self.assertFalse(state.get("complete",False));self.assertTrue(state.get("error")) + + def test_every_stage_including_1049_requires_ack(self): + for case in probe.CASES: + for phase in range(len(probe.phases(case))-1): + state,_=receive(case,"no-ack-"+str(phase)) + self.assertTrue(state.get("error"));self.assertEqual(len(state["records"]),phase+1) + + def test_single_axis_intervals_never_authorize_both_axes(self): + self.assertTrue(probe.allowed_grid((91,20),(91,14),(91,15))) + self.assertTrue(probe.allowed_grid((91,14),(91,20),(91,20))) + for value in ((90,15),(91,9),(91,21),(0,15),(True,15)): + self.assertFalse(probe.allowed_grid((91,20),(91,10),value)) + self.assertFalse(probe.allowed_grid((91,20),(61,10),(76,15))) + + def test_initial_fixture_is_mandatory_later_loss_is_only_observation(self): + terminal={"line_count":20,"lines":initial_lines()} + for case in probe.CASES: + probe.validate_document(case,0,terminal) + for phase in range(1,len(probe.phases(case))):probe.validate_document(case,phase,{"line_count":1,"lines":[""]}) + with self.assertRaisesRegex(ValueError,"Initial fixture"):probe.validate_document(case,0,{"line_count":1,"lines":[""]}) + for lines in (["SECRET"],["HTOP","HTOP"],["\ufffd"],[" "*500],["uvw","uvw"]): + with self.assertRaises(ValueError):probe.validate_document(case,1,{"line_count":len(lines),"lines":lines}) + + def test_1049_stages_keep_independent_ack_and_same_grid_without_resize(self): + state,_=receive() + self.assertTrue(state["complete"]) + self.assertEqual(state["freeze_event_counts"],[1,2,2,3,4,4,5]) + self.assertEqual([r["phase"] for r in state["resize_boundaries"]],[1,3,4,6]) + for phase in (2,5): + with self.subTest(phase=phase): + stopped,_=receive(mode="unarmed-switch-"+str(phase)) + self.assertFalse(stopped.get("complete",False));self.assertTrue(stopped.get("error")) + stopped,_=receive(mode="no-ack-"+str(phase)) + self.assertEqual(len(stopped["records"]),phase+1);self.assertTrue(stopped.get("error")) + + def test_empty_alternate_document_is_an_observation_not_a_policy_or_failure(self): + case="hidden-primary-height" + with tempfile.TemporaryDirectory() as temporary: + directory=Path(temporary);state,result=recording(case) + for phase in (2,3,4): + result["observations"][phase]["terminal"].update(lines=[""],line_count=1,markers=[]) + save_mechanics(directory,state,result) + audit=probe.audit_mechanics(directory,case) + self.assertEqual(audit["stage_count"],7);self.assertFalse(audit["semantic_acceptance"]) + self.assertEqual(state["records"][2]["source_hex"],"1b5b3f3130343968") + self.assertEqual(state["records"][5]["source_hex"],"1b5b3f313034396c") + + def test_mechanics_rechecks_variable_capacity_fixed_geometry_and_all_boundaries(self): + for case in probe.CASES: + with tempfile.TemporaryDirectory() as temporary: + directory=Path(temporary);state,result=recording(case);save_mechanics(directory,state,result) + self.assertEqual(probe.audit_mechanics(directory,case)["stage_count"],len(probe.phases(case))) + bad=json.loads(json.dumps(result));bad["observations"][1]["terminal"]["capacity"]+=1 + save_mechanics(directory,state,bad) + with self.assertRaises(ValueError):probe.audit_mechanics(directory,case) + bad=json.loads(json.dumps(result));bad["geometry_checks"][1]["window_size"][1]+=1 + save_mechanics(directory,state,bad) + with self.assertRaises(ValueError):probe.audit_mechanics(directory,case) + bad=json.loads(json.dumps(state));before=bad["resize_boundaries"][0]["before_event_count"] + fixed_axis = 1 # The first transition changes width only. + bad["naws_history"][before][fixed_axis]+=1 + save_mechanics(directory,bad,result) + with self.assertRaises(ValueError):probe.audit_mechanics(directory,case) + save_mechanics(directory,state,result);(directory/"ack-0").unlink() + with self.assertRaises((ValueError,FileNotFoundError)):probe.audit_mechanics(directory,case) + + def test_archived_dependencies_are_unchanged_and_case_namespaces_isolated(self): + before=probe.baseline.phase_records() + a,b=probe.peer_class("hidden-primary-height"),probe.peer_class("hidden-primary-height") + self.assertEqual(len(a._serve.__globals__["phases"](20,91)),7) + self.assertEqual(len(b._serve.__globals__["phases"](20,91)),7) + self.assertEqual(probe.baseline.phase_records(),before) + for case in probe.CASES: + compile(probe.launcher_source(case),"","exec") + self.assertIn(json.dumps(probe.controller_contract(case)),probe.probe_fragment(case)) + + +class HiddenHeightPlanTests(unittest.TestCase): + def setUp(self): + temporary=tempfile.TemporaryDirectory(prefix="resize-hidden-height-tests-");self.addCleanup(temporary.cleanup) + self.root=Path(temporary.name).resolve() + for name in probe.SOURCE_FILES: + target=self.root/name;target.parent.mkdir(parents=True,exist_ok=True);shutil.copyfile(probe.ROOT/name,target) + archive={"schema":probe.common.ARCHIVE_SCHEMA,"source_base_commit":"b"*40, + "source_files_sha256":{name:probe.sha(self.root/name) for name in probe.SOURCE_FILES}} + (self.root/"reference-source.json").write_text(json.dumps(archive)) + self.source=self.root/"original";self.source.mkdir();(self.source/"WindTerm.exe").write_bytes(b"synthetic, not an executable") + for target,name,value in ((probe,"ROOT",self.root),(probe,"EXE_SHA256",probe.sha(self.source/"WindTerm.exe")), + (probe.common,"host_identity",lambda:{"synthetic_host":True}), + (probe.common,"rea_identity",lambda _: {"node_executable":"inert-node"})): + patcher=patch.object(target,name,value);patcher.start();self.addCleanup(patcher.stop) + self.out=self.root/"local/out";self.plan=self.root/"local/plan.json" + + def prepare(self,authorized=False): + return probe.prepare_plan(self.source,self.out,self.root/"rea.cmd",self.plan,"hidden-primary-height",authorized) + + def test_archive_plan_is_offline_and_exactly_ten_source_files(self): + with patch.object(probe.common.subprocess,"check_output",side_effect=AssertionError("No archive Git")): + plan=self.prepare() + self.assertEqual(len(plan["source_files_sha256"]),10) + self.assertEqual(plan["actual_original_runs"],0);self.assertFalse(self.out.exists()) + with self.assertRaisesRegex(ValueError,"not authorized"):probe.run_plan(self.plan) + self.assertFalse(self.plan.with_suffix(".attempted").exists()) + + def test_changed_case_geometry_or_source_is_rejected_before_attempt(self): + plan=self.prepare(True) + for key,value in (("case_id","width-then-height"),("phase_count",99),("required_grids",[]),("phases",[]), + ("source_identity",{"kind":"git-checkout"}),("launcher_source_sha256","0"*64)): + self.plan.write_text(json.dumps({**plan,key:value})) + with self.subTest(key=key),self.assertRaises(ValueError):probe.run_plan(self.plan) + self.assertFalse(self.plan.with_suffix(".attempted").exists()) + + def test_local_boundary_existing_plan_and_existing_attempt_remain_enforced(self): + with self.assertRaisesRegex(ValueError,"local directory"): + probe.prepare_plan(self.source,self.root/"elsewhere",self.root/"rea.cmd",self.plan,"hidden-primary-height") + with patch.object(probe,"launcher_source",return_value="def run(*args):\n pass\n"),patch.object(probe,"audit_mechanics",return_value={}): + self.prepare(True) + with self.assertRaisesRegex(ValueError,"must be new"):self.prepare(True) + # A fake launcher namespace keeps this test independent of target APIs. + self.out.mkdir() + self.out.rmdir() + with patch.object(probe.Path,"write_text",autospec=True) as write_result: + probe.run_plan(self.plan) + write_result.assert_called_once() + with self.assertRaises(FileExistsError):probe.run_plan(self.plan) + + def test_failed_attempt_is_retained_and_cannot_be_replayed(self): + source = "def run(*args):\n raise RuntimeError('synthetic launch failure')\n" + with patch.object(probe,"launcher_source",return_value=source),patch.object(probe,"audit_mechanics") as audit: + self.prepare(True) + with self.assertRaisesRegex(RuntimeError,"synthetic launch failure"): + probe.run_plan(self.plan) + self.assertEqual(self.plan.with_suffix(".attempted").read_text(),probe.ATTEMPT) + self.assertFalse(self.out.exists());audit.assert_not_called() + with self.assertRaises(FileExistsError):probe.run_plan(self.plan) + + +if __name__ == "__main__": + unittest.main() diff --git a/research/ui/test_resize_hidden_height_stages.js b/research/ui/test_resize_hidden_height_stages.js new file mode 100644 index 00000000..80d6cacb --- /dev/null +++ b/research/ui/test_resize_hidden_height_stages.js @@ -0,0 +1,177 @@ +// Execute the actual hidden-primary height controller in pure V8: no target, socket or native UI. +'use strict'; +const fs=require('fs'),vm=require('vm'),assert=require('assert/strict'),crypto=require('crypto'); +const original=fs.readFileSync(process.argv[2],'utf8'); +const specs={ + 'hidden-primary-height':{grids:[[91,20],[61,20],[61,20],[61,14],[61,20],[61,20],[91,20]],labels:['primary-initial-body','primary-width-shrunk','alternate-entered','alternate-height-shrunk','alternate-height-restored','primary-restored','primary-width-restored'],resize:[1,3,4,6]} +}; +const markerLabels=['HTOP','HARD_A','HARD_B','u','v','w','LOWEND']; +function initialLines(){const a=Array(20).fill('');a[1]='HTOP';a[5]='HARD_A';a[6]='HARD_B';a[11]=' '.repeat(89)+'uvw';a[18]='LOWEND';return a;} +function fixture(caseId='hidden-primary-height'){ + const spec=specs[caseId],contract={case_id:caseId,labels:spec.labels,windows:spec.grids.map(([w,h])=>[w===91?1360:1000,h===20?749:569]), + grids:spec.grids,resize_phases:spec.resize,marker_labels:markerLabels}; + const state={now:0,window:[1360,749],origin:[113,-27],peer:{connected:true,columns:91,rows:20,phase:null,naws_history:[[91,20]]}, + terminal:{connected:true,protocol:'Telnet',capacity:20,line_count:20,lines:initialLines(),markers:[]}}; + const files=new Map(),messages=[],resizes=[],root='/owned/resize-hidden-height'; + const module=name=>({name,path:root+'/app/'+name,base:{add:()=>({readU8:()=>0})}}); + const context={__RUN_DIRECTORY_JSON__:root,uiThread:1,wind:module('WindTerm.exe'),core:module('Qt5Core.dll'),gui:module('Qt5Gui.dll'),widgets:module('Qt5Widgets.dll'), + user:{getExportByName:()=>1},Process:{getCurrentThreadId:()=>1},Date:{now:()=>state.now},send:value=>messages.push(value),setTimeout:()=>1, + File:class{constructor(name){this.name=name;this.data='';}write(value){this.data+=value;}close(){files.set(this.name,{at:state.now,data:this.data});}}, + peerState:()=>state.peer,evalText:source=>{if(state.failTextRead)throw Error('synthetic terminal binding unavailable');return state.textAPI?vm.runInNewContext(source,{window:{activeText:state.textAPI}},{timeout:100}):JSON.stringify(state.terminal);},ready:()=>true, + resize:(_,w,h)=>{state.window=[w,h];resizes.push({at:state.now,size:[w,h]});}, + rect:()=>[...state.origin,state.origin[0]+state.window[0]-1,state.origin[1]+state.window[1]-1], + scan:()=>{},expandAll:()=>{},focus:()=>{},key:()=>{},main:1,tree:2}; + context.Interceptor={attach:(_,callbacks)=>{context.dispatch=callbacks.onEnter;}}; + vm.createContext(context);vm.runInContext(original.replace('__RESIZE_HIDDEN_HEIGHT_CASE_JSON__',JSON.stringify(contract)),context,{timeout:1000}); + const tick=now=>{state.now=now;vm.runInContext('dispatch()',context,{timeout:100});}; + const file=name=>files.get(root+'/'+name); + const phase=index=>{ + state.peer.case_id=caseId;state.peer.phase=index;state.peer.phase_label=spec.labels[index];state.peer.frozen_grid=contract.grids[index]; + state.peer.resize_armed=null;state.peer.records??=[]; + state.peer.records[index]={cursor_reply:[19,7],cursor_reply_private:false,response_hex:'1b5b31393b3752',frozen_grid:contract.grids[index]}; + }; + tick(0);tick(100);tick(200);tick(1000);tick(1600);assert(file('fixture-ready')); + const observe=(index,time)=>{ + if(spec.resize.includes(index)){ + assert(file('resize-request-'+index));state.peer.resize_armed=index;tick(time); + state.peer.columns=spec.grids[index][0];state.peer.rows=spec.grids[index][1]; + state.terminal.capacity=state.peer.rows;state.peer.naws_history.push(contract.grids[index]); + tick(time+100);tick(time+700);assert(file('resize-ready-'+index)); + phase(index);tick(time+800);tick(time+1200);assert(file('ack-'+index));return time+1300; + } + phase(index);tick(time);tick(time+400);assert(file('ack-'+index));return time+500; + }; + const through=index=>{let time=1700;for(let i=0;i<=index;i++)time=observe(i,time);return time;}; + return {state,tick,file,phase,observe,through,resizes,messages,spec,contract}; +} +const cases=[];function test(name,action){cases.push({name,action});} +test('fixed hidden-primary schedule observes all7 states including1049 actions',()=>{ + let total=0; + for(const caseId of Object.keys(specs)){ + const h=fixture(caseId),last=h.spec.grids.length-1,time=h.through(last);h.state.peer.complete=true;h.tick(time); + const r=JSON.parse(h.file('resize-hidden-height-result.json').data); + assert.equal(r.status,'observed-fixed-resize-hidden-height-states');assert.equal(r.observations.length,last+1); + assert.deepEqual(r.observations.map(o=>o.frozen_grid),h.spec.grids); + assert.deepEqual(h.resizes.map(o=>o.size),[h.contract.windows[0],...h.spec.resize.map(i=>h.contract.windows[i])]);total+=r.observations.length; + } + assert.equal(total,7); +}); +test('every action and resize waits for the prior observation acknowledgment',()=>{ + const h=fixture();let time=h.through(0); + for(let i=1;i{ + for(const index of [2,5]){ + const h=fixture(),time=h.through(index-1),before=h.resizes.length;h.phase(index);h.tick(time); + h.tick(time+399);assert.equal(h.file('ack-'+index),undefined);assert.equal(h.file('resize-request-'+(index+1)),undefined); + assert.equal(h.resizes.length,before);h.tick(time+400);assert(h.file('ack-'+index));assert(h.file('resize-request-'+(index+1))); + assert.equal(h.resizes.length,before); + } +}); +test('empty alternate observations are preserved through both height endpoints',()=>{ + const h=fixture();let time=h.through(1);h.state.terminal.lines=[''];h.state.terminal.line_count=1;h.state.terminal.markers=[]; + for(const index of [2,3,4])time=h.observe(index,time); + const stages=h.messages.filter(x=>x.kind==='resize_hidden_height_phase').slice(2); + assert.equal(stages.length,3); + for(const stage of stages){ + assert.deepEqual(JSON.parse(JSON.stringify(stage.terminal.lines)),['']); + assert.deepEqual(JSON.parse(JSON.stringify(stage.terminal.markers)),[]); + } + assert.equal(h.file('ack-5'),undefined); +}); +test('initial missing sentinel or wrong DSR prevents first resize',()=>{ + for(const bad of ['missing','duplicate','DSR']){ + const h=fixture();h.phase(0); + if(bad==='missing')h.state.terminal.lines[1]=''; + else if(bad==='duplicate')h.state.terminal.lines[0]='HTOP'; + else h.state.peer.records[0].cursor_reply=[18,7]; + h.tick(1700);h.tick(2100);assert.equal(h.file('ack-0'),undefined); + assert.equal(h.file('resize-request-1'),undefined);assert.equal(JSON.parse(h.file('resize-hidden-height-result.json').data).status,'failed'); + } +}); +test('later loss and DSR outside 14-row viewport remain observations',()=>{ + const h=fixture();let time=h.through(1);h.state.terminal.lines=[''];h.state.terminal.line_count=1;time=h.observe(2,time);h.observe(3,time); + const r=h.messages.filter(x=>x.kind==='resize_hidden_height_phase')[3];assert.deepEqual(r.cursor_reply,[19,7]);assert.deepEqual(r.frozen_grid,[61,14]); +}); +test('both width and height NAWS changes reset all 600ms of stability',()=>{ + for(const index of [1,3,4,6]){ + const h=fixture(),time=h.through(index-1);h.state.peer.resize_armed=index;h.tick(time); + const target=h.spec.grids[index],prior=h.spec.grids[index-1]; + h.state.peer.columns=target[0];h.state.peer.rows=target[1];h.state.terminal.capacity=target[1];h.state.peer.naws_history.push(target);h.tick(time+100); + const middle=target.map((n,axis)=>Math.floor((n+prior[axis])/2));h.state.peer.naws_history.push(middle,target);h.tick(time+650);h.tick(time+700); + assert.equal(h.file('resize-ready-'+index),undefined);h.tick(time+1250);assert(h.file('resize-ready-'+index)); + } +}); +test('post-input document changes restart the full 400ms stability interval',()=>{ + const h=fixture();h.phase(0);h.tick(1700);h.tick(2000);assert.equal(h.file('ack-0'),undefined); + h.state.terminal.top=1;h.tick(2050);h.tick(2100);assert.equal(h.file('ack-0'),undefined); + h.tick(2449);assert.equal(h.file('ack-0'),undefined);h.tick(2450);assert(h.file('ack-0')); +}); +test('initial sentinels are checked after bounded observation stability',()=>{ + const h=fixture();h.phase(0);h.state.terminal.lines[1]='';h.tick(1700);assert.equal(h.file('resize-hidden-height-result.json'),undefined); + h.state.terminal.lines[1]='HTOP';h.tick(1900);h.tick(2299);assert.equal(h.file('ack-0'),undefined); + h.tick(2300);assert(h.file('ack-0')); +}); +test('fixed deadline rejects missing input without extending the schedule',()=>{ + const h=fixture();h.tick(41001);assert.equal(h.file('ack-0'),undefined); + const result=JSON.parse(h.file('resize-hidden-height-result.json').data);assert.equal(result.status,'failed');assert.match(result.error,/bounded scenario deadline/); +}); +test('wrong actual endpoint is saved before prediction failure without calibration',()=>{ + for(const bad of ['grid','capacity','geometry']){ + const h=fixture(),time=h.through(2);h.state.peer.resize_armed=3;h.tick(time); + h.state.peer.rows=bad==='grid'?13:14;h.state.terminal.capacity=bad==='capacity'?20:h.state.peer.rows; + h.state.peer.naws_history.push([61,h.state.peer.rows]);if(bad==='geometry')h.state.window[1]++; + h.tick(time+100);h.tick(time+700);assert.equal(h.file('resize-ready-3'),undefined); + if(bad==='capacity')h.tick(41001); + const result=JSON.parse(h.file('resize-hidden-height-result.json').data);assert.equal(result.status,'failed'); + assert.deepEqual(result.failure_geometry.requested_window,[1000,569]); + assert.deepEqual(result.failure_geometry.window_size,[1000,bad==='geometry'?570:569]); + assert.deepEqual(result.failure_geometry.observed_grid,[61,bad==='grid'?13:14]); + assert.equal(result.failure_geometry.terminal_capacity,bad==='capacity'?20:h.state.peer.rows); + if(bad!=='capacity'){ + assert.equal(result.geometry_checks.length,4); + assert.deepEqual(result.geometry_checks[3].observed_grid,result.failure_geometry.observed_grid); + assert.equal(result.geometry_checks[3].terminal_capacity,result.failure_geometry.terminal_capacity); + } + assert.equal(h.resizes.length,3); + } +}); +test('peer or phase errors retain bounded actual geometry and do not advance',()=>{ + for(const bad of ['peer','case','label']){ + const h=fixture();h.phase(0);if(bad==='case')h.state.peer.case_id='wrong';else if(bad==='label')h.state.peer.phase_label='wrong';else h.state.peer.error='synthetic';h.tick(1700); + assert.equal(h.file('ack-0'),undefined);const result=JSON.parse(h.file('resize-hidden-height-result.json').data);assert.equal(result.status,'failed'); + assert.deepEqual(result.failure_geometry.window_size,[1360,749]);assert.deepEqual(result.failure_geometry.observed_grid,[91,20]); + } +}); +test('failed terminal binding cannot erase independent window and NAWS evidence',()=>{ + const h=fixture(),time=h.through(2);h.state.peer.resize_armed=3;h.tick(time); + h.state.peer.rows=14;h.state.peer.naws_history.push([61,14]);h.state.failTextRead=true;h.tick(time+100); + const result=JSON.parse(h.file('resize-hidden-height-result.json').data);assert.equal(result.status,'failed'); + assert.deepEqual(result.failure_geometry.window_size,[1000,569]); + assert.deepEqual(result.failure_geometry.observed_grid,[61,14]);assert.equal(result.failure_geometry.terminal_capacity,null); + assert.equal(h.file('resize-ready-3'),undefined);assert.equal(h.resizes.length,3); +}); +test('missing final observation cannot become completion',()=>{ + const h=fixture(),time=h.through(5);h.state.peer.complete=true;h.tick(time); + assert.equal(JSON.parse(h.file('resize-hidden-height-result.json').data).status,'failed');assert.equal(h.file('ack-6'),undefined); +}); +test('actual text sampler records ASCII API offsets separately from pixels',()=>{ + const h=fixture(),lines=initialLines(); + h.state.textAPI={isConnected:true,protocolName:'Telnet',visibleLineCount:20,screenLineCapacity:20,topLine:null,scrollTop:0, + caretLine:18,caretColumn:6,caretPosition:1806,lineText:i=>lines[i],lineStart:i=>i*100, + visibleRectFromPosition:p=>({x:(p%100)*8,y:Math.floor(p/100)*18,width:8,height:18})}; + h.through(0);const markers=h.messages.find(x=>x.kind==='resize_hidden_height_phase').terminal.markers; + const u=markers.find(x=>x.label==='u');assert.equal(u.column,89);assert.equal(u.line_start_api,1100);assert.equal(u.position,1189); + assert.deepEqual(JSON.parse(JSON.stringify(u.rect)),{x:712,y:198,width:8,height:18}); +}); +const results=[];for(const entry of cases){try{entry.action();results.push({case:entry.name,status:'passed'});} + catch(error){results.push({case:entry.name,status:'failed',error:String(error.message)});}} +const failed=results.filter(v=>v.status==='failed').length; +process.stdout.write(JSON.stringify({schema:'termforge-resize-hidden-height-controller-test-v1',node_version:process.version, + actual_source_sha256:crypto.createHash('sha256').update(original).digest('hex'),case_count:results.length,failed, + child_processes_spawned:0,sockets_opened:0,target_runs:0,results})+'\n');process.exitCode=failed?1:0;