smartcli-toolkit 0.1.7__tar.gz → 0.1.8__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.
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/PKG-INFO +1 -1
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/pyproject.toml +1 -1
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_core/__init__.py +3 -2
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_core/readiness.py +62 -1
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_core/session.py +28 -1
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/PKG-INFO +1 -1
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/SOURCES.txt +3 -0
- smartcli_toolkit-0.1.8/tests/test_sixel.py +211 -0
- smartcli_toolkit-0.1.8/tests/test_tbench_adapter.py +217 -0
- smartcli_toolkit-0.1.8/tests/test_wait_any.py +149 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/LICENSE +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/README.md +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/setup.cfg +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_core/__main__.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_core/pty_backend.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_core/py.typed +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_core/screen_model.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_core/snapshot.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/dependency_links.txt +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/requires.txt +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/top_level.txt +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/tests/test_char_width.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/tests/test_cpr_reply.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/tests/test_degenerate_inputs.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/tests/test_doc_counts.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/tests/test_fx_contract.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/tests/test_golden_frames.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/tests/test_readiness.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/tests/test_vendor_sync.py +0 -0
- {smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/tests/test_wait_change.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: smartcli-toolkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
|
|
5
5
|
Author: dwgx
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,7 +30,7 @@ build-backend = "setuptools.build_meta"
|
|
|
30
30
|
|
|
31
31
|
[project]
|
|
32
32
|
name = "smartcli-toolkit"
|
|
33
|
-
version = "0.1.
|
|
33
|
+
version = "0.1.8"
|
|
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"
|
|
@@ -27,7 +27,7 @@ from .pty_backend import (
|
|
|
27
27
|
WinptyBackend,
|
|
28
28
|
get_default_backend,
|
|
29
29
|
)
|
|
30
|
-
from .readiness import wait_for_regex, wait_ready, wait_until_stable
|
|
30
|
+
from .readiness import wait_any, wait_for_regex, wait_ready, wait_until_stable
|
|
31
31
|
from .screen_model import CellAttrs, ScreenModel, safe_screen_display
|
|
32
32
|
from .session import KEY_MAP, PtySession
|
|
33
33
|
from .snapshot import Snapshot, Span, build_snapshot
|
|
@@ -50,9 +50,10 @@ __all__ = [
|
|
|
50
50
|
"wait_until_stable",
|
|
51
51
|
"wait_for_regex",
|
|
52
52
|
"wait_ready",
|
|
53
|
+
"wait_any",
|
|
53
54
|
# session
|
|
54
55
|
"PtySession",
|
|
55
56
|
"KEY_MAP",
|
|
56
57
|
]
|
|
57
58
|
|
|
58
|
-
__version__ = "0.1.
|
|
59
|
+
__version__ = "0.1.8"
|
|
@@ -21,7 +21,7 @@ from __future__ import annotations
|
|
|
21
21
|
|
|
22
22
|
import re
|
|
23
23
|
import time
|
|
24
|
-
from typing import Callable, Optional, Tuple
|
|
24
|
+
from typing import Callable, Optional, Sequence, Tuple
|
|
25
25
|
|
|
26
26
|
# Callable aliases (documentation only)
|
|
27
27
|
ReadFn = Callable[[], bytes] # pump: read+feed one batch, return bytes read
|
|
@@ -155,6 +155,67 @@ def wait_for_regex(
|
|
|
155
155
|
time.sleep(poll)
|
|
156
156
|
|
|
157
157
|
|
|
158
|
+
def wait_any(
|
|
159
|
+
read_fn: ReadFn,
|
|
160
|
+
get_text_fn: TextFn,
|
|
161
|
+
get_snapshot_fn: Callable[[], object],
|
|
162
|
+
patterns: Sequence[str],
|
|
163
|
+
timeout_ms: int = 10000,
|
|
164
|
+
poll_ms: int = 30,
|
|
165
|
+
min_wait_ms: int = 0,
|
|
166
|
+
flags: int = 0,
|
|
167
|
+
) -> Tuple[int, object]:
|
|
168
|
+
"""Pump reads until ANY of ``patterns`` matches the screen, or timeout.
|
|
169
|
+
|
|
170
|
+
The pexpect ``expect([...])`` analogue: race several possible outcomes
|
|
171
|
+
(prompt vs error vs EOF banner) and report WHICH one appeared first. Patterns
|
|
172
|
+
are scanned **in list order** each poll, so when two would match on the same
|
|
173
|
+
poll the earliest in the list wins (deterministic, documented) — order the
|
|
174
|
+
list most-specific-first if that matters.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
read_fn: called each poll to read+feed a batch.
|
|
178
|
+
get_text_fn: returns the current screen text searched by the regexes.
|
|
179
|
+
get_snapshot_fn: builds the :class:`Snapshot` returned to the caller.
|
|
180
|
+
patterns: regexes searched against the whole screen text, in priority order.
|
|
181
|
+
timeout_ms: hard ceiling.
|
|
182
|
+
poll_ms: sleep between polls when idle.
|
|
183
|
+
min_wait_ms: ignore matches before this much time has elapsed (guards
|
|
184
|
+
against matching a stale prior prompt right after sending input).
|
|
185
|
+
flags: extra ``re`` flags (``re.I`` etc.) applied to every pattern.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
``(index, snapshot)`` — ``index`` is the 0-based position in ``patterns``
|
|
189
|
+
of the pattern that matched, or ``-1`` on timeout. An empty ``patterns``
|
|
190
|
+
list can never match, so it returns ``(-1, snapshot)`` immediately (one
|
|
191
|
+
pump, no spin to the deadline). The snapshot is always the current screen
|
|
192
|
+
so the caller can act on the last state either way.
|
|
193
|
+
"""
|
|
194
|
+
rxs = [re.compile(p, flags) for p in patterns]
|
|
195
|
+
if not rxs:
|
|
196
|
+
# Nothing to match — pump once (so the snapshot is fresh) and report the
|
|
197
|
+
# timeout sentinel now instead of spinning the whole timeout window.
|
|
198
|
+
read_fn()
|
|
199
|
+
return -1, get_snapshot_fn()
|
|
200
|
+
poll = poll_ms / 1000.0
|
|
201
|
+
min_wait = min_wait_ms / 1000.0
|
|
202
|
+
start = time.monotonic()
|
|
203
|
+
deadline = start + (timeout_ms / 1000.0)
|
|
204
|
+
|
|
205
|
+
while True:
|
|
206
|
+
now = time.monotonic()
|
|
207
|
+
read_fn()
|
|
208
|
+
elapsed = now - start
|
|
209
|
+
if elapsed >= min_wait:
|
|
210
|
+
text = get_text_fn()
|
|
211
|
+
for i, rx in enumerate(rxs):
|
|
212
|
+
if rx.search(text):
|
|
213
|
+
return i, get_snapshot_fn()
|
|
214
|
+
if now >= deadline:
|
|
215
|
+
return -1, get_snapshot_fn()
|
|
216
|
+
time.sleep(poll)
|
|
217
|
+
|
|
218
|
+
|
|
158
219
|
def wait_ready(
|
|
159
220
|
read_fn: ReadFn,
|
|
160
221
|
get_screen_hash_fn: HashFn,
|
|
@@ -24,7 +24,7 @@ import time
|
|
|
24
24
|
from typing import List, Optional, Tuple, Union, Sequence
|
|
25
25
|
|
|
26
26
|
from .pty_backend import PtyBackend, get_default_backend
|
|
27
|
-
from .readiness import wait_for_regex, wait_ready, wait_until_stable
|
|
27
|
+
from .readiness import wait_any, wait_for_regex, wait_ready, wait_until_stable
|
|
28
28
|
from .screen_model import ScreenModel
|
|
29
29
|
from .snapshot import Snapshot, build_snapshot
|
|
30
30
|
|
|
@@ -297,6 +297,33 @@ class PtySession:
|
|
|
297
297
|
)
|
|
298
298
|
return matched, snap # type: ignore[return-value]
|
|
299
299
|
|
|
300
|
+
def wait_any(
|
|
301
|
+
self,
|
|
302
|
+
patterns: Sequence[str],
|
|
303
|
+
timeout_ms: int = 10000,
|
|
304
|
+
poll_ms: int = 30,
|
|
305
|
+
min_wait_ms: int = 0,
|
|
306
|
+
flags: int = 0,
|
|
307
|
+
) -> Tuple[int, Snapshot]:
|
|
308
|
+
"""Wait for ANY of ``patterns`` (pexpect ``expect([...])`` style).
|
|
309
|
+
|
|
310
|
+
Returns ``(index, snapshot)`` where ``index`` is the 0-based position of
|
|
311
|
+
the pattern that matched (earliest in the list wins a same-poll tie), or
|
|
312
|
+
``-1`` on timeout. The snapshot is always the current screen. See
|
|
313
|
+
:func:`readiness.wait_any`.
|
|
314
|
+
"""
|
|
315
|
+
index, snap = wait_any(
|
|
316
|
+
read_fn=self.pump,
|
|
317
|
+
get_text_fn=self.model.text,
|
|
318
|
+
get_snapshot_fn=self.snapshot,
|
|
319
|
+
patterns=patterns,
|
|
320
|
+
timeout_ms=timeout_ms,
|
|
321
|
+
poll_ms=poll_ms,
|
|
322
|
+
min_wait_ms=min_wait_ms,
|
|
323
|
+
flags=flags,
|
|
324
|
+
)
|
|
325
|
+
return index, snap # type: ignore[return-value]
|
|
326
|
+
|
|
300
327
|
def wait_change(
|
|
301
328
|
self,
|
|
302
329
|
baseline_hash: Optional[str] = None,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: smartcli-toolkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Pluggable-PTY + pyte screen model + semantic snapshot / readiness core for driving interactive terminal programs (SmartCLI shared core).
|
|
5
5
|
Author: dwgx
|
|
6
6
|
License-Expression: MIT
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test_sixel.py — Sixel encoder regression (deterministic, pure-memory).
|
|
3
|
+
|
|
4
|
+
Locks the wire format against the VT330/340 spec's known-good facts:
|
|
5
|
+
* DCS introducer ESC P 0;1;0 q, ST terminator ESC \\, raster attrs "1;1;W;H,
|
|
6
|
+
* char = 0x3F + mask, bit0 = TOP pixel (verified via the canonical DEC "HI" math),
|
|
7
|
+
* color registers #Pc;2;R;G;B with R/G/B in 0..100 PERCENT (not 0..255),
|
|
8
|
+
* `$` between colors in a band (never trailing), `-` between bands (bands-1 of them),
|
|
9
|
+
* a round-trip: decode the emitted stream back to a pixel grid and compare.
|
|
10
|
+
|
|
11
|
+
No terminal, no process — the bytes are checked structurally + by decode.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
19
|
+
sys.path.insert(0, str(ROOT))
|
|
20
|
+
sys.path.insert(0, str(ROOT / "skills" / "tui-ui"))
|
|
21
|
+
|
|
22
|
+
from ui.sixel import (encode_sixel, _quantize_index, _index_to_pct, # noqa: E402
|
|
23
|
+
raster_to_sixel)
|
|
24
|
+
|
|
25
|
+
failures = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check(cond, label, detail=""):
|
|
29
|
+
global failures
|
|
30
|
+
if not cond:
|
|
31
|
+
failures += 1
|
|
32
|
+
print(f"{'PASS' if cond else 'FAIL'} {label}" + (f" -- {detail}" if detail else ""))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
RED = (255, 0, 0)
|
|
36
|
+
BLUE = (0, 0, 255)
|
|
37
|
+
WHITE = (255, 255, 255)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_red_blue_fixture():
|
|
41
|
+
"""Spec worked example: 2px x 6px, col0 red, col1 blue. One band, two colors."""
|
|
42
|
+
px = [[RED, BLUE] for _ in range(6)]
|
|
43
|
+
s = encode_sixel(px)
|
|
44
|
+
check(s.startswith("\x1bP0;1;0q"), "starts with DCS ESC P 0;1;0 q", repr(s[:9]))
|
|
45
|
+
check(s.endswith("\x1b\\"), "ends with ST ESC backslash")
|
|
46
|
+
check('"1;1;2;6' in s, "raster attrs 1:1 aspect + WxH = 2x6")
|
|
47
|
+
ri, bi = _quantize_index(RED), _quantize_index(BLUE)
|
|
48
|
+
check(_index_to_pct(ri) == (100, 0, 0), "red register defined as 100;0;0 percent",
|
|
49
|
+
str(_index_to_pct(ri)))
|
|
50
|
+
check(_index_to_pct(bi) == (0, 0, 100), "blue register defined as 0;0;100 percent",
|
|
51
|
+
str(_index_to_pct(bi)))
|
|
52
|
+
# red column0 = all 6 rows lit = mask 63 = '~'; its column1 empty = '?'
|
|
53
|
+
check(f"#{ri}~?" in s, "red pass: col0 all-six '~', col1 none '?'",
|
|
54
|
+
f"want #{ri}~?")
|
|
55
|
+
check("$" in s, "a graphics-CR '$' separates the two colors in the band")
|
|
56
|
+
check(f"#{bi}?~" in s, "blue pass: col0 '?', col1 all-six '~'", f"want #{bi}?~")
|
|
57
|
+
check(s.count("-") == 0, "single band -> no graphics-NL '-'", f"count={s.count('-')}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_bit_math_matches_dec_hi_example():
|
|
61
|
+
"""The canonical DEC 'HI' example proves char=0x3F+mask, bit0=top:
|
|
62
|
+
'~'=0x7E->63 (all six), '@'=0x40->1 (top only), '}'=0x7D->62 (top off)."""
|
|
63
|
+
check(chr(0x3F + 0b111111) == "~", "mask 63 (all six) encodes to '~'")
|
|
64
|
+
check(chr(0x3F + 0b000001) == "@", "mask 1 (top pixel only) encodes to '@'")
|
|
65
|
+
check(chr(0x3F + 0b111110) == "}", "mask 62 (top off, rest on) encodes to '}'")
|
|
66
|
+
check(chr(0x3F + 0) == "?", "mask 0 (no pixels) encodes to '?'")
|
|
67
|
+
# a single top-lit pixel in a 1x1 image must emit '@' for its color.
|
|
68
|
+
s = encode_sixel([[RED]])
|
|
69
|
+
ri = _quantize_index(RED)
|
|
70
|
+
check(f"#{ri}@" in s, "1x1 top pixel -> sixel char '@' (bit0=top)")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_percent_scaling_not_255():
|
|
74
|
+
"""Colors must be 0..100 percent, never 0..255 (the classic gotcha)."""
|
|
75
|
+
for c in (RED, BLUE, WHITE, (128, 64, 200)):
|
|
76
|
+
r, g, b = _index_to_pct(_quantize_index(c))
|
|
77
|
+
check(0 <= r <= 100 and 0 <= g <= 100 and 0 <= b <= 100,
|
|
78
|
+
f"{c} -> percent in 0..100", f"got {(r, g, b)}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_band_advance_count():
|
|
82
|
+
"""A 2-wide x 13-tall image spans ceil(13/6)=3 bands -> exactly 2 '-'."""
|
|
83
|
+
px = [[WHITE, WHITE] for _ in range(13)]
|
|
84
|
+
s = encode_sixel(px)
|
|
85
|
+
check(s.count("-") == 2, "13 rows -> 3 bands -> 2 graphics-NL", f"count={s.count('-')}")
|
|
86
|
+
check(not s.rstrip("\x1b\\").endswith("-"), "no trailing '-' before ST")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _decode_sixel(s: str):
|
|
90
|
+
"""Minimal decoder for our own output: returns {(x,y): register} for lit px.
|
|
91
|
+
Enough to prove the encoder is self-consistent (round-trip)."""
|
|
92
|
+
body = s
|
|
93
|
+
assert body.startswith("\x1bP")
|
|
94
|
+
body = body[2:]
|
|
95
|
+
# strip params up to the 'q'
|
|
96
|
+
body = body[body.index("q") + 1:]
|
|
97
|
+
body = body[:body.rindex("\x1b\\")]
|
|
98
|
+
lit = {}
|
|
99
|
+
band = 0
|
|
100
|
+
x = 0
|
|
101
|
+
cur = None
|
|
102
|
+
i = 0
|
|
103
|
+
n = len(body)
|
|
104
|
+
while i < n:
|
|
105
|
+
ch = body[i]
|
|
106
|
+
if ch == '"': # raster attrs: skip to next control
|
|
107
|
+
j = i + 1
|
|
108
|
+
while j < n and body[j] not in "#$-":
|
|
109
|
+
j += 1
|
|
110
|
+
i = j
|
|
111
|
+
continue
|
|
112
|
+
if ch == '#': # color: #Pc or #Pc;2;r;g;b
|
|
113
|
+
j = i + 1
|
|
114
|
+
num = ""
|
|
115
|
+
while j < n and body[j].isdigit():
|
|
116
|
+
num += body[j]; j += 1
|
|
117
|
+
if j < n and body[j] == ';': # definition — skip the ;2;r;g;b
|
|
118
|
+
while j < n and body[j] not in "#$-?~" and not (0x3F <= ord(body[j]) <= 0x7E and body[j] not in ";0123456789"):
|
|
119
|
+
j += 1
|
|
120
|
+
# after a definition the register is also selected
|
|
121
|
+
cur = int(num)
|
|
122
|
+
x = 0
|
|
123
|
+
i = j
|
|
124
|
+
continue
|
|
125
|
+
if ch == '$': # CR: same band, back to x=0
|
|
126
|
+
x = 0; i += 1; continue
|
|
127
|
+
if ch == '-': # NL: next band
|
|
128
|
+
band += 1; x = 0; i += 1; continue
|
|
129
|
+
if ch == '!': # RLE: !Pn<char>
|
|
130
|
+
j = i + 1; num = ""
|
|
131
|
+
while j < n and body[j].isdigit():
|
|
132
|
+
num += body[j]; j += 1
|
|
133
|
+
rep = int(num); sc = body[j]
|
|
134
|
+
mask = ord(sc) - 0x3F
|
|
135
|
+
for _ in range(rep):
|
|
136
|
+
for dy in range(6):
|
|
137
|
+
if mask & (1 << dy):
|
|
138
|
+
lit[(x, band * 6 + dy)] = cur
|
|
139
|
+
x += 1
|
|
140
|
+
i = j + 1
|
|
141
|
+
continue
|
|
142
|
+
if 0x3F <= ord(ch) <= 0x7E: # a data char
|
|
143
|
+
mask = ord(ch) - 0x3F
|
|
144
|
+
for dy in range(6):
|
|
145
|
+
if mask & (1 << dy):
|
|
146
|
+
lit[(x, band * 6 + dy)] = cur
|
|
147
|
+
x += 1
|
|
148
|
+
i += 1
|
|
149
|
+
continue
|
|
150
|
+
i += 1
|
|
151
|
+
return lit
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_round_trip():
|
|
155
|
+
"""Encode a known pattern, decode our own stream, confirm lit pixels match."""
|
|
156
|
+
# 3x6 checkerboard of red/blue
|
|
157
|
+
px = [[RED if (x + y) % 2 == 0 else BLUE for x in range(3)] for y in range(6)]
|
|
158
|
+
s = encode_sixel(px)
|
|
159
|
+
lit = _decode_sixel(s)
|
|
160
|
+
ri, bi = _quantize_index(RED), _quantize_index(BLUE)
|
|
161
|
+
good = True
|
|
162
|
+
for y in range(6):
|
|
163
|
+
for x in range(3):
|
|
164
|
+
want = ri if (x + y) % 2 == 0 else bi
|
|
165
|
+
if lit.get((x, y)) != want:
|
|
166
|
+
good = False
|
|
167
|
+
check(good, "round-trip: decoded pixels match the source checkerboard",
|
|
168
|
+
f"{len(lit)} lit px")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_transparent_pixels_skipped():
|
|
172
|
+
"""None pixels must not be painted (P2=1 transparent)."""
|
|
173
|
+
px = [[RED, None], [None, RED]] # 2x2, diagonal
|
|
174
|
+
s = encode_sixel(px)
|
|
175
|
+
lit = _decode_sixel(s)
|
|
176
|
+
ri = _quantize_index(RED)
|
|
177
|
+
check(lit.get((0, 0)) == ri and lit.get((1, 1)) == ri, "lit diagonal present")
|
|
178
|
+
check((1, 0) not in lit and (0, 1) not in lit, "None pixels stay transparent")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_raster_adapter():
|
|
182
|
+
"""raster_to_sixel forwards a SubcellRaster's .px buffer."""
|
|
183
|
+
from ui.raster import SubcellRaster
|
|
184
|
+
r = SubcellRaster(2, 2, "quad") # pw=4, ph=4
|
|
185
|
+
r.px[0][0] = RED
|
|
186
|
+
s = raster_to_sixel(r)
|
|
187
|
+
check(s.startswith("\x1bP") and s.endswith("\x1b\\"), "raster adapter emits a DCS")
|
|
188
|
+
check('"1;1;4;4' in s, "raster adapter uses the pixel-buffer dims (4x4)")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_empty_image():
|
|
192
|
+
s = encode_sixel([])
|
|
193
|
+
check(s == "\x1bP0;1;0q\x1b\\", "empty image -> bare DCS+ST wrapper", repr(s))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main():
|
|
197
|
+
for fn in (test_red_blue_fixture, test_bit_math_matches_dec_hi_example,
|
|
198
|
+
test_percent_scaling_not_255, test_band_advance_count,
|
|
199
|
+
test_round_trip, test_transparent_pixels_skipped,
|
|
200
|
+
test_raster_adapter, test_empty_image):
|
|
201
|
+
fn()
|
|
202
|
+
print("-" * 60)
|
|
203
|
+
if failures:
|
|
204
|
+
print(f"test_sixel FAIL -- {failures} check(s) failed")
|
|
205
|
+
return 1
|
|
206
|
+
print("test_sixel PASS -- sixel wire format locked to spec")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
sys.exit(main())
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test_tbench_adapter.py — smartcli_tbench driver + loop (deterministic, no Docker).
|
|
3
|
+
|
|
4
|
+
Proves the Terminal-Bench adapter's LOGIC on any box (incl. Windows) without
|
|
5
|
+
Docker, terminal-bench, or an LLM:
|
|
6
|
+
* TmuxSessionDriver.wait_for / wait_any / wait_change / wait_stable fire correctly
|
|
7
|
+
over a scripted FakeTmuxSession (fake clock + no-op sleep — instant, deterministic),
|
|
8
|
+
* run_agent_loop executes perceive→decide→act→wait→confirm, terminates on the
|
|
9
|
+
decider's done signal, and respects the step budget,
|
|
10
|
+
* send_keys / send_line reach the session.
|
|
11
|
+
|
|
12
|
+
The LLM-backed SmartCliAgent itself needs terminal-bench (CI/Linux); this locks
|
|
13
|
+
everything below that seam.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
21
|
+
sys.path.insert(0, str(ROOT))
|
|
22
|
+
|
|
23
|
+
from smartcli_tbench.driver import TmuxSessionDriver # noqa: E402
|
|
24
|
+
from smartcli_tbench.loop import AgentStep, run_agent_loop # noqa: E402
|
|
25
|
+
|
|
26
|
+
failures = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(cond, label, detail=""):
|
|
30
|
+
global failures
|
|
31
|
+
if not cond:
|
|
32
|
+
failures += 1
|
|
33
|
+
print(f"{'PASS' if cond else 'FAIL'} {label}" + (f" -- {detail}" if detail else ""))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FakeClock:
|
|
37
|
+
"""Monotonic clock that only advances when the driver sleeps, so wait loops
|
|
38
|
+
hit their deadline deterministically without real time passing."""
|
|
39
|
+
|
|
40
|
+
def __init__(self):
|
|
41
|
+
self.t = 0.0
|
|
42
|
+
|
|
43
|
+
def now(self):
|
|
44
|
+
return self.t
|
|
45
|
+
|
|
46
|
+
def sleep(self, s):
|
|
47
|
+
self.t += s
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FakeTmuxSession:
|
|
51
|
+
"""Scripts capture_pane() outputs: each poll returns the next screen; the last
|
|
52
|
+
screen repeats forever. Records sent keys. With ``cycle=True`` the script loops
|
|
53
|
+
(to model a screen that alternates forever and truly never settles)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, screens, cycle=False):
|
|
56
|
+
self._screens = list(screens)
|
|
57
|
+
self._i = 0
|
|
58
|
+
self._cycle = cycle
|
|
59
|
+
self.sent = []
|
|
60
|
+
|
|
61
|
+
def capture_pane(self, capture_entire=False):
|
|
62
|
+
if self._cycle:
|
|
63
|
+
s = self._screens[self._i % len(self._screens)]
|
|
64
|
+
else:
|
|
65
|
+
s = self._screens[min(self._i, len(self._screens) - 1)]
|
|
66
|
+
self._i += 1
|
|
67
|
+
return s
|
|
68
|
+
|
|
69
|
+
def send_keys(self, keys, block=False, max_timeout_sec=180.0, **kw):
|
|
70
|
+
self.sent.append(list(keys))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _driver(screens):
|
|
74
|
+
clk = FakeClock()
|
|
75
|
+
sess = FakeTmuxSession(screens)
|
|
76
|
+
return TmuxSessionDriver(sess, poll_ms=100, clock=clk.now, sleep=clk.sleep), sess, clk
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_wait_for_matches():
|
|
80
|
+
d, _, _ = _driver(["booting...", "booting...", "$ ready"])
|
|
81
|
+
ok, screen = d.wait_for(r"\$ ready", timeout_sec=5)
|
|
82
|
+
check(ok is True, "wait_for finds the marker once it appears")
|
|
83
|
+
check("ready" in screen, "wait_for returns the matching screen")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_wait_for_timeout():
|
|
87
|
+
d, _, _ = _driver(["nope", "nope", "nope"])
|
|
88
|
+
ok, _ = d.wait_for(r"never", timeout_sec=1)
|
|
89
|
+
check(ok is False, "wait_for times out when the marker never appears")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_wait_any_index_and_tie():
|
|
93
|
+
d, _, _ = _driver(["...", "ERROR: boom"])
|
|
94
|
+
idx, _ = d.wait_any([r">>> ", r"ERROR"], timeout_sec=5)
|
|
95
|
+
check(idx == 1, "wait_any returns the index of the matching pattern", f"idx={idx}")
|
|
96
|
+
# same-poll tie -> earliest in list wins
|
|
97
|
+
d2, _, _ = _driver(["ERROR: boom"])
|
|
98
|
+
idx2, _ = d2.wait_any([r"ERROR", r"boom"], timeout_sec=5)
|
|
99
|
+
check(idx2 == 0, "wait_any tie: earliest-in-list wins", f"idx={idx2}")
|
|
100
|
+
# empty list short-circuits
|
|
101
|
+
d3, _, _ = _driver(["anything"])
|
|
102
|
+
idx3, _ = d3.wait_any([], timeout_sec=5)
|
|
103
|
+
check(idx3 == -1, "wait_any empty patterns -> -1", f"idx={idx3}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_wait_change():
|
|
107
|
+
d, _, _ = _driver(["same", "same", "DIFFERENT"])
|
|
108
|
+
changed, screen = d.wait_change(timeout_sec=5)
|
|
109
|
+
check(changed is True, "wait_change detects the screen changing from baseline")
|
|
110
|
+
check("DIFFERENT" in screen, "wait_change returns the changed screen")
|
|
111
|
+
|
|
112
|
+
d2, _, _ = _driver(["frozen", "frozen", "frozen"])
|
|
113
|
+
changed2, _ = d2.wait_change(timeout_sec=1)
|
|
114
|
+
check(changed2 is False, "wait_change times out when nothing changes")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_wait_stable():
|
|
118
|
+
# changes for two polls, then holds -> becomes stable (min_wait_sec=0 to isolate
|
|
119
|
+
# the dwell logic from the stale-screen floor, which is tested separately below).
|
|
120
|
+
d, _, _ = _driver(["a", "b", "c", "c", "c"])
|
|
121
|
+
ok = d.wait_stable(quiet_polls=2, timeout_sec=5, min_wait_sec=0)
|
|
122
|
+
check(ok is True, "wait_stable settles once the screen stops changing")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_wait_stable_never_settles():
|
|
126
|
+
# A screen that alternates FOREVER (cycle=True) must never satisfy the dwell
|
|
127
|
+
# counter -> can only exit via timeout. Uses a truly non-clamping fake so this
|
|
128
|
+
# is a real never-settle, not a clamp-then-freeze artifact.
|
|
129
|
+
clk = FakeClock()
|
|
130
|
+
sess = FakeTmuxSession(["A", "B"], cycle=True)
|
|
131
|
+
d = TmuxSessionDriver(sess, poll_ms=100, clock=clk.now, sleep=clk.sleep)
|
|
132
|
+
ok = d.wait_stable(quiet_polls=2, timeout_sec=1, min_wait_sec=0)
|
|
133
|
+
check(ok is False, "wait_stable never settles on an alternating screen -> timeout")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_wait_stable_min_wait_guards_stale_screen():
|
|
137
|
+
# The stale-screen race (finding 1): the screen is ALREADY static from poll 0
|
|
138
|
+
# (the shell echoed the command instantly) but real output is still pending.
|
|
139
|
+
# With min_wait_sec, wait_stable must NOT declare stable before the floor, even
|
|
140
|
+
# though the dwell counter is satisfied immediately.
|
|
141
|
+
clk = FakeClock()
|
|
142
|
+
sess = FakeTmuxSession(["prompt$ cmd"], cycle=True) # static from the very start
|
|
143
|
+
d = TmuxSessionDriver(sess, poll_ms=100, clock=clk.now, sleep=clk.sleep)
|
|
144
|
+
t0 = clk.now()
|
|
145
|
+
ok = d.wait_stable(quiet_polls=2, timeout_sec=5, min_wait_sec=0.5)
|
|
146
|
+
elapsed = clk.now() - t0
|
|
147
|
+
check(ok is True, "wait_stable eventually settles on a static screen")
|
|
148
|
+
check(elapsed >= 0.5, "min_wait_sec floor delays settle past the stale-screen race",
|
|
149
|
+
f"elapsed={elapsed:.2f}s")
|
|
150
|
+
# And without the floor it would settle almost immediately (proves the guard bites):
|
|
151
|
+
clk2 = FakeClock()
|
|
152
|
+
d2 = TmuxSessionDriver(FakeTmuxSession(["prompt$ cmd"], cycle=True),
|
|
153
|
+
poll_ms=100, clock=clk2.now, sleep=clk2.sleep)
|
|
154
|
+
d2.wait_stable(quiet_polls=2, timeout_sec=5, min_wait_sec=0)
|
|
155
|
+
check(clk2.now() < 0.5, "without the floor, settle is immediate (guard is what delays)",
|
|
156
|
+
f"elapsed={clk2.now():.2f}s")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_send_helpers():
|
|
160
|
+
d, sess, _ = _driver(["scr"])
|
|
161
|
+
d.send_keys(["Down", "Enter"])
|
|
162
|
+
d.send_line("echo hi")
|
|
163
|
+
check(sess.sent[0] == ["Down", "Enter"], "send_keys forwards key tokens")
|
|
164
|
+
check(sess.sent[1] == ["echo hi", "Enter"], "send_line appends Enter")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_loop_runs_to_done():
|
|
168
|
+
# Decider: step 0 types a command, step 1 declares done.
|
|
169
|
+
d, sess, _ = _driver(["prompt$ ", "prompt$ output", "prompt$ output"])
|
|
170
|
+
|
|
171
|
+
def decide(instruction, screen, history):
|
|
172
|
+
if len(history) == 0:
|
|
173
|
+
return AgentStep(line="run the thing", wait_for=None)
|
|
174
|
+
return AgentStep(done=True)
|
|
175
|
+
|
|
176
|
+
res = run_agent_loop("do it", d, decide, max_steps=10)
|
|
177
|
+
check(res.done is True, "loop ends when the decider signals done")
|
|
178
|
+
check(res.steps_taken == 1, "one action taken before done", f"steps={res.steps_taken}")
|
|
179
|
+
check(sess.sent and sess.sent[0] == ["run the thing", "Enter"],
|
|
180
|
+
"loop actually sent the decided line")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_loop_respects_step_budget():
|
|
184
|
+
d, _, _ = _driver(["x"])
|
|
185
|
+
# Decider never says done -> must stop at max_steps.
|
|
186
|
+
def decide(instruction, screen, history):
|
|
187
|
+
return AgentStep(keys=["a"])
|
|
188
|
+
res = run_agent_loop("loop forever", d, decide, max_steps=5)
|
|
189
|
+
check(res.done is False, "loop stops at the step budget when never done")
|
|
190
|
+
check(res.steps_taken == 5, "exactly max_steps actions taken", f"steps={res.steps_taken}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_agent_module_imports_without_tb():
|
|
194
|
+
# Importing the agent module must NOT require terminal-bench; SmartCliAgent is
|
|
195
|
+
# None here (TB absent), which is the documented non-TB-host behavior.
|
|
196
|
+
import smartcli_tbench.agent as ag
|
|
197
|
+
check(hasattr(ag, "SmartCliAgent"), "agent module imports without terminal-bench")
|
|
198
|
+
check(ag.SmartCliAgent is None, "SmartCliAgent is None on a non-TB host (expected)")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main():
|
|
202
|
+
for fn in (test_wait_for_matches, test_wait_for_timeout, test_wait_any_index_and_tie,
|
|
203
|
+
test_wait_change, test_wait_stable, test_wait_stable_never_settles,
|
|
204
|
+
test_wait_stable_min_wait_guards_stale_screen, test_send_helpers,
|
|
205
|
+
test_loop_runs_to_done, test_loop_respects_step_budget,
|
|
206
|
+
test_agent_module_imports_without_tb):
|
|
207
|
+
fn()
|
|
208
|
+
print("-" * 60)
|
|
209
|
+
if failures:
|
|
210
|
+
print(f"test_tbench_adapter FAIL -- {failures} check(s) failed")
|
|
211
|
+
return 1
|
|
212
|
+
print("test_tbench_adapter PASS -- driver + loop logic locked (no Docker/LLM)")
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
sys.exit(main())
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test_wait_any.py — PtySession.wait_any regression (deterministic).
|
|
3
|
+
|
|
4
|
+
wait_any is the pexpect expect([...]) analogue: race several regexes and report
|
|
5
|
+
WHICH matched first. This locks:
|
|
6
|
+
* the pattern that appears wins and its LIST INDEX is returned,
|
|
7
|
+
* a later-listed pattern wins when only it appears,
|
|
8
|
+
* earliest-in-list wins a same-poll tie (documented ordering),
|
|
9
|
+
* timeout with none matched returns (-1, latest snapshot),
|
|
10
|
+
* min_wait_ms ignores a stale prior match until the guard elapses.
|
|
11
|
+
|
|
12
|
+
Pure/in-memory: a fake backend feeds bytes on demand. No PTY, no process.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
21
|
+
sys.path.insert(0, str(ROOT))
|
|
22
|
+
|
|
23
|
+
from smartcli_core import PtySession # noqa: E402
|
|
24
|
+
from smartcli_core.pty_backend import PtyBackend # noqa: E402
|
|
25
|
+
|
|
26
|
+
failures = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(cond, label, detail=""):
|
|
30
|
+
global failures
|
|
31
|
+
if not cond:
|
|
32
|
+
failures += 1
|
|
33
|
+
print(f"{'PASS' if cond else 'FAIL'} {label}" + (f" -- {detail}" if detail else ""))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FakeBackend(PtyBackend):
|
|
37
|
+
"""Feeds queued byte chunks one-per-read; optionally delays the first chunk."""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
self._q = []
|
|
41
|
+
self._alive = True
|
|
42
|
+
|
|
43
|
+
def queue(self, data):
|
|
44
|
+
self._q.append(data)
|
|
45
|
+
|
|
46
|
+
def spawn(self, cmd, cols, rows):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
def read_nonblocking(self, timeout=0.0):
|
|
50
|
+
return self._q.pop(0) if self._q else b""
|
|
51
|
+
|
|
52
|
+
def write(self, data):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def resize(self, cols, rows):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
def is_alive(self):
|
|
59
|
+
return self._alive
|
|
60
|
+
|
|
61
|
+
def terminate(self):
|
|
62
|
+
self._alive = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_first_pattern_matches():
|
|
66
|
+
be = FakeBackend()
|
|
67
|
+
sess = PtySession(cols=40, rows=10, backend=be)
|
|
68
|
+
be.queue(b">>> ")
|
|
69
|
+
idx, snap = sess.wait_any([r">>> ", r"Traceback"], timeout_ms=2000)
|
|
70
|
+
check(idx == 0, "the prompt pattern matches -> index 0", f"idx={idx}")
|
|
71
|
+
check(">>>" in snap.to_text(), "returned snapshot shows the matched screen")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_later_pattern_matches():
|
|
75
|
+
be = FakeBackend()
|
|
76
|
+
sess = PtySession(cols=40, rows=10, backend=be)
|
|
77
|
+
be.queue(b"Traceback (most recent call last)")
|
|
78
|
+
idx, _ = sess.wait_any([r">>> ", r"Traceback"], timeout_ms=2000)
|
|
79
|
+
check(idx == 1, "only the error pattern is present -> index 1", f"idx={idx}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_earliest_in_list_wins_tie():
|
|
83
|
+
# One screen that matches BOTH patterns; the earlier list entry must win.
|
|
84
|
+
be = FakeBackend()
|
|
85
|
+
sess = PtySession(cols=40, rows=10, backend=be)
|
|
86
|
+
be.queue(b"ERROR: boom")
|
|
87
|
+
idx, _ = sess.wait_any([r"ERROR", r"boom"], timeout_ms=2000)
|
|
88
|
+
check(idx == 0, "same-poll tie: earliest-in-list pattern wins", f"idx={idx}")
|
|
89
|
+
# And the reverse order proves it's list-order, not text-position.
|
|
90
|
+
be2 = FakeBackend()
|
|
91
|
+
sess2 = PtySession(cols=40, rows=10, backend=be2)
|
|
92
|
+
be2.queue(b"ERROR: boom")
|
|
93
|
+
idx2, _ = sess2.wait_any([r"boom", r"ERROR"], timeout_ms=2000)
|
|
94
|
+
check(idx2 == 0, "reversed list -> the now-first pattern ('boom') wins", f"idx={idx2}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_empty_patterns_short_circuits():
|
|
98
|
+
# An empty list can never match: return (-1, snapshot) immediately instead of
|
|
99
|
+
# spinning the whole timeout window (adversarial-review polish).
|
|
100
|
+
be = FakeBackend()
|
|
101
|
+
sess = PtySession(cols=40, rows=10, backend=be)
|
|
102
|
+
t0 = time.monotonic()
|
|
103
|
+
idx, snap = sess.wait_any([], timeout_ms=3000)
|
|
104
|
+
elapsed_ms = (time.monotonic() - t0) * 1000
|
|
105
|
+
check(idx == -1, "empty patterns -> index -1", f"idx={idx}")
|
|
106
|
+
check(snap is not None, "empty patterns still returns a snapshot")
|
|
107
|
+
check(elapsed_ms < 500, "empty patterns returns immediately, not after timeout",
|
|
108
|
+
f"elapsed={elapsed_ms:.0f}ms")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_timeout_returns_minus_one():
|
|
112
|
+
be = FakeBackend()
|
|
113
|
+
sess = PtySession(cols=40, rows=10, backend=be)
|
|
114
|
+
# nothing queued -> screen stays blank -> no pattern ever matches
|
|
115
|
+
idx, snap = sess.wait_any([r"never", r"appears"], timeout_ms=250)
|
|
116
|
+
check(idx == -1, "no pattern within the window -> index -1 (timeout)", f"idx={idx}")
|
|
117
|
+
check(snap is not None, "timeout still returns the latest snapshot")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_min_wait_ignores_stale_match():
|
|
121
|
+
# The target text is already on screen at call time; min_wait_ms must make
|
|
122
|
+
# wait_any ignore it until the guard elapses, then it still returns the match.
|
|
123
|
+
be = FakeBackend()
|
|
124
|
+
sess = PtySession(cols=40, rows=10, backend=be)
|
|
125
|
+
be.queue(b"$ ")
|
|
126
|
+
sess.pump() # paint "$ " BEFORE the wait, so it's a stale prior match
|
|
127
|
+
t0 = time.monotonic()
|
|
128
|
+
idx, _ = sess.wait_any([r"\$ "], timeout_ms=2000, min_wait_ms=150)
|
|
129
|
+
elapsed_ms = (time.monotonic() - t0) * 1000
|
|
130
|
+
check(idx == 0, "stale match is still returned after the guard", f"idx={idx}")
|
|
131
|
+
check(elapsed_ms >= 140, "min_wait_ms delayed the match past the guard",
|
|
132
|
+
f"elapsed={elapsed_ms:.0f}ms")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main():
|
|
136
|
+
for fn in (test_first_pattern_matches, test_later_pattern_matches,
|
|
137
|
+
test_earliest_in_list_wins_tie, test_empty_patterns_short_circuits,
|
|
138
|
+
test_timeout_returns_minus_one, test_min_wait_ignores_stale_match):
|
|
139
|
+
fn()
|
|
140
|
+
print("-" * 60)
|
|
141
|
+
if failures:
|
|
142
|
+
print(f"test_wait_any FAIL -- {failures} check(s) failed")
|
|
143
|
+
return 1
|
|
144
|
+
print("test_wait_any PASS -- all wait_any paths locked")
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{smartcli_toolkit-0.1.7 → smartcli_toolkit-0.1.8}/smartcli_toolkit.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|