smartcli-toolkit 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/PKG-INFO +22 -2
  2. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/README.md +1 -1
  3. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/pyproject.toml +43 -1
  4. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_core/__init__.py +7 -3
  5. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_core/pty_backend.py +8 -0
  6. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_core/readiness.py +22 -2
  7. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_core/session.py +13 -2
  8. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/PKG-INFO +22 -2
  9. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/SOURCES.txt +4 -1
  10. smartcli_toolkit-0.1.2/tests/test_degenerate_inputs.py +93 -0
  11. smartcli_toolkit-0.1.2/tests/test_fx_contract.py +267 -0
  12. smartcli_toolkit-0.1.2/tests/test_readiness.py +396 -0
  13. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/LICENSE +0 -0
  14. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/setup.cfg +0 -0
  15. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_core/screen_model.py +0 -0
  16. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_core/snapshot.py +0 -0
  17. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/dependency_links.txt +0 -0
  18. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/requires.txt +0 -0
  19. {smartcli_toolkit-0.1.0 → smartcli_toolkit-0.1.2}/smartcli_toolkit.egg-info/top_level.txt +0 -0
@@ -1,8 +1,28 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
5
+ Author: dwgx
5
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dwgx/SmartCLI
8
+ Project-URL: Repository, https://github.com/dwgx/SmartCLI
9
+ Project-URL: Changelog, https://github.com/dwgx/SmartCLI/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/dwgx/SmartCLI/issues
11
+ Project-URL: PyPI, https://pypi.org/project/smartcli-toolkit/
12
+ Keywords: terminal,tui,pty,ansi,cli,ascii-art,terminal-effects,claude-skill,pyte,conpty
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Terminals
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Environment :: Console
6
26
  Requires-Python: >=3.9
7
27
  Description-Content-Type: text/markdown
8
28
  License-File: LICENSE
@@ -66,7 +86,7 @@ works today from a clean clone.
66
86
  **Primary — reproduce the full dev environment** (recommended):
67
87
 
68
88
  ```bash
69
- git clone <repo-url> SmartCLI
89
+ git clone https://github.com/dwgx/SmartCLI SmartCLI
70
90
  cd SmartCLI
71
91
  python -m pip install -r requirements.txt
72
92
  ```
@@ -44,7 +44,7 @@ works today from a clean clone.
44
44
  **Primary — reproduce the full dev environment** (recommended):
45
45
 
46
46
  ```bash
47
- git clone <repo-url> SmartCLI
47
+ git clone https://github.com/dwgx/SmartCLI SmartCLI
48
48
  cd SmartCLI
49
49
  python -m pip install -r requirements.txt
50
50
  ```
@@ -30,7 +30,7 @@ build-backend = "setuptools.build_meta"
30
30
 
31
31
  [project]
32
32
  name = "smartcli-toolkit"
33
- version = "0.1.0"
33
+ version = "0.1.2"
34
34
  description = "Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core)."
35
35
  readme = "README.md"
36
36
  license = "MIT"
@@ -40,6 +40,41 @@ license-files = ["LICENSE"]
40
40
  # No match/case and no runtime `X | Y` types are used, so nothing forces higher.
41
41
  requires-python = ">=3.9"
42
42
 
43
+ authors = [{name = "dwgx"}]
44
+
45
+ keywords = [
46
+ "terminal",
47
+ "tui",
48
+ "pty",
49
+ "ansi",
50
+ "cli",
51
+ "ascii-art",
52
+ "terminal-effects",
53
+ "claude-skill",
54
+ "pyte",
55
+ "conpty",
56
+ ]
57
+
58
+ # Real PyPI trove classifiers. NOTE: no `License ::` classifier is listed on
59
+ # purpose — with the PEP 639 SPDX `license = "MIT"` field above, setuptools
60
+ # (77+) treats a license trove classifier as redundant/conflicting and errors
61
+ # out. The SPDX field is the single source of truth for licensing.
62
+ classifiers = [
63
+ "Development Status :: 4 - Beta",
64
+ "Intended Audience :: Developers",
65
+ "Operating System :: OS Independent",
66
+ "Programming Language :: Python :: 3",
67
+ "Programming Language :: Python :: 3.9",
68
+ "Programming Language :: Python :: 3.10",
69
+ "Programming Language :: Python :: 3.11",
70
+ "Programming Language :: Python :: 3.12",
71
+ "Programming Language :: Python :: 3.13",
72
+ "Programming Language :: Python :: 3.14",
73
+ "Topic :: Terminals",
74
+ "Topic :: Software Development :: Libraries",
75
+ "Environment :: Console",
76
+ ]
77
+
43
78
  # REQUIRED runtime deps (mirror requirements.txt). pyte is a hard top-level
44
79
  # import; pywinpty is imported only on the Windows PTY backend path, hence the
45
80
  # environment marker. POSIX uses the stdlib `pty` backend (no extra package).
@@ -48,6 +83,13 @@ dependencies = [
48
83
  "pywinpty>=2.0; platform_system == 'Windows'",
49
84
  ]
50
85
 
86
+ [project.urls]
87
+ Homepage = "https://github.com/dwgx/SmartCLI"
88
+ Repository = "https://github.com/dwgx/SmartCLI"
89
+ Changelog = "https://github.com/dwgx/SmartCLI/blob/main/CHANGELOG.md"
90
+ Issues = "https://github.com/dwgx/SmartCLI/issues"
91
+ PyPI = "https://pypi.org/project/smartcli-toolkit/"
92
+
51
93
  [project.optional-dependencies]
52
94
  # All guarded imports with pure-stdlib fallbacks — none required to run.
53
95
  art = ["pyfiglet>=1.0.0"] # real FIGlet fonts (else built-in block font)
@@ -9,9 +9,13 @@ Quick start::
9
9
 
10
10
  with PtySession(cols=100, rows=30) as sess:
11
11
  sess.start("python")
12
- sess.wait_ready(marker=r">>> $")
12
+ # NOTE: pyte right-pads every screen line with spaces, so an end-anchored
13
+ # marker like r">>> $" never matches. Use an unanchored marker (or r">>> *$"
14
+ # with re.M). wait_ready also races screen-stability, so pass a real marker
15
+ # for a known first prompt rather than relying on STABLE on startup.
16
+ sess.wait_ready(marker=r">>> ")
13
17
  sess.send_line("print('hello')")
14
- reason, snap = sess.wait_ready(marker=r">>> $")
18
+ reason, snap = sess.wait_ready(marker=r">>> ")
15
19
  print(snap.to_text())
16
20
  """
17
21
 
@@ -50,4 +54,4 @@ __all__ = [
50
54
  "KEY_MAP",
51
55
  ]
52
56
 
53
- __version__ = "0.1.0"
57
+ __version__ = "0.1.2"
@@ -75,6 +75,14 @@ class WinptyBackend(PtyBackend):
75
75
  def spawn(self, cmd: Union[str, Sequence[str]], cols: int, rows: int) -> None:
76
76
  import winpty # imported lazily so non-Windows hosts don't require it
77
77
 
78
+ # Reset per-spawn state so a re-used backend (a second spawn on the same
79
+ # instance) does not inherit the previous child's EOF sentinel or latched
80
+ # _eof — otherwise read_nonblocking would stop draining at the stale None
81
+ # and consumers would see premature EOF. A fresh queue drops any leftover.
82
+ self._queue = queue.Queue()
83
+ self._eof = False
84
+ self._reader = None
85
+
78
86
  # winpty.spawn accepts a command string or an argv list; dimensions are
79
87
  # (rows, cols).
80
88
  self._proc = winpty.PtyProcess.spawn(cmd, dimensions=(rows, cols))
@@ -41,6 +41,7 @@ def wait_until_stable(
41
41
  max_wait_ms: int = 8000,
42
42
  grace_ms: int = 40,
43
43
  min_wait_ms: int = 0,
44
+ blank_hash: Optional[int] = None,
44
45
  ) -> bool:
45
46
  """Pump reads until the screen hash is unchanged for ``quiet_ms``.
46
47
 
@@ -71,22 +72,32 @@ def wait_until_stable(
71
72
  deadline = start + (max_wait_ms / 1000.0)
72
73
  last_hash: Optional[int] = None
73
74
  stable_since: Optional[float] = None
75
+ # Readiness gate: never declare stable on a never-painted BLANK screen. Only
76
+ # engages when the caller passes ``blank_hash`` (the construct-time all-blank
77
+ # baseline) AND no output has been seen this wait AND the screen still equals
78
+ # that baseline. Default (blank_hash=None) is byte-for-byte the old behavior,
79
+ # so an already-drawn screen that is genuinely static still settles.
80
+ seen_any = False
74
81
 
75
82
  while True:
76
83
  now = time.monotonic()
77
84
  data = read_fn()
85
+ if data:
86
+ seen_any = True
78
87
  h = get_screen_hash_fn()
79
88
  elapsed = now - start
89
+ blank = (not seen_any and blank_hash is not None and h == blank_hash)
80
90
 
81
91
  if not data and h == last_hash:
82
92
  if stable_since is None:
83
93
  stable_since = now
84
- elif (now - stable_since) >= quiet and elapsed >= min_wait:
94
+ elif (now - stable_since) >= quiet and elapsed >= min_wait and not blank:
85
95
  if grace > 0:
86
96
  time.sleep(grace)
87
97
  tail = read_fn()
88
98
  if tail:
89
99
  # late flush: resume waiting
100
+ seen_any = True
90
101
  stable_since = None
91
102
  last_hash = get_screen_hash_fn()
92
103
  continue
@@ -156,6 +167,7 @@ def wait_ready(
156
167
  min_wait_ms: int = 50,
157
168
  grace_ms: int = 40,
158
169
  flags: int = 0,
170
+ blank_hash: Optional[int] = None,
159
171
  ) -> Tuple[str, object]:
160
172
  """Unified wait: satisfy on ``marker`` OR screen stability, capped by max_wait.
161
173
 
@@ -177,10 +189,16 @@ def wait_ready(
177
189
  deadline = start + (max_wait_ms / 1000.0)
178
190
  last_hash: Optional[int] = None
179
191
  stable_since: Optional[float] = None
192
+ # Readiness gate (see wait_until_stable): a marker match is never gated —
193
+ # only the stability branch refuses to fire on a never-painted blank screen
194
+ # when the caller supplies the blank baseline. Default None = old behavior.
195
+ seen_any = False
180
196
 
181
197
  while True:
182
198
  now = time.monotonic()
183
199
  data = read_fn()
200
+ if data:
201
+ seen_any = True
184
202
  elapsed = now - start
185
203
 
186
204
  # 1) marker wins immediately (respect min_wait)
@@ -189,14 +207,16 @@ def wait_ready(
189
207
 
190
208
  # 2) stability
191
209
  h = get_screen_hash_fn()
210
+ blank = (not seen_any and blank_hash is not None and h == blank_hash)
192
211
  if not data and h == last_hash:
193
212
  if stable_since is None:
194
213
  stable_since = now
195
- elif (now - stable_since) >= quiet and elapsed >= min_wait:
214
+ elif (now - stable_since) >= quiet and elapsed >= min_wait and not blank:
196
215
  if grace > 0:
197
216
  time.sleep(grace)
198
217
  tail = read_fn()
199
218
  if tail:
219
+ seen_any = True
200
220
  stable_since = None
201
221
  last_hash = get_screen_hash_fn()
202
222
  continue
@@ -6,10 +6,12 @@
6
6
 
7
7
  sess = PtySession(cols=100, rows=30)
8
8
  sess.start("python")
9
- sess.wait_ready(marker=r">>> $")
9
+ # Use an unanchored marker: pyte space-pads every line, so r">>> $" never
10
+ # matches (use r">>> " or r">>> *$" with re.M).
11
+ sess.wait_ready(marker=r">>> ")
10
12
  sess.send_text("print('hi')")
11
13
  sess.send_keys(["Enter"])
12
- snap = sess.wait_ready(marker=r">>> $")[1]
14
+ snap = sess.wait_ready(marker=r">>> ")[1]
13
15
  print(snap.to_text())
14
16
  sess.close()
15
17
 
@@ -113,6 +115,10 @@ class PtySession:
113
115
  self.rows = rows
114
116
  self.backend: PtyBackend = backend or get_default_backend()
115
117
  self.model = ScreenModel(cols, rows)
118
+ # Baseline hash of the freshly-constructed (all-blank) screen. Passed to
119
+ # the readiness waits so they never declare STABLE on a never-painted
120
+ # screen during a startup quiet-gap. Recomputed on resize.
121
+ self._blank_hash = self.model.content_hash()
116
122
  self._started = False
117
123
 
118
124
  # -- lifecycle ---------------------------------------------------------
@@ -142,6 +148,9 @@ class PtySession:
142
148
  self.rows = rows
143
149
  self.backend.resize(cols, rows)
144
150
  self.model.resize(cols, rows)
151
+ # Blank baseline for the new dimensions (only used by the readiness gate
152
+ # before any output has been seen).
153
+ self._blank_hash = ScreenModel(cols, rows).content_hash()
145
154
 
146
155
  # -- io ----------------------------------------------------------------
147
156
 
@@ -200,6 +209,7 @@ class PtySession:
200
209
  min_wait_ms=min_wait_ms,
201
210
  grace_ms=grace_ms,
202
211
  flags=flags,
212
+ blank_hash=self._blank_hash,
203
213
  )
204
214
  return reason, snap # type: ignore[return-value]
205
215
 
@@ -220,6 +230,7 @@ class PtySession:
220
230
  max_wait_ms=max_wait_ms,
221
231
  grace_ms=grace_ms,
222
232
  min_wait_ms=min_wait_ms,
233
+ blank_hash=self._blank_hash,
223
234
  )
224
235
 
225
236
  def wait_for(
@@ -1,8 +1,28 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartcli-toolkit
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
5
+ Author: dwgx
5
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/dwgx/SmartCLI
8
+ Project-URL: Repository, https://github.com/dwgx/SmartCLI
9
+ Project-URL: Changelog, https://github.com/dwgx/SmartCLI/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/dwgx/SmartCLI/issues
11
+ Project-URL: PyPI, https://pypi.org/project/smartcli-toolkit/
12
+ Keywords: terminal,tui,pty,ansi,cli,ascii-art,terminal-effects,claude-skill,pyte,conpty
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Terminals
24
+ Classifier: Topic :: Software Development :: Libraries
25
+ Classifier: Environment :: Console
6
26
  Requires-Python: >=3.9
7
27
  Description-Content-Type: text/markdown
8
28
  License-File: LICENSE
@@ -66,7 +86,7 @@ works today from a clean clone.
66
86
  **Primary — reproduce the full dev environment** (recommended):
67
87
 
68
88
  ```bash
69
- git clone <repo-url> SmartCLI
89
+ git clone https://github.com/dwgx/SmartCLI SmartCLI
70
90
  cd SmartCLI
71
91
  python -m pip install -r requirements.txt
72
92
  ```
@@ -11,4 +11,7 @@ smartcli_toolkit.egg-info/PKG-INFO
11
11
  smartcli_toolkit.egg-info/SOURCES.txt
12
12
  smartcli_toolkit.egg-info/dependency_links.txt
13
13
  smartcli_toolkit.egg-info/requires.txt
14
- smartcli_toolkit.egg-info/top_level.txt
14
+ smartcli_toolkit.egg-info/top_level.txt
15
+ tests/test_degenerate_inputs.py
16
+ tests/test_fx_contract.py
17
+ tests/test_readiness.py
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python
2
+ """test_degenerate_inputs.py — regression locks for the degenerate-input crashes
3
+ found by the fx/tui-ui defect review (all in skill code, not smartcli_core).
4
+
5
+ Each check reproduces an input that used to raise (ZeroDivisionError / IndexError
6
+ / ValueError) and asserts it now degrades gracefully instead of crashing. Fast,
7
+ no PTY, no animation loop.
8
+
9
+ Run: python tests/test_degenerate_inputs.py (exit 0 = pass)
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ _ROOT = Path(__file__).resolve().parents[1]
18
+ sys.path.insert(0, str(_ROOT / "skills" / "tui-ui"))
19
+ sys.path.insert(0, str(_ROOT / "skills" / "cmd-art"))
20
+
21
+ _fails = []
22
+
23
+
24
+ def check_no_raise(fn, name):
25
+ try:
26
+ fn()
27
+ print(f"[PASS] {name}")
28
+ except Exception as e: # noqa: BLE001 - we are asserting NO exception
29
+ print(f"[FAIL] {name} raised {type(e).__name__}: {e}")
30
+ _fails.append(name)
31
+
32
+
33
+ def check(cond, name, detail=""):
34
+ print(f"{'[PASS]' if cond else '[FAIL]'} {name} {detail}")
35
+ if not cond:
36
+ _fails.append(name)
37
+
38
+
39
+ def main() -> int:
40
+ from ui.field import Ripple
41
+ from ui.widgets_ext.slider_track import SliderTrack
42
+ from ui.widgets_ext.braille_chart import BrailleChart
43
+ from fx.base import Param
44
+
45
+ print("=" * 60)
46
+ print("degenerate-input regression locks")
47
+ print("=" * 60)
48
+
49
+ # field.Ripple degenerate params (were ZeroDivisionError / IndexError)
50
+ check_no_raise(lambda: Ripple(origin=(0, 0), wavelength=0.0, travel=10.0).sample(1, 1),
51
+ "Ripple wavelength=0 does not divide-by-zero")
52
+ check_no_raise(lambda: Ripple(origin=(0, 0), wavelength=20.0, travel=10.0,
53
+ falloff_radius=0.0).sample(1, 1),
54
+ "Ripple falloff_radius=0 does not divide-by-zero")
55
+ check_no_raise(lambda: Ripple(origin=(0, 0), wavelength=20.0, travel=10.0,
56
+ palette=[]).sample(0, 0),
57
+ "Ripple empty palette does not IndexError")
58
+
59
+ # SliderTrack empty positions list (was ValueError max()/IndexError)
60
+ check_no_raise(lambda: SliderTrack(["a", "b"], positions=[]).measure(20, 3),
61
+ "SliderTrack positions=[] measure does not crash")
62
+ check_no_raise(lambda: SliderTrack(["a", "b"], positions=[]).render(20, 3),
63
+ "SliderTrack positions=[] render does not crash")
64
+
65
+ # BrailleChart NaN in series (was ValueError NaN->int)
66
+ check_no_raise(lambda: BrailleChart([float("nan"), 1, 2]).render(10, 3),
67
+ "BrailleChart NaN in series does not crash")
68
+
69
+ # Param int coercion: zero-padded decimals work; hex kept; bad input clean err
70
+ p = Param("count", "int")
71
+ check(p.coerce("08") == 8, "Param int '08' -> 8 (not base-0 ValueError)", f"={p.coerce('08')}")
72
+ check(p.coerce("010") == 10, "Param int '010' -> 10", f"={p.coerce('010')}")
73
+ check(p.coerce("0x10") == 16, "Param int '0x10' -> 16 (hex preserved)", f"={p.coerce('0x10')}")
74
+ # sign symmetry: +0x/-0x both honored (a prior fix narrowed this by omitting +)
75
+ check(p.coerce("+0x10") == 16, "Param int '+0x10' -> 16 (plus-signed hex)", f"={p.coerce('+0x10')}")
76
+ check(p.coerce("-0x10") == -16, "Param int '-0x10' -> -16 (minus-signed hex)", f"={p.coerce('-0x10')}")
77
+ try:
78
+ p.coerce("abc")
79
+ check(False, "Param int 'abc' raises ValueError", "no error raised")
80
+ except ValueError as e:
81
+ check("count" in str(e) and "abc" in str(e),
82
+ "Param int 'abc' -> clean ValueError naming param+value", f"{e}")
83
+
84
+ print("-" * 60)
85
+ if _fails:
86
+ print(f"FAIL: {len(_fails)} check(s) failed: {_fails}")
87
+ return 1
88
+ print("PASS: all degenerate-input regression locks hold")
89
+ return 0
90
+
91
+
92
+ if __name__ == "__main__":
93
+ sys.exit(main())
@@ -0,0 +1,267 @@
1
+ """Frame-contract test for the cmd-art fx framework (deterministic, no TTY).
2
+
3
+ Every :class:`fx.base.Effect` is a PURE frame producer: ``render(ctx) -> str``
4
+ must return a frame of EXACTLY ``ctx.height`` rows, and no row may be wider than
5
+ ``ctx.width`` display cells (a row wider than the frame is the "overflows at N
6
+ cols" defect the terminal would wrap; a frame with the wrong row count is
7
+ under/overfill). "Field"-style effects (fire, plasma, donut, ...) fill EVERY
8
+ cell, so for those the width must equal ``ctx.width`` exactly.
9
+
10
+ The live harness (verify_fx.py) proves effects RUN and keep the alt-screen /
11
+ truecolor invariants, and probe.py checks row count + an *upper* width bound.
12
+ Neither asserts the EXACT per-line cell width for the solid-fill effects, nor
13
+ sweeps degenerate ctx sizes (tiny 1x1/2x2). This test closes that gap by
14
+ rendering each effect at several sizes / frames and asserting the contract with
15
+ the repo's AUTHORITATIVE display-width function (ui.core.width -- wcwidth-backed,
16
+ ANSI-stripping, CJK/emoji aware -- the same measure the engine and pyte use).
17
+
18
+ Contracts asserted (HARD -> non-zero exit):
19
+ C1 row count == ctx.height for ALL effects at ALL sizes.
20
+ C2 no row wider than ctx.width for ALL effects at REALISTIC sizes.
21
+ C3 every row width == ctx.width for SOLID (full-fill) effects, at
22
+ realistic + narrow + tiny sizes.
23
+ C4 render() never raises for ALL effects at tiny (1x1/2x2) sizes.
24
+ C5 no UNEXPECTED narrow overflow at (20,8): only the documented
25
+ allowlist below may exceed width.
26
+
27
+ SOFT (reported, never fails a correct shipped build):
28
+ * known narrow-width overflow of the sparse text effects (see allowlist),
29
+ * width defects at the degenerate tiny sizes (1x1/2x2), which some effects
30
+ legitimately cannot honour (e.g. image2ascii's 2-cell half-block glyph).
31
+
32
+ Usage: python tests/test_fx_contract.py [name ...] (default: everything)
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ ROOT = Path(__file__).resolve().parents[1]
40
+ sys.path.insert(0, str(ROOT))
41
+ sys.path.insert(0, str(ROOT / "skills" / "cmd-art"))
42
+ sys.path.insert(0, str(ROOT / "skills" / "tui-ui"))
43
+
44
+ import fx.registry as registry # noqa: E402
45
+ from fx.base import FrameCtx # noqa: E402
46
+ from fx.theme import get_theme # noqa: E402
47
+
48
+ # ---- authoritative display-cell width (same fn the engine + pyte agree on) ---
49
+ # Reuse ui.core.width: strips ANSI/SGR then sums per-codepoint wcwidth (wide
50
+ # glyphs = 2, combining/ZWJ = 0). Falling back to a matching local stripper only
51
+ # if the tui-ui skill is somehow unavailable keeps this test self-contained.
52
+ try:
53
+ from ui.core import width as visible_width # type: ignore
54
+ except Exception: # pragma: no cover - tui-ui should always be importable
55
+ import re
56
+ import unicodedata
57
+
58
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
59
+
60
+ def visible_width(s: str) -> int: # type: ignore
61
+ s = _ANSI_RE.sub("", s)
62
+ w = 0
63
+ for ch in s:
64
+ o = ord(ch)
65
+ if o == 0 or unicodedata.combining(ch) or o in (0x200D, 0xFE0E, 0xFE0F):
66
+ continue
67
+ if o < 32 or 0x7F <= o < 0xA0:
68
+ continue
69
+ w += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
70
+ return w
71
+
72
+
73
+ # --------------------------------------------------------------------------
74
+ # Test matrix
75
+ # --------------------------------------------------------------------------
76
+ # Realistic frame sizes: the contract is HARD here.
77
+ REALISTIC = [(80, 24), (120, 40), (40, 12)]
78
+ # Narrow but real terminal: HARD row-count + curated overflow allowlist (C5).
79
+ NARROW = [(20, 8)]
80
+ # Degenerate/tiny: HARD row-count + no-crash; width is SOFT (reported only).
81
+ TINY = [(2, 2), (1, 1)]
82
+
83
+ # Effects whose rows are known to exceed ctx.width at the (20,8) narrow size,
84
+ # because they emit text without clipping to ctx.width. This is a genuine
85
+ # frame-contract weakness in the shipped effect (documented as a FINDING in the
86
+ # report), NOT a test-measurement artifact -- the raw text really is wider than
87
+ # the frame. Listed here so the test still PASSES on today's shipped build while
88
+ # a hard gate guards against any *new* effect regressing the same way.
89
+ KNOWN_NARROW_OVERFLOW = {"decrypt", "typewriter"}
90
+
91
+ results: list[tuple[str, bool, str]] = []
92
+ findings: list[str] = []
93
+
94
+
95
+ def record(name: str, ok: bool, detail: str = "") -> None:
96
+ results.append((name, ok, detail))
97
+ print(f"{'PASS' if ok else 'FAIL'} {name}" + (f" -- {detail}" if detail else ""))
98
+
99
+
100
+ def frames_for(cls) -> list[tuple[float, int]]:
101
+ """(t, frame_index) samples to render. Animated effects get a few frames to
102
+ catch frame-dependent raggedness; static effects only ever draw one frame."""
103
+ if cls.is_animated(cls.param_defaults()):
104
+ return [(0.0, 0), (0.7, 5), (3.1, 93)]
105
+ return [(0.7, 0)]
106
+
107
+
108
+ def render(cls, w: int, h: int, t: float, fi: int) -> str:
109
+ """Build the ctx the CLI would and render ONE frame (mirrors probe.py /
110
+ fx.core.render_once: fresh instance, setup(), render(), teardown())."""
111
+ eff = cls()
112
+ eff.setup()
113
+ try:
114
+ ctx = FrameCtx(t=t, frame_index=fi, width=w, height=h,
115
+ theme=get_theme(cls.preferred_theme),
116
+ params=cls.param_defaults())
117
+ return eff.render(ctx)
118
+ finally:
119
+ try:
120
+ eff.teardown()
121
+ except Exception:
122
+ pass
123
+
124
+
125
+ def _rows(frame: str) -> list[str]:
126
+ """Split a frame into its rows -- the AUTHORITATIVE row measure, identical to
127
+ probe.py's ``frame.split("\\n")``. The contract is "ctx.height rows joined by
128
+ newlines, NO trailing newline" (see fx.base), so a plain split yields exactly
129
+ ctx.height rows. We deliberately do NOT strip a trailing newline: sparse
130
+ effects pad with empty rows, so a frame can legitimately END in "\\n" where
131
+ that newline is the separator before a final empty row -- stripping it would
132
+ drop a real row and under-count the frame."""
133
+ return frame.split("\n")
134
+
135
+
136
+ def _is_solid(cls) -> bool:
137
+ """A SOLID (full-fill) effect writes every cell, so every row is EXACTLY
138
+ ctx.width. Classified empirically at the canonical 80x24 across its frames:
139
+ if no row deviates from 80, it is a field/particle effect and must honour
140
+ the exact-width contract everywhere. Sparse text/banner/image effects (which
141
+ legitimately leave blank cells) are excluded from the exact-width gate."""
142
+ for (t, fi) in frames_for(cls):
143
+ for ln in _rows(render(cls, 80, 24, t, fi)):
144
+ if visible_width(ln) != 80:
145
+ return False
146
+ return True
147
+
148
+
149
+ # --------------------------------------------------------------------------
150
+ # Checks
151
+ # --------------------------------------------------------------------------
152
+ def check_rowcount(cls) -> None:
153
+ """C1: every rendered frame has EXACTLY ctx.height rows, at ALL sizes."""
154
+ for (w, h) in REALISTIC + NARROW + TINY:
155
+ for (t, fi) in frames_for(cls):
156
+ rows = _rows(render(cls, w, h, t, fi))
157
+ if len(rows) != h:
158
+ record(f"rowcount {cls.name}", False,
159
+ f"{w}x{h} t={t}: {len(rows)} rows != {h}")
160
+ return
161
+ record(f"rowcount {cls.name}", True, "exact rows at every size")
162
+
163
+
164
+ def check_no_overflow(cls) -> None:
165
+ """C2: no row wider than ctx.width at REALISTIC sizes (all effects)."""
166
+ for (w, h) in REALISTIC:
167
+ for (t, fi) in frames_for(cls):
168
+ for i, ln in enumerate(_rows(render(cls, w, h, t, fi))):
169
+ cw = visible_width(ln)
170
+ if cw > w:
171
+ record(f"no-overflow {cls.name}", False,
172
+ f"{w}x{h} t={t} row {i}: width {cw} > {w}")
173
+ return
174
+ record(f"no-overflow {cls.name}", True, "no row exceeds width")
175
+
176
+
177
+ def check_exact_width(cls) -> None:
178
+ """C3: SOLID effects fill every cell -> width == ctx.width, at realistic +
179
+ narrow + tiny sizes. Sparse effects are skipped (SOFT-reported elsewhere)."""
180
+ if not _is_solid(cls):
181
+ record(f"exact-width {cls.name}", True, "sparse effect (exact-width n/a)")
182
+ return
183
+ for (w, h) in REALISTIC + NARROW + TINY:
184
+ for (t, fi) in frames_for(cls):
185
+ for i, ln in enumerate(_rows(render(cls, w, h, t, fi))):
186
+ cw = visible_width(ln)
187
+ if cw != w:
188
+ record(f"exact-width {cls.name}", False,
189
+ f"{w}x{h} t={t} row {i}: width {cw} != {w}")
190
+ return
191
+ record(f"exact-width {cls.name}", True, "every row == width (solid fill)")
192
+
193
+
194
+ def check_narrow_allowlist(cls) -> None:
195
+ """C5: at the narrow (20,8) size, overflow is a HARD failure UNLESS the
196
+ effect is on the documented KNOWN_NARROW_OVERFLOW allowlist. This is the
197
+ regression gate that would catch a NEW effect emitting unclipped rows."""
198
+ for (w, h) in NARROW:
199
+ for (t, fi) in frames_for(cls):
200
+ for i, ln in enumerate(_rows(render(cls, w, h, t, fi))):
201
+ cw = visible_width(ln)
202
+ if cw > w:
203
+ if cls.name in KNOWN_NARROW_OVERFLOW:
204
+ findings.append(
205
+ f"narrow overflow (known) {cls.name} {w}x{h} "
206
+ f"t={t} row {i}: width {cw} > {w}")
207
+ record(f"narrow {cls.name}", True,
208
+ f"known overflow tolerated (row {i} width {cw}>{w})")
209
+ return
210
+ record(f"narrow {cls.name}", False,
211
+ f"UNEXPECTED overflow {w}x{h} t={t} row {i}: "
212
+ f"width {cw} > {w} (not in allowlist)")
213
+ return
214
+ if cls.name in KNOWN_NARROW_OVERFLOW:
215
+ # allowlisted but no longer overflowing -> the effect was fixed; the
216
+ # allowlist is now stale (report so it can be tightened).
217
+ findings.append(f"allowlist stale: {cls.name} no longer overflows at 20x8")
218
+ record(f"narrow {cls.name}", True, "fits 20x8 (or known)")
219
+
220
+
221
+ def check_tiny_no_crash(cls) -> None:
222
+ """C4: render() must not raise at degenerate 1x1 / 2x2 sizes. Width defects
223
+ at these sizes are SOFT (some effects legitimately can't honour 1x1)."""
224
+ for (w, h) in TINY:
225
+ for (t, fi) in frames_for(cls):
226
+ try:
227
+ rows = _rows(render(cls, w, h, t, fi))
228
+ except Exception as exc: # a crash IS a hard failure (finding)
229
+ record(f"tiny-safe {cls.name}", False,
230
+ f"raised at {w}x{h} t={t}: {exc!r}")
231
+ return
232
+ for i, ln in enumerate(rows):
233
+ cw = visible_width(ln)
234
+ if cw != w:
235
+ findings.append(
236
+ f"tiny width (soft) {cls.name} {w}x{h} row {i}: "
237
+ f"width {cw} != {w}")
238
+ record(f"tiny-safe {cls.name}", True, "no crash at 1x1/2x2")
239
+
240
+
241
+ def main(argv: list[str]) -> int:
242
+ registry.load_all()
243
+ only = set(argv)
244
+ effects = [c for c in registry.all_effects() if not only or c.name in only]
245
+ sizes = REALISTIC + NARROW + TINY
246
+ for cls in effects:
247
+ check_rowcount(cls)
248
+ check_no_overflow(cls)
249
+ check_exact_width(cls)
250
+ check_narrow_allowlist(cls)
251
+ check_tiny_no_crash(cls)
252
+
253
+ failed = [r for r in results if not r[1]]
254
+ if findings:
255
+ print("\nFINDINGS (soft / documented):")
256
+ for f in findings:
257
+ print(" - " + f)
258
+ checks_per = 5
259
+ print(f"\n{len(effects)} effects x {len(sizes)} sizes "
260
+ f"({checks_per} contracts each)")
261
+ print(f"{len(results) - len(failed)}/{len(results)} checks passed")
262
+ return 1 if failed else 0
263
+
264
+
265
+ if __name__ == "__main__":
266
+ sys.exit(main(sys.argv[1:]))
267
+
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env python
2
+ """test_readiness.py — deterministic unit tests for smartcli_core.readiness.
3
+
4
+ These cover the failure/edge paths that historically broke and are otherwise only
5
+ reachable through flaky real-PTY timing:
6
+ * TIMEOUT — a screen that never settles / a marker that never matches.
7
+ * STABLE — the content hash repeats long enough to declare stability.
8
+ * MARKER — an expected regex is found (beats stability).
9
+ * late-flush — data arrives after the grace sleep → the wait resumes instead of
10
+ declaring done on a half-drawn screen (the ConPTY quiet-gap bug).
11
+ * min_wait — stability/marker is NOT declared before the minimum elapsed time.
12
+
13
+ Determinism: we monkeypatch ``readiness.time.monotonic`` / ``readiness.time.sleep``
14
+ with a virtual clock so ``sleep(x)`` advances virtual time by ``x`` and timeouts
15
+ fire instantly with no real waiting. This patches only the clock BOUNDARY — the
16
+ real decision logic in readiness.py runs unchanged (HARD-LESSONS rule 5: a test
17
+ double for the clock/IO edge, never a patch that hides a bug).
18
+
19
+ Run: python tests/test_readiness.py (exit 0 = pass, 1 = fail)
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ # Portable: make the repo root importable regardless of cwd.
28
+ _ROOT = Path(__file__).resolve().parents[1]
29
+ if str(_ROOT) not in sys.path:
30
+ sys.path.insert(0, str(_ROOT))
31
+
32
+ from smartcli_core import readiness # noqa: E402
33
+
34
+
35
+ class VirtualClock:
36
+ """A fake monotonic clock: sleep() advances virtual time; no real waiting."""
37
+
38
+ def __init__(self) -> None:
39
+ self.t = 0.0
40
+ self.last_sleep = None # duration of the most recent sleep() (seconds)
41
+
42
+ def monotonic(self) -> float:
43
+ return self.t
44
+
45
+ def sleep(self, seconds: float) -> None:
46
+ # Advance virtual time; also nudge forward on zero-sleep to guarantee
47
+ # progress so no loop can spin forever in a test.
48
+ self.last_sleep = seconds
49
+ self.t += seconds if seconds > 0 else 0.001
50
+
51
+
52
+ def _install_clock():
53
+ clk = VirtualClock()
54
+ readiness.time.monotonic = clk.monotonic # type: ignore[assignment]
55
+ readiness.time.sleep = clk.sleep # type: ignore[assignment]
56
+ return clk
57
+
58
+
59
+ # scripted-callable helpers ------------------------------------------------
60
+
61
+ def seq_reader(batches):
62
+ """read_fn that returns each batch in turn, then b'' forever."""
63
+ it = iter(batches)
64
+
65
+ def _read():
66
+ return next(it, b"")
67
+
68
+ return _read
69
+
70
+
71
+ def seq_hashes(values):
72
+ """hash_fn that returns each value in turn, then repeats the last forever."""
73
+ state = {"i": 0, "vals": list(values)}
74
+
75
+ def _hash():
76
+ i = state["i"]
77
+ vals = state["vals"]
78
+ v = vals[i] if i < len(vals) else vals[-1]
79
+ if i < len(vals) - 1:
80
+ state["i"] = i + 1
81
+ return v
82
+
83
+ return _hash
84
+
85
+
86
+ def seq_text(values):
87
+ """text_fn that returns each value in turn, then repeats the last forever."""
88
+ state = {"i": 0, "vals": list(values)}
89
+
90
+ def _text():
91
+ i = state["i"]
92
+ vals = state["vals"]
93
+ v = vals[i] if i < len(vals) else vals[-1]
94
+ if i < len(vals) - 1:
95
+ state["i"] = i + 1
96
+ return v
97
+
98
+ return _text
99
+
100
+
101
+ _fails = []
102
+
103
+
104
+ def check(cond, name, detail=""):
105
+ tag = "[PASS]" if cond else "[FAIL]"
106
+ print(f"{tag} {name} {detail}")
107
+ if not cond:
108
+ _fails.append(name)
109
+
110
+
111
+ def test_stable_reached():
112
+ """Hash unchanged + no new bytes for quiet_ms → wait_until_stable True, fast."""
113
+ _install_clock()
114
+ ok = readiness.wait_until_stable(
115
+ read_fn=seq_reader([]), # no bytes ever
116
+ get_screen_hash_fn=seq_hashes([42]), # constant hash
117
+ quiet_ms=200, poll_ms=30, max_wait_ms=8000, grace_ms=0, min_wait_ms=0,
118
+ )
119
+ check(ok is True, "wait_until_stable: constant screen settles", f"ret={ok}")
120
+
121
+
122
+ def test_stable_timeout():
123
+ """Hash never stops changing → returns False (TIMEOUT) within budget, no hang."""
124
+ clk = _install_clock()
125
+ # hash flips forever: 0,1,0,1,... → never quiet
126
+ flip = {"n": 0}
127
+
128
+ def churn_hash():
129
+ flip["n"] ^= 1
130
+ return flip["n"]
131
+
132
+ ok = readiness.wait_until_stable(
133
+ read_fn=seq_reader([]),
134
+ get_screen_hash_fn=churn_hash,
135
+ quiet_ms=200, poll_ms=30, max_wait_ms=1000, grace_ms=0, min_wait_ms=0,
136
+ )
137
+ check(ok is False, "wait_until_stable: churning screen hits TIMEOUT", f"ret={ok}")
138
+ check(clk.t >= 1.0, "wait_until_stable: respected max_wait ceiling", f"virt_t={clk.t:.2f}s")
139
+
140
+
141
+ def test_late_flush_resume():
142
+ """A late flush delivered ONLY on the post-grace `tail = read_fn()` drain must
143
+ resume the wait — not let it return True on the half-drawn (pre-flush) screen.
144
+
145
+ This targets readiness.py lines 87-93 specifically. The flush is delivered on
146
+ the exact read that immediately follows a `grace` sleep (grace=40ms, distinct
147
+ from the 30ms poll sleep), so it can only land on the tail drain — the branch
148
+ the earlier version of this test never exercised (mutation-proven false-green).
149
+
150
+ Proof of resume: (a) the flush was delivered on a tail drain, and (b) the wait
151
+ kept polling AFTER the flush (reads continued) instead of returning immediately.
152
+ Under a mutant that drops the resume branch, the function returns True right
153
+ after the flush drain → no further reads → this test FAILS.
154
+ """
155
+ clk = _install_clock()
156
+ reads = {"n": 0}
157
+ tail_flush = {"delivered_at": None}
158
+ reads_after_flush = {"n": 0}
159
+
160
+ def read_fn():
161
+ reads["n"] += 1
162
+ if tail_flush["delivered_at"] is not None:
163
+ reads_after_flush["n"] += 1
164
+ return b"" # quiet again so it can finally re-settle
165
+ # The tail drain is the read whose immediately-preceding sleep was `grace`
166
+ # (40ms), not a `poll` (30ms). Deliver the flush there, exactly once.
167
+ if clk.last_sleep is not None and abs(clk.last_sleep - 0.040) < 1e-9:
168
+ tail_flush["delivered_at"] = reads["n"]
169
+ return b"late output"
170
+ return b""
171
+
172
+ def hash_fn():
173
+ # constant until the flush lands (so quiet accumulates and we reach the
174
+ # tail drain), then a new value that itself settles.
175
+ return 2 if tail_flush["delivered_at"] is not None else 1
176
+
177
+ ok = readiness.wait_until_stable(
178
+ read_fn=read_fn,
179
+ get_screen_hash_fn=hash_fn,
180
+ quiet_ms=60, poll_ms=30, max_wait_ms=5000, grace_ms=40, min_wait_ms=0,
181
+ )
182
+ check(ok is True, "wait_until_stable: resumes after tail-drain flush then settles", f"ret={ok}")
183
+ check(tail_flush["delivered_at"] is not None,
184
+ "wait_until_stable: flush landed on the post-grace tail drain",
185
+ f"at read #{tail_flush['delivered_at']}")
186
+ # The crux: after the tail-drain flush, the loop must have RESUMED (more reads),
187
+ # not returned. A mutant dropping the resume branch returns immediately → 0.
188
+ check(reads_after_flush["n"] > 0,
189
+ "wait_until_stable: kept polling after the tail-drain flush (resumed, not returned)",
190
+ f"reads_after_flush={reads_after_flush['n']}")
191
+
192
+
193
+ def test_min_wait_guard():
194
+ """Stability must NOT be declared before min_wait_ms even if screen is quiet."""
195
+ clk = _install_clock()
196
+ ok = readiness.wait_until_stable(
197
+ read_fn=seq_reader([]),
198
+ get_screen_hash_fn=seq_hashes([7]), # instantly "quiet"
199
+ quiet_ms=50, poll_ms=30, max_wait_ms=8000, grace_ms=0, min_wait_ms=500,
200
+ )
201
+ check(ok is True, "wait_until_stable: settles after min_wait", f"ret={ok}")
202
+ check(clk.t >= 0.5, "wait_until_stable: honored min_wait_ms floor", f"virt_t={clk.t:.3f}s (>=0.5)")
203
+
204
+
205
+ def test_regex_match():
206
+ """wait_for_regex finds the marker and returns (True, snapshot)."""
207
+ _install_clock()
208
+ matched, snap = readiness.wait_for_regex(
209
+ read_fn=seq_reader([b">>> "]),
210
+ get_text_fn=seq_text(["loading...", "loading...", ">>> ready"]),
211
+ get_snapshot_fn=lambda: "SNAP",
212
+ pattern=r">>> ", timeout_ms=5000, poll_ms=30, min_wait_ms=0,
213
+ )
214
+ check(matched is True, "wait_for_regex: finds marker", f"matched={matched}")
215
+ check(snap == "SNAP", "wait_for_regex: returns snapshot on match", f"snap={snap}")
216
+
217
+
218
+ def test_regex_timeout_returns_snapshot():
219
+ """wait_for_regex on a never-matching pattern returns (False, snapshot)."""
220
+ clk = _install_clock()
221
+ matched, snap = readiness.wait_for_regex(
222
+ read_fn=seq_reader([]),
223
+ get_text_fn=seq_text(["nothing here"]),
224
+ get_snapshot_fn=lambda: "LAST",
225
+ pattern=r"WILL_NEVER_APPEAR", timeout_ms=800, poll_ms=30, min_wait_ms=0,
226
+ )
227
+ check(matched is False, "wait_for_regex: TIMEOUT when no match", f"matched={matched}")
228
+ check(snap == "LAST", "wait_for_regex: still returns last snapshot on timeout", f"snap={snap}")
229
+ check(clk.t >= 0.8, "wait_for_regex: respected timeout ceiling", f"virt_t={clk.t:.2f}s")
230
+
231
+
232
+ def test_regex_min_wait():
233
+ """A match present from t=0 must be ignored until min_wait_ms elapses."""
234
+ clk = _install_clock()
235
+ matched, _ = readiness.wait_for_regex(
236
+ read_fn=seq_reader([]),
237
+ get_text_fn=seq_text([">>> "]), # matches immediately
238
+ get_snapshot_fn=lambda: None,
239
+ pattern=r">>> ", timeout_ms=5000, poll_ms=30, min_wait_ms=300,
240
+ )
241
+ check(matched is True, "wait_for_regex: eventually matches past min_wait", f"matched={matched}")
242
+ check(clk.t >= 0.3, "wait_for_regex: did not match before min_wait", f"virt_t={clk.t:.3f}s (>=0.3)")
243
+
244
+
245
+ def test_wait_ready_marker_beats_stability():
246
+ """wait_ready returns MARKER when the regex hits (even amid churn)."""
247
+ _install_clock()
248
+ # screen keeps changing (never stable) but marker appears on 3rd text sample
249
+ reason, snap = readiness.wait_ready(
250
+ read_fn=seq_reader([b"a", b"b", b"c"]),
251
+ get_screen_hash_fn=seq_hashes([1, 2, 3, 4, 5]), # churning
252
+ get_text_fn=seq_text(["...", "...", "DONE!"]),
253
+ get_snapshot_fn=lambda: "S",
254
+ marker=r"DONE!", quiet_ms=200, poll_ms=30, max_wait_ms=5000, min_wait_ms=0,
255
+ )
256
+ check(reason == "MARKER", "wait_ready: marker beats a churning screen", f"reason={reason}")
257
+
258
+
259
+ def test_wait_ready_stable():
260
+ """wait_ready returns STABLE when no marker and screen settles."""
261
+ _install_clock()
262
+ reason, _ = readiness.wait_ready(
263
+ read_fn=seq_reader([]),
264
+ get_screen_hash_fn=seq_hashes([9]),
265
+ get_text_fn=seq_text(["idle"]),
266
+ get_snapshot_fn=lambda: "S",
267
+ marker=None, quiet_ms=100, poll_ms=30, max_wait_ms=5000, min_wait_ms=0, grace_ms=0,
268
+ )
269
+ check(reason == "STABLE", "wait_ready: settles to STABLE with no marker", f"reason={reason}")
270
+
271
+
272
+ def test_wait_ready_stable_timing_floor():
273
+ """wait_ready must NOT declare STABLE before quiet_ms + min_wait_ms elapse.
274
+
275
+ Mirrors test_min_wait_guard but for wait_ready's stability branch. Asserts a
276
+ virtual-time floor, so a mutant that declares STABLE on the first quiet poll
277
+ (ignoring quiet_ms/min_wait_ms) is caught (the false-green M7 gap).
278
+ """
279
+ clk = _install_clock()
280
+ reason, _ = readiness.wait_ready(
281
+ read_fn=seq_reader([]), # instantly quiet
282
+ get_screen_hash_fn=seq_hashes([5]), # constant hash
283
+ get_text_fn=seq_text(["idle"]),
284
+ get_snapshot_fn=lambda: "S",
285
+ marker=None, quiet_ms=200, poll_ms=30, max_wait_ms=8000,
286
+ min_wait_ms=300, grace_ms=0,
287
+ )
288
+ check(reason == "STABLE", "wait_ready: eventually STABLE past the floor", f"reason={reason}")
289
+ # Must have waited at least min_wait (0.3s); a no-quiet mutant returns near t=0.
290
+ check(clk.t >= 0.3, "wait_ready: honored quiet_ms + min_wait_ms floor before STABLE",
291
+ f"virt_t={clk.t:.3f}s (>=0.3)")
292
+
293
+
294
+ def test_blank_gate_refuses_stable_on_never_painted_screen():
295
+ """With blank_hash set, a never-painted blank screen must NOT declare STABLE.
296
+
297
+ Regression lock for core bug #1: the readiness gate. read_fn never yields
298
+ bytes and the hash stays at the blank baseline → stability is withheld →
299
+ TIMEOUT, not a false STABLE on a screen the program hasn't drawn yet.
300
+ """
301
+ BLANK = 1000
302
+ # wait_until_stable form
303
+ clk = _install_clock()
304
+ ok = readiness.wait_until_stable(
305
+ read_fn=seq_reader([]), get_screen_hash_fn=seq_hashes([BLANK]),
306
+ quiet_ms=200, poll_ms=30, max_wait_ms=1000, grace_ms=40, min_wait_ms=50,
307
+ blank_hash=BLANK,
308
+ )
309
+ check(ok is False, "wait_until_stable: blank_hash gate withholds stable on blank screen", f"ret={ok}")
310
+ # wait_ready form
311
+ _install_clock()
312
+ reason, _ = readiness.wait_ready(
313
+ read_fn=seq_reader([]), get_screen_hash_fn=seq_hashes([BLANK]),
314
+ get_text_fn=seq_text([""]), get_snapshot_fn=lambda: "B",
315
+ marker=r">>> ", quiet_ms=200, poll_ms=30, max_wait_ms=1000,
316
+ min_wait_ms=50, grace_ms=40, blank_hash=BLANK,
317
+ )
318
+ check(reason == "TIMEOUT", "wait_ready: blank_hash gate → TIMEOUT not false STABLE", f"reason={reason}")
319
+
320
+
321
+ def test_blank_gate_does_not_harm_drawn_static_screen():
322
+ """The gate must NOT harm a legitimately-drawn, static screen (hash != blank).
323
+
324
+ Regression lock: the naive first attempt broke this exact case. A drawn UI
325
+ whose hash differs from the blank baseline and then holds steady must still
326
+ settle to STABLE even with blank_hash supplied.
327
+ """
328
+ _install_clock()
329
+ reason, _ = readiness.wait_ready(
330
+ read_fn=seq_reader([]), get_screen_hash_fn=seq_hashes([5555]), # != blank
331
+ get_text_fn=seq_text(["drawn UI"]), get_snapshot_fn=lambda: "S",
332
+ marker=None, quiet_ms=100, poll_ms=30, max_wait_ms=5000,
333
+ min_wait_ms=0, grace_ms=0, blank_hash=1000,
334
+ )
335
+ check(reason == "STABLE", "wait_ready: drawn static screen still settles under gate", f"reason={reason}")
336
+ # And once output has been seen, a return to the blank hash still settles.
337
+ _install_clock()
338
+ reads = iter([b"data", b"", b"", b"", b"", b"", b""])
339
+ reason2, _ = readiness.wait_ready(
340
+ read_fn=lambda: next(reads, b""), get_screen_hash_fn=seq_hashes([1000]),
341
+ get_text_fn=seq_text([""]), get_snapshot_fn=lambda: "S",
342
+ marker=None, quiet_ms=100, poll_ms=30, max_wait_ms=5000,
343
+ min_wait_ms=0, grace_ms=0, blank_hash=1000,
344
+ )
345
+ check(reason2 == "STABLE", "wait_ready: seen_any overrides blank gate", f"reason={reason2}")
346
+
347
+
348
+ def test_wait_ready_timeout():
349
+ """wait_ready returns TIMEOUT when marker never matches and screen churns."""
350
+ clk = _install_clock()
351
+ flip = {"n": 0}
352
+
353
+ def churn_hash():
354
+ flip["n"] ^= 1
355
+ return flip["n"]
356
+
357
+ reason, snap = readiness.wait_ready(
358
+ read_fn=seq_reader([b"x", b"y", b"z"] * 100), # bytes keep coming
359
+ get_screen_hash_fn=churn_hash,
360
+ get_text_fn=seq_text(["busy"]),
361
+ get_snapshot_fn=lambda: "LASTSNAP",
362
+ marker=r"NOPE", quiet_ms=200, poll_ms=30, max_wait_ms=1000, min_wait_ms=0,
363
+ )
364
+ check(reason == "TIMEOUT", "wait_ready: TIMEOUT when nothing satisfies", f"reason={reason}")
365
+ check(snap == "LASTSNAP", "wait_ready: returns last snapshot on timeout", f"snap={snap}")
366
+ check(clk.t >= 1.0, "wait_ready: respected max_wait ceiling", f"virt_t={clk.t:.2f}s")
367
+
368
+
369
+ def main() -> int:
370
+ import time as _real_time
371
+ t0 = _real_time.perf_counter()
372
+ print("=" * 60)
373
+ print("readiness deterministic unit tests (virtual clock, no real waits)")
374
+ print("=" * 60)
375
+ for fn in (
376
+ test_stable_reached, test_stable_timeout, test_late_flush_resume,
377
+ test_min_wait_guard, test_regex_match, test_regex_timeout_returns_snapshot,
378
+ test_regex_min_wait, test_wait_ready_marker_beats_stability,
379
+ test_wait_ready_stable, test_wait_ready_stable_timing_floor,
380
+ test_blank_gate_refuses_stable_on_never_painted_screen,
381
+ test_blank_gate_does_not_harm_drawn_static_screen,
382
+ test_wait_ready_timeout,
383
+ ):
384
+ fn()
385
+ wall = _real_time.perf_counter() - t0
386
+ print("-" * 60)
387
+ if _fails:
388
+ print(f"FAIL: {len(_fails)} check(s) failed: {_fails}")
389
+ return 1
390
+ print(f"PASS: all readiness edge paths covered in {wall*1000:.0f} ms wall (deterministic)")
391
+ return 0
392
+
393
+
394
+ if __name__ == "__main__":
395
+ sys.exit(main())
396
+