From 5db6e262a02c7f8042c93412d5fe2e9c0502eb12 Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:40:25 +0800 Subject: [PATCH 1/2] test(core): add wide/combining x 1049 literal-oracle CTest for #27 --- CMakeLists.txt | 6 + experiments/core/widechar_1049_test.cpp | 411 ++++++++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 experiments/core/widechar_1049_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f504d187..303bcfac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,6 +132,10 @@ add_executable(pending_wrap_test experiments/core/pending_wrap_test.cpp) target_link_libraries(pending_wrap_test PRIVATE termforge_core) termforge_warnings(pending_wrap_test) +add_executable(widechar_1049_test experiments/core/widechar_1049_test.cpp) +target_link_libraries(widechar_1049_test PRIVATE termforge_core) +termforge_warnings(widechar_1049_test) + add_executable(view_mapping_test experiments/core/view_mapping_test.cpp) target_link_libraries(view_mapping_test PRIVATE termforge_core) termforge_warnings(view_mapping_test) @@ -357,6 +361,8 @@ add_test(NAME view_mapping_core COMMAND view_mapping_test) add_test(NAME private_mode_core COMMAND private_mode_test) add_test(NAME decom_origin_core COMMAND decom_origin_test) add_test(NAME pending_wrap_core COMMAND pending_wrap_test) +add_test(NAME widechar_1049_core COMMAND widechar_1049_test) + set_tests_properties(widechar_1049_core PROPERTIES TIMEOUT 10) add_test(NAME reply_core COMMAND reply_test) add_test(NAME edit_csi_core COMMAND edit_csi_test) set_tests_properties(decawm_mode_core PROPERTIES TIMEOUT 10) diff --git a/experiments/core/widechar_1049_test.cpp b/experiments/core/widechar_1049_test.cpp new file mode 100644 index 00000000..c1880b4d --- /dev/null +++ b/experiments/core/widechar_1049_test.cpp @@ -0,0 +1,411 @@ +// Bounded-slice literal-oracle CTest for issue #27 wide/combining x 1049 x +// DECAWM-off wrap: wide (CJK double-width) scalars and combining-mark sequences +// crossing the 1049 buffer switch, with the insufficient-space pre-wrap policy, +// lead/trail non-splitting, hide/restore, resize edges and chunk/recovery +// boundaries. Contract baseline is the existing core (screen.hpp put/resize/ +// activate_buffer, terminal.hpp DECAWM/1049 dispatch) plus ADR-0005 physical-row +// crop. No new WindTerm run produced these expectations: the original never +// observed wide or combining text across 1049, so every crossing below is a +// composed model check that locks the current implementation rather than an +// independent literal oracle. The unobserved combinations stay recorded as +// unknown (README/validation.json), not asserted as original parity. +#include "screen.hpp" +#include "terminal.hpp" +#include +#include +#include +#include +#include +#include +#include + +using namespace termforge; + +static const std::string CJK = "\xe4\xb8\xad"; // U+4E2D, width 2 +static const std::string COMB_ACUTE = "\xcc\x81"; // U+0301, width 0 +static const std::string COMB_DIAERESIS = "\xcc\x88"; // U+0308, width 0 +static const std::string UFFFD = "\xef\xbf\xbd"; // replacement scalar + +static void check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +static std::string line_text(const Line& line) { + std::string value; + for (const auto& cell : line) value += cell.text; + return value; +} + +// Structural invariant: every width-2 lead has a width-0 trail immediately +// after it, and no width-0 cell is orphaned. Rows always match the screen width. +static void invariant(const Screen& s) { + check(s.row >= 0 && s.row < s.height() && s.column >= 0 && s.column < s.width(), "cursor bounds"); + const auto rows = [&](const auto& collection) { + for (const auto& line : collection) { + check(int(line.size()) == s.width(), "row width"); + for (int c = 0; c < s.width(); ++c) { + if (line[c].width == 0) check(c > 0 && line[c - 1].width == 2, "orphan trail"); + if (line[c].width == 2) check(c + 1 < s.width() && line[c + 1].width == 0, "orphan lead"); + } + } + }; + rows(s.history_lines()); + rows(s.visible_lines()); +} + +// Canonical state signature for cross-chunk equality only, never a contract +// oracle. Captures cursor, pending edge, discarded count, per-cell width/marks +// and per-row text plus wrap metadata so a chunking difference cannot hide. +static std::string signature(const Screen& s) { + std::ostringstream out; + out << s.row << ',' << s.column << ',' << (s.wrap_pending ? 1 : 0) << ',' << s.discarded << ';'; + const auto emit = [&](const Line& line) { + out << line.id << ':' << static_cast(line.ending) << ':' << line.next_id << ':' + << line.wrap_columns << ':' << (line.cropped ? 1 : 0) << '|'; + for (const auto& cell : line) out << cell.width << '/' << cell.marks << ','; + out << '|' << line_text(line) << ';'; + }; + for (const auto& line : s.history_lines()) emit(line); + out << '#'; + for (const auto& line : s.visible_lines()) emit(line); + return out.str(); +} + +// Wide scalar at the last column has insufficient room, so it pre-wraps to the +// next row as a whole glyph. The permission bit only gates consuming a delayed +// narrow edge, so DECAWM on and off produce the identical pre-wrap result. +static void wide_insufficient_space_prewrap(ParseMode mode) { + for (const auto& arm : {std::string("\x1b[?7h"), std::string("\x1b[?7l")}) { + Parser p(3, 4, 2, mode); + p.feed(arm + "\x1b[1;4H" + CJK); + check(p.screen.row == 1 && p.screen.column == 2 && !p.screen.wrap_pending, + "wide last-column prewrap cursor or pending edge"); + const auto& r0 = p.screen.visible_lines()[0]; + check(line_text(r0) == " " && r0.ending == LineEnding::soft_wrap && r0.wrap_columns == 3u, + "wide last-column prewrap source-row metadata"); + const auto& r1 = p.screen.visible_lines()[1]; + check(r1[0].width == 2 && r1[1].width == 0 && r1[0].text == CJK && r1[1].text.empty() && + line_text(r1) == CJK + " ", "wide last-column prewrap split the lead/trail"); + invariant(p.screen); + p.finish(); + } +} + +// A wide scalar that fits the last two columns exactly arms the delayed edge +// instead of pre-wrapping. DECAWM on lets the next narrow printable wrap; off +// keeps it on the last column, where it overwrites the trail and blanks the +// lead so no half glyph survives (non-splitting). +static void wide_exact_fit_non_splitting(ParseMode mode) { + { + Parser p(3, 4, 2, mode); + p.feed("\x1b[?7h\x1b[1;3H" + CJK + "X"); + check(p.screen.row == 1 && p.screen.column == 1, "decawm on did not wrap after exact-fit wide char"); + check(p.screen.visible_lines()[0][2].width == 2 && p.screen.visible_lines()[0][3].width == 0 && + p.screen.visible_lines()[0].ending == LineEnding::soft_wrap && + p.screen.visible_lines()[1][0].text == "X", + "decawm on split or clobbered the wide glyph"); + invariant(p.screen); + p.finish(); + } + { + Parser p(3, 4, 2, mode); + p.feed("\x1b[?7l\x1b[1;3H" + CJK + "X"); + check(p.screen.row == 0 && p.screen.column == 3 && p.screen.wrap_pending, + "decawm off did not stay on the last column after exact-fit wide char"); + check(line_text(p.screen.visible_lines()[0]) == " X", + "decawm off did not overwrite the trail and blank the lead"); + check(p.screen.visible_lines()[0][2].width == 1 && p.screen.visible_lines()[0][3].text == "X", + "decawm off left an orphaned wide half-cell"); + invariant(p.screen); + p.finish(); + } +} + +// 1049 hides the primary wide glyph and its armed edge, then restores both on +// exit with the lead/trail pairing intact. +static void wide_hidden_restored_across_1049(ParseMode mode) { + Parser p(3, 4, 2, mode); + p.feed("\x1b[1;3H" + CJK); + check(p.screen.row == 0 && p.screen.column == 3 && p.screen.wrap_pending && + p.screen.visible_lines()[0][2].width == 2 && p.screen.visible_lines()[0][3].width == 0 && + p.screen.visible_lines()[0][2].text == CJK && line_text(p.screen.visible_lines()[0]) == " " + CJK, + "wide exact-fit fixture not armed with a paired lead/trail"); + p.feed("\x1b[?1049h"); + check(p.screen.active_buffer() == ScreenBuffer::alternate && !p.screen.wrap_pending && + p.screen.row == 0 && p.screen.column == 0 && line_text(p.screen.visible_lines()[0]) == " ", + "alternate entry kept the primary pending edge or failed to clear the alternate"); + p.feed("\x1b[?1049l"); + check(p.screen.active_buffer() == ScreenBuffer::primary && p.screen.wrap_pending && + p.screen.row == 0 && p.screen.column == 3, + "1049 exit lost the saved primary pending edge"); + check(p.screen.visible_lines()[0][2].width == 2 && p.screen.visible_lines()[0][3].width == 0 && + p.screen.visible_lines()[0][2].text == CJK && line_text(p.screen.visible_lines()[0]) == " " + CJK, + "1049 roundtrip split the wide lead/trail"); + invariant(p.screen); + p.finish(); +} + +// The primary combining cluster (base + marks) survives a 1049 roundtrip and +// keeps accepting further marks on the restored base. The row text must still +// round-trip the original UTF-8 bytes. +static void combining_cluster_survives_1049(ParseMode mode) { + Parser p(3, 4, 2, mode); + p.feed("a" + COMB_ACUTE); + check(p.screen.visible_lines()[0][0].marks == 1 && + p.screen.visible_lines()[0][0].text == "a" + COMB_ACUTE, + "fixture did not attach the first combining mark"); + p.feed("\x1b[?1049h"); + check(p.screen.active_buffer() == ScreenBuffer::alternate, "1049 entry failed"); + p.feed("\x1b[?1049l"); + check(p.screen.active_buffer() == ScreenBuffer::primary && + p.screen.visible_lines()[0][0].marks == 1 && + p.screen.visible_lines()[0][0].text == "a" + COMB_ACUTE, + "1049 roundtrip lost the primary combining cluster"); + p.feed(COMB_DIAERESIS); + check(p.screen.visible_lines()[0][0].marks == 2 && + p.screen.visible_lines()[0][0].text == "a" + COMB_ACUTE + COMB_DIAERESIS && + line_text(p.screen.visible_lines()[0]) == "a" + COMB_ACUTE + COMB_DIAERESIS + " ", + "restored attachment did not extend the original cluster"); + p.finish(); +} + +// A combining mark after a wide glyph lands on the lead cell and stays there +// across a 1049 roundtrip, so the whole cluster is copied as one unit. +static void combining_after_wide_survives_1049(ParseMode mode) { + Parser p(3, 4, 2, mode); + p.feed("\x1b[1;3H" + CJK + COMB_ACUTE); + check(p.screen.visible_lines()[0][2].width == 2 && p.screen.visible_lines()[0][2].marks == 1 && + p.screen.visible_lines()[0][3].width == 0 && p.screen.visible_lines()[0][3].text.empty(), + "combining mark did not land on the wide lead cell"); + p.feed("\x1b[?1049h\x1b[?1049l" + COMB_DIAERESIS); + check(p.screen.visible_lines()[0][2].width == 2 && p.screen.visible_lines()[0][2].marks == 2 && + p.screen.visible_lines()[0][2].text == CJK + COMB_ACUTE + COMB_DIAERESIS && + p.screen.visible_lines()[0][3].width == 0 && p.screen.visible_lines()[0][3].text.empty(), + "wide cluster did not survive and extend across 1049"); + check(line_text(p.screen.visible_lines()[0]) == " " + CJK + COMB_ACUTE + COMB_DIAERESIS, + "wide cluster text did not round-trip"); + invariant(p.screen); + p.finish(); +} + +// The primary and alternate buffers keep independent combining attachments; +// the primary cluster survives exit, while a 1049 re-entry clears the alternate +// frame (and with it any pending combining base) per the existing 1049 contract. +static void combining_both_frames_independent(ParseMode mode) { + Parser p(3, 4, 2, mode); + p.feed("a" + COMB_ACUTE); + p.feed("\x1b[?1049h"); + p.feed("b" + COMB_ACUTE); + check(p.screen.visible_lines()[0][0].text == "b" + COMB_ACUTE && + p.screen.visible_lines()[0][0].marks == 1, "alternate combining cluster not formed"); + p.feed("\x1b[?1049l"); + check(p.screen.visible_lines()[0][0].text == "a" + COMB_ACUTE && + p.screen.visible_lines()[0][0].marks == 1, "exit did not restore the primary cluster"); + p.feed(COMB_DIAERESIS); + check(p.screen.visible_lines()[0][0].marks == 2, "primary cluster not extended after exit"); + p.feed("\x1b[?1049h"); + check(line_text(p.screen.visible_lines()[0]) == " ", "1049 re-entry did not clear the alternate frame"); + p.finish(); +} + +// Strict rejects a combining mark without an adjacent base; recovery replaces it +// with U+FFFD and records the replacement. This also covers the cleared-alternate +// case after a 1049 entry. +static void combining_without_base_handling(ParseMode mode) { + if (mode == ParseMode::strict) { + Parser p(3, 4, 2, mode); + p.feed("\x1b[?1049h"); + bool rejected = false; + try { p.feed(COMB_ACUTE); } + catch (const InputError&) { rejected = true; } + check(rejected && p.screen.row == 0 && p.screen.column == 0, + "strict combining without adjacent base did not reject atomically"); + p.finish(); + } else { + Parser p(3, 4, 2, mode); + p.feed("\x1b[?1049h"); + p.feed(COMB_ACUTE); + check(p.diagnostics().replaced_scalars == 1 && + p.screen.visible_lines()[0][0].text == UFFFD && + p.screen.visible_lines()[0][0].width == 1, + "recovery combining without adjacent base did not replace with U+FFFD"); + p.finish(); + } +} + +// Original-text copy consistency: the visible row text must reproduce the exact +// input bytes for a mixed narrow base + marks + space + wide glyph + mark, with +// no duplication of the wide glyph (its trail cell is empty). +static void text_copy_consistency(ParseMode mode) { + Parser p(2, 5, 2, mode); + const std::string source = "a" + COMB_ACUTE + COMB_DIAERESIS + " " + CJK + COMB_ACUTE; + p.feed(source); + check(line_text(p.screen.visible_lines()[0]) == source + " ", + "row text did not round-trip the original wide + combining bytes"); + check(p.screen.visible_lines()[0][2].width == 2 && p.screen.visible_lines()[0][2].marks == 1 && + p.screen.visible_lines()[0][3].width == 0 && p.screen.visible_lines()[0][3].text.empty(), + "wide glyph lead/trail or mark placement wrong"); + invariant(p.screen); + p.finish(); +} + +// Resize x wide glyph x 1049 boundary: shrinking width to one column blanks an +// orphaned lead (non-splitting, ADR-0005 crop), resizing while the primary is +// hidden resizes the hidden frame too, a one-row crop keeps a row-0 wide glyph +// intact, and a wide scalar in a one-column screen is rejected outright. +static void resize_wide_1049_boundary(ParseMode mode) { + { + Parser p(2, 4, 2, mode); + p.feed(CJK); + check(p.screen.visible_lines()[0][0].width == 2 && p.screen.visible_lines()[0][1].width == 0, + "wide fixture lead/trail not paired"); + p.screen.resize(2, 1); + check(line_text(p.screen.visible_lines()[0]) == " " && p.screen.visible_lines()[0][0].width == 1 && + p.screen.visible_lines()[0].cropped, "one-column crop left an orphaned wide lead"); + invariant(p.screen); + p.finish(); + } + { + Parser p(2, 4, 2, mode); + p.feed(CJK); + p.feed("\x1b[?1049h"); + p.screen.resize(2, 3); + p.feed("\x1b[?1049l"); + check(p.screen.visible_lines()[0][0].width == 2 && p.screen.visible_lines()[0][1].width == 0 && + p.screen.visible_lines()[0][0].text == CJK && p.screen.visible_lines()[0].cropped, + "resize while hidden split or lost the primary wide glyph"); + invariant(p.screen); + p.finish(); + } + { + Parser p(2, 4, 2, mode); + p.feed(CJK); + p.feed("\x1b[1;1H"); + p.screen.resize(1, 4); + check(line_text(p.screen.visible_lines()[0]) == CJK + " " && + p.screen.visible_lines()[0][0].width == 2 && p.screen.visible_lines()[0][1].width == 0, + "one-row crop broke the row-0 wide glyph"); + p.screen.resize(2, 4); + check(p.screen.visible_lines()[0][0].text == CJK, "grow-back displaced the surviving wide glyph"); + invariant(p.screen); + p.finish(); + } + { + Parser p(2, 1, 2, mode); + if (mode == ParseMode::strict) { + bool rejected = false; + try { p.feed(CJK); } + catch (const InputError&) { rejected = true; } + check(rejected, "one-column screen accepted a wide scalar it cannot hold"); + } else { + p.feed(CJK); + check(p.diagnostics().replaced_scalars == 1 && + p.screen.visible_lines()[0][0].text == UFFFD && p.screen.visible_lines()[0][0].width == 1, + "recovery one-column wide scalar did not replace with U+FFFD"); + } + p.finish(); + } +} + +// A real size change clears the pending combining base; a further mark throws in +// strict. The base text itself is preserved, only the pending attachment dies. +static void real_resize_clears_combining_attachment(ParseMode mode) { + Parser p(2, 4, 2, mode); + p.feed("a" + COMB_ACUTE); + p.screen.resize(2, 3); + if (mode == ParseMode::strict) { + bool rejected = false; + try { p.feed(COMB_DIAERESIS); } + catch (const InputError&) { rejected = true; } + check(rejected && p.screen.visible_lines()[0][0].text == "a" + COMB_ACUTE && + p.screen.visible_lines()[0][0].marks == 1, + "real resize did not clear the pending combining base but kept the cluster text"); + } else { + p.feed(COMB_DIAERESIS); + check(p.diagnostics().replaced_scalars == 1 && + p.screen.visible_lines()[0][0].text == "a" + COMB_ACUTE && + p.screen.visible_lines()[0][0].marks == 1, + "recovery real resize did not replace the orphaned combining mark with U+FFFD"); + } + p.finish(); +} + +// A fixed wide+combining+1049+DECAWM source must replay identically when fed in +// one shot, byte by byte, and every two-way split (including mid-UTF-8 cuts). +static std::string invariance_source() { + return "\x1b[?7h\x1b[1;3H" + CJK + COMB_ACUTE + "\x1b[?1049h" + "x" + COMB_ACUTE + + "\x1b[?1049l" + COMB_DIAERESIS; +} + +static void chunk_invariance(ParseMode mode, size_t* cases) { + const std::string source = invariance_source(); + std::string single; + { + Parser p(3, 4, 2, mode); + p.feed(source); + p.finish(); + single = signature(p.screen); + } + { + Parser p(3, 4, 2, mode); + for (const auto byte : source) p.feed(std::string_view(&byte, 1)); + p.finish(); + check(signature(p.screen) == single, "byte-wise state differs from single-shot"); + } + for (size_t split = 0; split <= source.size(); ++split) { + Parser p(3, 4, 2, mode); + p.feed(std::string_view(source).substr(0, split)); + p.feed(std::string_view(source).substr(split)); + p.finish(); + check(signature(p.screen) == single, "two-way split state differs from single-shot"); + ++*cases; + } +} + +// Recovery replaces a malformed UTF-8 byte with U+FFFD without disturbing a +// preceding wide glyph; the diagnostic split (malformed_utf8 vs replaced_scalars) +// stays observable. +static void malformed_utf8_recovery_replaces() { + Parser p(2, 4, 2, ParseMode::recovery); + p.feed("a" + CJK); + p.feed("\xff"); + check(p.diagnostics().malformed_utf8 == 1 && p.diagnostics().replaced_scalars == 0, + "malformed UTF-8 was not counted as malformed_utf8"); + check(p.screen.visible_lines()[0][0].text == "a" && + p.screen.visible_lines()[0][1].width == 2 && p.screen.visible_lines()[0][1].text == CJK && + p.screen.visible_lines()[0][3].text == UFFFD, + "malformed byte replaced the wrong cell or disturbed the wide glyph"); + invariant(p.screen); + p.finish(); +} + +int main() { + int failures = 0, checks = 0; + const auto run = [&](const std::string& name, const auto& action) { + ++checks; + try { action(); std::cout << name << " passed\n"; } + catch (const std::exception& error) { + ++failures; + std::cerr << name << " failed: " << error.what() << '\n'; + } + }; + for (const auto mode : {ParseMode::strict, ParseMode::recovery}) { + const std::string name = mode == ParseMode::strict ? "strict" : "recovery"; + run(name + " wide insufficient-space prewrap", [&] { wide_insufficient_space_prewrap(mode); }); + run(name + " wide exact-fit non-splitting", [&] { wide_exact_fit_non_splitting(mode); }); + run(name + " wide hidden/restored across 1049", [&] { wide_hidden_restored_across_1049(mode); }); + run(name + " combining cluster survives 1049", [&] { combining_cluster_survives_1049(mode); }); + run(name + " combining after wide survives 1049", [&] { combining_after_wide_survives_1049(mode); }); + run(name + " combining both frames independent", [&] { combining_both_frames_independent(mode); }); + run(name + " combining without base handling", [&] { combining_without_base_handling(mode); }); + run(name + " text copy consistency", [&] { text_copy_consistency(mode); }); + run(name + " resize wide 1049 boundary", [&] { resize_wide_1049_boundary(mode); }); + run(name + " real resize clears combining attachment", [&] { real_resize_clears_combining_attachment(mode); }); + size_t cases = 0; + run(name + " chunk invariance", [&] { chunk_invariance(mode, &cases); }); + std::cout << " " << name << " chunk cases: " << cases << "\n"; + } + run("recovery malformed UTF-8 replacement", malformed_utf8_recovery_replaces); + std::cout << "widechar-1049 checks: " << (checks - failures) << '/' << checks << " passed\n"; + return failures ? 1 : 0; +} From e2af5c8882accb6aec429ffe42d973b671fd6d6f Mon Sep 17 00:00:00 2001 From: JavaZeroo <71128095+JavaZeroo@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:42:01 +0800 Subject: [PATCH 2/2] docs(core): archive wide/combining x 1049 evidence for #27 --- docs/EVIDENCE.md | 1 + .../2026-11-02-widechar-1049/README.md | 41 ++++++ .../2026-11-02-widechar-1049/validation.json | 120 ++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 experiments/core/results/2026-11-02-widechar-1049/README.md create mode 100644 experiments/core/results/2026-11-02-widechar-1049/validation.json diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md index bfb75239..70cc891d 100644 --- a/docs/EVIDENCE.md +++ b/docs/EVIDENCE.md @@ -15,6 +15,7 @@ | 自主 C++ 主屏 ASCII DECAWM | [有限合同与红绿](../experiments/core/results/2026-10-29-cpp-decawm/README.md)、[固定验证身份](../experiments/core/results/2026-10-29-cpp-decawm/validation.json):抽取原版14–18阶段79B,strict/recovery各235个字面前缀分块用例;初次定向8/9的缺构建依赖失败、补建后的core单项31测试通过分列。跨1049/宽字/resize、origin/margin、GUI/固定ConPTY运输和完整TUI均不由本项验收 | | 自主 C++ DECOM origin 与 margin/1049(#27 切片) | [有限合同与验证](../experiments/core/results/2026-11-01-decom-origin/README.md)、[固定验证身份](../experiments/core/results/2026-11-01-decom-origin/validation.json):原版25阶段334B完整回放、逐阶段DSR核对;strict/recovery各整体/逐字节/435二段分割一致;13项核心CTest与66项Python核心测试通过、0跳过。每屏origin位、返回margin下界、origin×末列wrap与完整grid均不由此项验收 | | 自主 C++ pending-wrap:延迟换行边缘跨 1049/DECAWM/DECOM(#27 切片) | [有限合同与验证](../experiments/core/results/2026-11-02-pending-wrap/README.md)、[固定验证身份](../experiments/core/results/2026-11-02-pending-wrap/validation.json):以 #123 备用屏阶段 8–9 与 #151 阶段 14–18 为字面 oracle,锁末列延迟换行在 1049 进出/重复切换下的保存恢复、DECAWM开/关 × DECOM开/关的 wrap 决策/光标落点/下一可打印字符起始;strict/recovery 各 7 阶段累计回放、逐字节与 151 个二段分割一致,异常序列恢复不串扰。origin×末列 wrap 为组合模型检查(原版未观测);16 项核心 CTest 与 66 项 Python 核心测试通过、0 跳过;实现与基线无冲突(仅测试自断行号一处修正)。宽字/结合字跨 1049、完整 grid/样式与固定 ConPTY 运输均不由此项验收 | +| 自主 C++ 宽字/结合字跨 1049 与 DECAWM 关闭换行(#27 切片) | [有限合同与验证](../experiments/core/results/2026-11-02-widechar-1049/README.md)、[固定验证身份](../experiments/core/results/2026-11-02-widechar-1049/validation.json):以现有实现为基线(无新 WindTerm 原版运行),锁末列宽字不足格预换行在 DECAWM 开/关下一致、宽字 lead/trail 经 1049 进出不分裂且隐藏/恢复、结合字聚类跨 1049 保持并可续接、原文(宽字不重复、结合字聚合)复制一致、resize 1 列/1 行与 1049 交叉的宽字边界、逐字节与 38 个二段分割一致、异常序列与 malformed UTF-8 恢复;strict/recovery 各 11 项组合检查 23/23、17 项核心 CTest 与 66 项 Python 核心测试通过、0 跳过;实现与基线无冲突(仅测试自身 strict/recovery 区分两处修正)。宽字×DECAWM 关、宽字/结合字×1049 原版均未观测,计为组合模型检查;完整 grid/逐格样式与固定 ConPTY 运输不由此项验收 | | 自主 C++ 行结构发布与精确身份查询 | [首片合同与固定夹具](../experiments/core/results/2026-10-29-order-query/README.md)、[验证身份](../experiments/core/results/2026-10-29-order-query/validation.json):`[1,3,4,5,7,6]` 的精确 rank(7→4、6→5、2 缺失)、文档序比较与强制 Find 导航、结构 revision 边界、发布拒绝与光标夹取;筛选的 8 项核心 CTest 与本地候选 `candidate_core` 通过,首个推送版本的 CI 红(回放保留历史仍为上一帧宽度)与按物理行策略适配后的绿分列,完整默认 Release 构建 exit0。未封住公开 lines/history,运行中跨重排的增量 Find、区域 LF、Qt/固定 ConPTY 与性能均未验收 | | 自主 C++ 限定 1049 协议 | [固定结果](../experiments/core/results/2026-10-29-cpp-1049/README.md)、[验证身份](../experiments/core/results/2026-10-29-cpp-1049/validation.json):文字/光标/颜色及 pending-wrap、重复进入与读者失效;55项中Qt1项分列,13条内部skip及旧负例判据失败保留。未验收非颜色属性/全网格背景、其他模式、真实TUI/IME/DPI、呈现或性能 | | 自主 C++ 双缓冲存储与文档 epoch 基础 | [固定结果](../experiments/core/results/2026-10-29-buffer-state-r2/README.md)、[验证身份](../experiments/core/results/2026-10-29-buffer-state-r2/validation.json):拥有状态、共享 ID、隐藏帧 resize 回滚与跨缓冲句柄失效;Qt/candidate 关闭配置的完整 50 项及 12 条内部 skip 分列。未接 private CSI,不是原版模式、Qt 产品路线或完整 #27 验收 | diff --git a/experiments/core/results/2026-11-02-widechar-1049/README.md b/experiments/core/results/2026-11-02-widechar-1049/README.md new file mode 100644 index 00000000..5b206ce5 --- /dev/null +++ b/experiments/core/results/2026-11-02-widechar-1049/README.md @@ -0,0 +1,41 @@ +# 自主 C++ 宽字/结合字跨 1049 与 DECAWM 关闭换行(#27 限定切片) + +自主 C++ strict/recovery 锁定宽字(CJK 双宽)与结合字序列跨 1049 备用屏切换的行为:末列宽字不足格预换行策略与 DECAWM 开/关组合、宽字 lead/trail 不分裂与隐藏/恢复、结合字聚类跨 1049 保持、原文复制一致、resize(1 列/1 行)与 1049 交叉的宽字边界、逐字节与分块喂入一致、异常序列与 malformed UTF-8 恢复。 + +重要边界:本切片没有新增 WindTerm 原版运行。原版从未观测宽字或结合字跨 1049 的组合,因此下面所有交叉都是组合模型检查,以现有实现为基线锁定当前行为,而不是独立的字面 oracle。原版未观测的组合保持未知并记录,不猜。 + +## 固定输入与范围 + +[测试](../../widechar_1049_test.cpp) 通过公开 Parser 的 feed/finish/take_replies 接口锁定: + +- 末列宽字不足格预换行 × DECAWM:双宽字在末列只剩一格时整体预换行到下一行,DECAWM 开与关结果一致(许可位只门控延迟窄字的消费);源行保留软续行 metadata,目标行 lead/trail 配对完整。 +- 宽字恰好填满末两格 × DECAWM:开时下一窄字标准换行,关时下一窄字覆盖 trail 并清空 lead(不分裂,不留半字)。 +- 宽字 × 1049 隐藏/恢复:进入 1049 清掉备用屏可见 pending,退出恢复主屏的宽字 lead/trail 与 pending 边,往返不分裂。 +- 结合字聚类 × 1049:主屏 base+marks 聚类跨 1049 往返保持并可续接;主屏与备用屏各自独立;1049 再进入清空备用屏(含其 pending 结合基)。 +- 宽字后的结合标落在 lead 格并跨 1049 继续聚合;原文复制(行文本)对窄 base+多结合、宽字+结合的原始 UTF-8 逐字节回环,宽字不重复、结合字聚合不回退。 +- resize 宽字边界:1 列收缩清空被裁剪的 lead(不分裂);1049 内 resize 同步裁剪隐藏主屏宽字;1 行裁剪保留第 0 行宽字;1 列屏放不下宽字(strict 拒绝,recovery 以 U+FFFD 替换)。 +- 真实尺寸变更清掉 pending 结合基(后续结合标 strict 抛错、recovery 替换),宽字/结合字簇的已写文本保留。 +- 分块/逐字节一致:对固定含宽字+结合字+1049+DECAWM 的源做整体、逐字节、37 字节对应 38 个二段分割,状态签名一致。 +- 异常序列:strict 拒绝无相邻 base 的结合标,recovery 以 U+FFFD 替换并计数 replaced_scalars;malformed UTF-8 在 recovery 计 malformed_utf8 并替换,不扰动前序宽字。 + +## 设计边界(区分实测/推断/未验证) + +- 实测:宽字恰好填满末两格在 DECAWM 开下的标准换行,以及末列宽字不足格预换行策略(源自 #161 批次里兼容保留的 wide fit policy,原版未观测 DECAWM 关下的宽字)。均为本核心既有行为,本切片不修改实现。 +- 推断(组合模型):上述全部宽字/结合字与 1049、DECAWM 关、resize 的交叉,因为原版对这些组合无观测,不算原版匹配。 +- 未验证:完整 active grid 与逐格样式、每屏 origin 位、固定 ConPTY 运输、真实 TUI、真实 DPI/呈现与性能。 + +## 红绿与实际验证 + +[验证记录](validation.json) 保留源码/二进制 SHA、配置与通过/失败/跳过。 + +- MSVC 19.42.34433.0、Release,Qt/candidate 关闭。定向构建成功;17 项核心 C++ CTest 全绿(16 项既有 + 新 widechar_1049_core 23/23 checks),0 跳过。 +- Python experiments.core.test_core/test_unicode/test_recovery 66 项通过。 +- 唯一中途红是测试自身把 recovery 模式误当 strict:recovery 以 U+FFFD 替换(不抛 InputError),已按 strict/recovery 区分修正两处;生产实现代码零改动,与合同基线无冲突。 + +复验只需普通 C++ 构建,无新增依赖: + + cmake -S . -B build/widechar-1049 -G 'Visual Studio 17 2022' -A x64 -DTERMFORGE_BUILD_QT_SHELL=OFF -DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF + cmake --build build/widechar-1049 --config Release --target widechar_1049_test pending_wrap_test decawm_mode_test alternate_mode_test decom_origin_test buffer_state_test history_test resize_test resize_edges_test selection_test search_test search_reorder_test reply_test edit_csi_test order_query_test view_mapping_test private_mode_test replay terminal_trace recovery_probe receive --parallel 2 + ctest --test-dir build/widechar-1049 -C Release -R '^(resize_core|resize_edges_core|history_core|selection_core|search_core|search_reorder_core|buffer_state_core|view_mapping_core|alternate_mode_core|decawm_mode_core|private_mode_core|decom_origin_core|reply_core|edit_csi_core|order_query_core|pending_wrap_core|widechar_1049_core)$' + +这里只声明有限 core feed 能力;Qt、原生 GUI、固定 ConPTY 路径、真实 TUI、呈现与性能均未由本项运行或验收。#27 保持开放。 diff --git a/experiments/core/results/2026-11-02-widechar-1049/validation.json b/experiments/core/results/2026-11-02-widechar-1049/validation.json new file mode 100644 index 00000000..e4aa06f5 --- /dev/null +++ b/experiments/core/results/2026-11-02-widechar-1049/validation.json @@ -0,0 +1,120 @@ +{ + "schema": "termforge-cpp-widechar-1049-local-validation-v1", + "issue": 27, + "base_commit": "3b17e92d7e74ace88d3e3f0daf9c1c37dcef946c", + "runtime_implementation_commit": "5db6e262a02c7f8042c93412d5fe2e9c0502eb12", + "validated_source_commit": "5db6e262a02c7f8042c93412d5fe2e9c0502eb12", + "final_binary_hash_observation": "After all directed tests completed; hashes taken once at the end and do not claim continuous monitoring.", + "configuration": { + "platform": "Windows-11-10.0.26200-SP0", + "python": "3.12.10", + "generator": "Visual Studio 17 2022", + "compiler": "MSVC 19.42.34433.0", + "compiler_sha256": "42fd88579adc0e2418a0a71ec9adcfcf677d39ff2724c9d6ac9d901264812bf2", + "configuration": "Release", + "qt_shell": false, + "candidate_adapter": false, + "conpty_runtime_exercised": false + }, + "observed_contract": { + "reference_issues": [ + 27, + 161, + 123, + 151 + ], + "reference_docs": { + "docs/adr/0005-physical-row-resize.md": "677d3f889dc61acd8d3b0b0dd27c17b3b3fa0057c3b5f0c7253d823844be6fc2" + }, + "whole_original_trace_replayed": false, + "original_runs_this_implementation": 0, + "basis": "composed_model_check", + "basis_note": "The original WindTerm never observed wide or combining text across 1049, so this slice locks the existing core implementation as the baseline with composed model checks; it is not an independent literal oracle and does not claim original parity.", + "parser_modes": [ + "strict", + "recovery" + ], + "per_mode_two_way_split_cases": 38, + "per_mode_bytewise_cases": 1, + "cpp_checks": 23, + "cpp_test_groups": 23, + "covered_crossings": [ + "wide scalar insufficient space at the last column pre-wraps identically under DECAWM on and off", + "wide scalar exact fit arms the delayed edge; DECAWM on wraps the next narrow printable, DECAWM off overwrites the trail and blanks the lead (non-splitting)", + "wide lead/trail survive a 1049 entry/exit with the pending edge hidden then restored", + "combining cluster (base + marks) survives a 1049 roundtrip and keeps accepting further marks", + "combining mark after a wide glyph lands on the lead cell and survives across 1049", + "primary and alternate keep independent combining attachments; 1049 re-entry clears the alternate", + "strict rejects a combining mark without an adjacent base; recovery replaces it with U+FFFD", + "row text round-trips the original wide + combining UTF-8 bytes without duplicating the wide glyph", + "1-column crop blanks an orphaned wide lead; resize while hidden crops the primary frame; 1-row crop keeps a row-0 wide glyph; a 1-column screen rejects a wide scalar", + "a real size change clears the pending combining base but keeps the cluster text", + "byte-wise and two-way split feeding reproduce the same state signature" + ] + }, + "final_runtime_sha256": { + "experiments/core/widechar_1049_test.cpp": "e53fbdd8ec29bd31ae0aff26af7391955fdfa68b9713a24599d9313eba017396", + "CMakeLists.txt": "0b80a69d54e4d67530c8cbdd6a9a04cd84c0789c2dbf46490424def32af9d9d1", + "experiments/core/screen.hpp": "142e786bd202a63f948d5443bcd203985d57c30f7ca39bd1cb9133646780a9ed", + "experiments/core/terminal.hpp": "486527af086973b21a672699ad5a70cb5b2ed7b709203e420212f0ce0b5bc3c9", + "experiments/core/unicode_width.hpp": "d48bf60090a9c6a4263bb46ac587b13814b52c0dbe9d2649cee20e832a1e1926" + }, + "final_binary_sha256": { + "widechar_1049_test.exe": "02ae31723e2b8d484a3bf311f748a1d48476f3be6dd3a7ec6a902014c9f1910d", + "pending_wrap_test.exe": "eb8a717b0c4ba69cf734d6ee93d27b8766e82cd8a45a997b0235eeaf257287e5", + "decawm_mode_test.exe": "43c86d7596091df5c0a45e8921ce51fa66c237986de5b22effebde141db5160a", + "alternate_mode_test.exe": "80a2b7dc760ce89894c1fc0f11a6af5e2e0e3559b5c9727aa5742088f9aef520", + "decom_origin_test.exe": "e3c381cb854e3fef6c3fae4b67e671301301ace4401323b3b8d16478529ba643", + "buffer_state_test.exe": "716c88d0ed4e3465d18c093ee98f5942d3b2af6c63cf8977a53363d01752263d", + "history_test.exe": "9a003119b9c363f4d7332889884df5974e70d6b7d49f7f5b9d6cac3a8be2f5b7", + "resize_test.exe": "91cc03d25f3aa5a3d03d34500568eaaebc8d65acfa188eaf2caf74d4b202a749", + "resize_edges_test.exe": "92c970aa4d42934ad82d93d06aa2df5f353f09275387c7e5b1f233100fea6369", + "selection_test.exe": "146345a13fa1bc5a5a10b87c2ee79513ae9bf67a23b9feb0de484e53888273cd", + "search_test.exe": "afd8c5d077d2f613bbb21e18b9011bf93a0ce0a562991c97647ef71ec05bef46", + "search_reorder_test.exe": "2c4421056ccdd3baf7a83717b9361e88f7df757d87d6af158ca619e7cf6e7fef", + "reply_test.exe": "2a0cd2b83980af36c9eb4a28144d5af3a83852bee8ea64581c00bfa27ef1b6f4", + "edit_csi_test.exe": "547ba7d0ab27b199c22bce2a3abe765d6473b147dcf81d767cf77b14d9f6ef8b", + "order_query_test.exe": "4fc5d9bdee1cc8e477acb1ec24a62c885c04a9909a02ee2a57c867d3df97a69a", + "view_mapping_test.exe": "5268dafaeb43213fa1a62c7f76ed07b9bef040651656ee69a1233f6536f27c84", + "private_mode_test.exe": "b114577527a736aef02a34685d39a7fabc0dc1476ed67b4dfb10ec486eb1e692", + "replay.exe": "ccbdd19bd058be45af92812c5aeba80b899d48eb95edc7b47edb1cf397cf8bd3", + "terminal_trace.exe": "0f336da7f0c8a522a0508434c602dfc3ab524a2440c93a33853560c68a83b71d", + "recovery_probe.exe": "487a4801c5ce9017f893540f7e4d9202980ffab32777ef16204e4860164074b1", + "receive.exe": "1e13dd7fcbfe973b8f6b1ea90cbd0d3e8945abb6d857c70bb02101db7d74dfab" + }, + "executions": [ + { + "label": "configure", + "exit_code": 0, + "timed_out": false, + "note": "build/widechar-1049, Qt and candidate disabled" + }, + { + "label": "build-core-tests", + "exit_code": 0, + "timed_out": false, + "note": "widechar_1049_test + 16 existing core C++ test executables + replay/terminal_trace/recovery_probe/receive" + }, + { + "label": "widechar_1049_test", + "exit_code": 0, + "note": "23/23 checks passed; strict+recovery 11 composed crossings each, 38 two-way splits and bytewise each" + }, + { + "label": "ctest-core", + "exit_code": 0, + "note": "17/17 core C++ tests passed, 0 failed, 0 skipped" + }, + { + "label": "python-core-unicode-recovery", + "exit_code": 0, + "note": "66 tests passed (experiments.core.test_core/test_unicode/test_recovery)" + } + ], + "skip_count": 0, + "residual_gaps": [ + "Every wide/combining x 1049, x DECAWM-off and x resize crossing is a composed model check; the original WindTerm observed none of them, so they stay unobserved rather than original parity.", + "Full active grid, per-cell style, per-screen origin bit and fixed-ConPTY transport are outside this slice.", + "Wide char x DECAWM off (insufficient-space prewrap) was never observed in the original; only the exact-fit DECAWM-on wrap and the preserved wide-fit fallback policy derive from the existing #161 batch." + ] +}