From 5804d25672f799623b78f1e70b3fd4e754b74b95 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:45:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(core):=20=E5=A2=9E=E5=8A=A0=E5=B8=A6?= =?UTF-8?q?=20resize=20=E6=97=B6=E9=97=B4=E7=BA=BF=E7=9A=84=E5=9B=9E?= =?UTF-8?q?=E6=94=BE=E5=B7=A5=E5=85=B7=20(#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现有 replay 只能按固定几何回放整段捕获,因此跨 DPI/缩放/窗口恢复的捕获无法忠实回放—— #194 要核对的正是「resize 期间的重绘与视口/缓冲是否对齐」,缺的正是这个手段。 resize_replay 读取同一份字节流,但按外部步骤文件依次应用几何: [label] 每一步先 Screen::resize(),再消费相应字节,并输出该步的 history/retained/top/ maximum_top/following/cursor。最后一行输出整份 screen JSON。 验证: - 合成用例:3 行视口喂 12 行后 resize 到 5 行再喂 8 行,history 10→16、 retained 分别等于 history + 当前页高 - 与现有 replay 对拍:本项 #194 的 geometry-main 块在 7x111 下, 两者都得到 retained=37 / history=30 - 新增 test_resize_replay.py 三条用例并注册 CTest resize_replay_contract --- CMakeLists.txt | 5 ++ experiments/core/resize_replay.cpp | 114 +++++++++++++++++++++++++ experiments/core/test_resize_replay.py | 58 +++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 experiments/core/resize_replay.cpp create mode 100644 experiments/core/test_resize_replay.py diff --git a/CMakeLists.txt b/CMakeLists.txt index cd7f0a5c..7aef9038 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,10 @@ add_executable(replay experiments/core/replay.cpp) target_link_libraries(replay PRIVATE termforge_core) termforge_warnings(replay) +add_executable(resize_replay experiments/core/resize_replay.cpp) +target_link_libraries(resize_replay PRIVATE termforge_core) +termforge_warnings(resize_replay) + add_executable(terminal_trace experiments/core/trace.cpp) target_link_libraries(terminal_trace PRIVATE termforge_core) termforge_warnings(terminal_trace) @@ -350,6 +354,7 @@ add_test(NAME edit_csi_core COMMAND edit_csi_test) set_tests_properties(candidate_shell_tui PROPERTIES TIMEOUT 120) endif() termforge_add_python_test(core -m unittest experiments.core.test_core experiments.core.test_unicode -v) + termforge_add_python_test(resize_replay_contract -m unittest experiments.core.test_resize_replay -v) termforge_add_python_test(recovery -m unittest experiments.core.test_recovery -v) termforge_add_python_test(measure -m unittest experiments.measure.test_measure experiments.measure.test_r54_contract.CostPlanTests -v) termforge_add_python_test(binary_paths -m unittest experiments.test_build_paths -v) diff --git a/experiments/core/resize_replay.cpp b/experiments/core/resize_replay.cpp new file mode 100644 index 00000000..86b4a6e7 --- /dev/null +++ b/experiments/core/resize_replay.cpp @@ -0,0 +1,114 @@ +// Replay one captured byte stream across an explicit resize timeline. +// +// The plain `replay` tool feeds a whole capture at one fixed geometry, so a +// capture that changed size mid-session (DPI switch, zoom, restored window) +// cannot be replayed faithfully. This tool reads the same bytes but applies a +// caller-supplied sequence of geometries, reporting the viewport and buffer +// state after each step so a repaint hypothesis can be checked against real +// bytes (#194). +#include "terminal.hpp" +#include "history_view.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace termforge; + +// One step of the timeline: resize to rows x columns, then consume `bytes` +// bytes of the capture. +struct Step { + int rows = 0; + int columns = 0; + size_t bytes = 0; + std::string label; +}; + +void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +std::vector read_steps(const char* path) { + std::ifstream input(path); + require(static_cast(input), "cannot open steps file"); + std::vector steps; + std::string line; + while (std::getline(input, line)) { + if (line.empty() || line[0] == '#') continue; + std::istringstream fields(line); + Step step; + require(static_cast(fields >> step.rows >> step.columns >> step.bytes), "malformed step line"); + require(step.rows > 0 && step.columns > 0, "step geometry must be positive"); + std::getline(fields, step.label); + if (!step.label.empty() && step.label.front() == ' ') step.label.erase(0, 1); + steps.push_back(step); + } + require(!steps.empty(), "steps file has no steps"); + return steps; +} + +void quoted(std::ostream& out, const std::string& value) { + out << '"'; + for (const char ch : value) { + if (ch == '"' || ch == '\\') out << '\\' << ch; + else if (ch == '\n') out << "\\n"; + else out << ch; + } + out << '"'; +} + +} // namespace + +int main(int argc, char** argv) { + try { + if (argc != 4) throw std::runtime_error("usage: resize_replay input steps history"); + std::ifstream input(argv[1], std::ios::binary | std::ios::ate); + require(static_cast(input), "cannot open input file"); + require(input.tellg() <= 64 * 1024 * 1024, "input file too large"); + input.seekg(0); + const std::string data((std::istreambuf_iterator(input)), {}); + const std::vector steps = read_steps(argv[2]); + const size_t history = std::stoull(argv[3]); + + const Step& first = steps.front(); + Parser parser(first.rows, first.columns, history); + HistoryViewport viewport; + const auto start = std::chrono::steady_clock::now(); + size_t consumed = 0; + for (size_t index = 0; index < steps.size(); ++index) { + const Step& step = steps[index]; + if (index > 0) parser.screen.resize(step.rows, step.columns); + const size_t take = (std::min)(step.bytes, data.size() - consumed); + parser.feed(std::string_view(data).substr(consumed, take)); + parser.finish(); + consumed += take; + const ViewportState state = viewport.reconcile(parser.screen); + std::cout << "{\"step\":" << index + 1 << ",\"label\":"; + quoted(std::cout, step.label); + std::cout << ",\"rows\":" << step.rows << ",\"columns\":" << step.columns + << ",\"consumed\":" << take << ",\"total_consumed\":" << consumed + << ",\"history\":" << parser.screen.history_lines().size() + << ",\"retained\":" << parser.screen.retained_size() + << ",\"top\":" << state.top << ",\"maximum_top\":" << state.maximum_top + << ",\"following\":" << (state.following ? "true" : "false") + << ",\"cursor_row\":" << parser.screen.row + << ",\"cursor_column\":" << parser.screen.column << "}\n"; + } + const double ms = + std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + std::cout << "{\"bytes\":" << data.size() << ",\"consumed\":" << consumed << ",\"steps\":" + << steps.size() << ",\"parse_ms\":" << ms << ",\"screen\":"; + parser.screen.json(std::cout); + std::cout << "}\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 2; + } +} diff --git a/experiments/core/test_resize_replay.py b/experiments/core/test_resize_replay.py new file mode 100644 index 00000000..d35700d0 --- /dev/null +++ b/experiments/core/test_resize_replay.py @@ -0,0 +1,58 @@ +"""Resize-aware replay: one capture read across an explicit geometry timeline.""" +import json +from pathlib import Path +import subprocess +import tempfile +import unittest + +from experiments.build_paths import binary + + +class ResizeReplayTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + folder = Path(self.temp.name) + self.input = folder / "input.bin" + self.steps = folder / "steps.txt" + + def run_steps(self, data, steps, history=100): + self.input.write_bytes(data) + self.steps.write_text("".join(f"{rows} {columns} {count} {label}\n" + for rows, columns, count, label in steps), encoding="utf-8") + completed = subprocess.run( + [str(binary("resize_replay")), str(self.input), str(self.steps), str(history)], + capture_output=True, text=True, encoding="utf-8", timeout=10) + self.assertEqual(completed.returncode, 0, completed.stderr) + return [json.loads(line) for line in completed.stdout.splitlines()] + + def test_resize_between_chunks_moves_the_viewport_and_grows_history(self): + data = "".join(f"L{index:02d}\r\n" for index in range(1, 21)).encode() + split = len("".join(f"L{index:02d}\r\n" for index in range(1, 13)).encode()) + rows = self.run_steps(data, [(3, 40, split, "small"), (5, 40, len(data) - split, "grown")]) + first, second = rows[0], rows[1] + self.assertEqual((first["rows"], second["rows"]), (3, 5)) + self.assertTrue(first["following"] and second["following"]) + self.assertEqual(second["total_consumed"], len(data)) + self.assertGreater(second["history"], first["history"]) + # The viewport follows the bottom, so retained rows are history plus one page. + self.assertEqual(second["retained"], second["history"] + 5) + self.assertEqual(first["retained"], first["history"] + 3) + + def test_a_step_longer_than_the_capture_consumes_only_what_is_left(self): + rows = self.run_steps(b"only\r\n", [(2, 20, 100, "overreach")]) + self.assertEqual(rows[0]["consumed"], len(b"only\r\n")) + self.assertEqual(rows[0]["total_consumed"], len(b"only\r\n")) + + def test_malformed_steps_are_rejected_before_any_parsing(self): + self.input.write_bytes(b"x") + self.steps.write_text("not a step\n", encoding="utf-8") + completed = subprocess.run( + [str(binary("resize_replay")), str(self.input), str(self.steps), "10"], + capture_output=True, text=True, encoding="utf-8", timeout=10) + self.assertNotEqual(completed.returncode, 0) + self.assertIn("malformed step", completed.stderr) + + +if __name__ == "__main__": + unittest.main() From f3169b1728f77eb38e0726ac21fac555cca9042e Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:54:18 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(core):=20=E9=94=81=E5=AE=9A=E9=87=8D?= =?UTF-8?q?=E7=BB=98=E7=9A=84=E5=87=A0=E4=BD=95=E7=9B=B8=E5=85=B3=E6=80=A7?= =?UTF-8?q?=20(#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #194 的机制实验显示:同一段重绘字节在 7 行页高下推入 30 行历史,在 24 行页高下推入 0 行。 本用例把这条性质固化为回归——同一份 20 行输入分别喂给 4/10/24 行视口,断言 history 为 17/11/0,且 retained 始终等于 history + 当前页高。 这样该性质不会因后续解析器改动被悄悄改变,也把 #194 的实测结论留在可执行的形式里。 --- experiments/core/test_resize_replay.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/experiments/core/test_resize_replay.py b/experiments/core/test_resize_replay.py index d35700d0..cb0f322a 100644 --- a/experiments/core/test_resize_replay.py +++ b/experiments/core/test_resize_replay.py @@ -39,6 +39,16 @@ def test_resize_between_chunks_moves_the_viewport_and_grows_history(self): self.assertEqual(second["retained"], second["history"] + 5) self.assertEqual(first["retained"], first["history"] + 3) + def test_identical_bytes_scale_their_scrollback_with_page_height(self): + # #194 measured this on a real capture: the same repaint bytes pushed 30 + # rows into scrollback behind a 7-row page and none behind a 24-row one. + data = "".join(f"R{index:02d}\r\n" for index in range(1, 21)).encode() + short, medium, tall = (self.run_steps(data, [(rows, 20, len(data), f"rows-{rows}")])[0] + for rows in (4, 10, 24)) + self.assertEqual((short["history"], medium["history"], tall["history"]), (17, 11, 0)) + for row in (short, medium, tall): + self.assertEqual(row["retained"], row["history"] + row["rows"]) + def test_a_step_longer_than_the_capture_consumes_only_what_is_left(self): rows = self.run_steps(b"only\r\n", [(2, 20, 100, "overreach")]) self.assertEqual(rows[0]["consumed"], len(b"only\r\n"))