Skip to content

Commit 25b5275

Browse files
committed
Add now/random/assert-var flow commands and QR reading
Four additions: - AC_now_to_var: current time (strftime format) into a variable. - AC_random_to_var: a seeded random int / float / choice into a variable. - assert_variable / AC_assert_var: fail when a flow variable doesn't satisfy eq/ne/lt/gt/contains/regex/... — the assertion-DSL companion to if_var, pairing with ocr_to_var / shell_to_var. - read_qr_codes / AC_read_qr: decode QR codes in a screen region via OpenCV's QRCodeDetector (no new dependency; decoder injectable). All exposed via the facade / executor and the visual script builder.
1 parent 56f09b7 commit 25b5275

10 files changed

Lines changed: 310 additions & 3 deletions

File tree

je_auto_control/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
from je_auto_control.utils.scroll_find import scroll_until_visible
8080
# Recoverable deletion (move files to the OS recycle bin).
8181
from je_auto_control.utils.trash import move_to_trash
82+
# QR code decoding from a screen region / image.
83+
from je_auto_control.utils.qr import read_qr_codes
8284
# WebRunner bridge (headless: optional je_web_runner dependency)
8385
from je_auto_control.utils.webrunner_bridge import (
8486
WebRunnerBridgeError, is_webrunner_available, list_webrunner_commands,
@@ -155,7 +157,8 @@
155157
AssertionResult, GroupAssertionResult, assert_all, assert_any,
156158
assert_by_description, assert_clipboard, assert_duration,
157159
assert_eventually, assert_file, assert_http, assert_image, assert_pixel,
158-
assert_process, assert_text, assert_window, run_assertion_spec,
160+
assert_process, assert_text, assert_variable, assert_window,
161+
run_assertion_spec,
159162
)
160163
# Data-driven execution (load rows from CSV / JSON / SQLite / Excel)
161164
from je_auto_control.utils.data_source import data_source_kinds, load_rows
@@ -549,6 +552,7 @@ def start_autocontrol_gui(*args, **kwargs):
549552
"AssertionResult", "assert_image", "assert_pixel",
550553
"assert_text", "assert_window", "assert_clipboard", "assert_process",
551554
"assert_file", "assert_http", "assert_by_description", "assert_duration",
555+
"assert_variable",
552556
# Assertion combinators (soft groups + eventual polling)
553557
"GroupAssertionResult", "assert_all", "assert_any", "assert_eventually",
554558
"run_assertion_spec",
@@ -602,6 +606,8 @@ def start_autocontrol_gui(*args, **kwargs):
602606
"scroll_until_visible",
603607
# Recoverable deletion (recycle bin)
604608
"move_to_trash",
609+
# QR code decoding
610+
"read_qr_codes",
605611
# WebRunner bridge (browser automation via je_web_runner)
606612
"WebRunnerBridgeError", "is_webrunner_available",
607613
"list_webrunner_commands", "run_webrunner_action",

je_auto_control/gui/script_builder/command_schema.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,14 @@ def _add_ocr_specs(specs: List[CommandSpec]) -> None:
224224
default=60.0, min_value=0.0, max_value=100.0),
225225
),
226226
))
227+
specs.append(CommandSpec(
228+
"AC_read_qr", "OCR", "Read QR Codes",
229+
fields=(
230+
FieldSpec("region", FieldType.STRING, optional=True,
231+
placeholder="[0, 0, 400, 400]"),
232+
),
233+
description="Decode QR codes in a screen region (OpenCV).",
234+
))
227235
specs.append(CommandSpec(
228236
"AC_scroll_to_find", "OCR", "Scroll Until Visible",
229237
fields=(
@@ -448,6 +456,39 @@ def _add_flow_specs(specs: List[CommandSpec]) -> None:
448456
),
449457
description="String-transform a variable (upper/strip/replace/regex/...).",
450458
))
459+
specs.append(CommandSpec(
460+
"AC_now_to_var", "Flow", "Timestamp into Variable",
461+
fields=(
462+
FieldSpec("var", FieldType.STRING, default="now"),
463+
FieldSpec("format", FieldType.STRING, optional=True,
464+
default="%Y-%m-%d %H:%M:%S"),
465+
),
466+
description="Store the current time (strftime format) in a variable.",
467+
))
468+
specs.append(CommandSpec(
469+
"AC_random_to_var", "Flow", "Random into Variable",
470+
fields=(
471+
FieldSpec("var", FieldType.STRING, default="random"),
472+
FieldSpec("kind", FieldType.ENUM,
473+
choices=("int", "float", "choice"), default="int"),
474+
FieldSpec("min", FieldType.FLOAT, optional=True, default=0.0),
475+
FieldSpec("max", FieldType.FLOAT, optional=True, default=100.0),
476+
FieldSpec("seed", FieldType.INT, optional=True),
477+
),
478+
description="Store a random int / float / choice in a variable.",
479+
))
480+
specs.append(CommandSpec(
481+
"AC_assert_var", "Flow", "Assert Variable",
482+
fields=(
483+
FieldSpec("name", FieldType.STRING),
484+
FieldSpec("op", FieldType.ENUM,
485+
choices=("eq", "ne", "lt", "le", "gt", "ge",
486+
"contains", "startswith", "endswith",
487+
"regex"), default="eq"),
488+
FieldSpec("value", FieldType.STRING, optional=True),
489+
),
490+
description="Fail if a flow variable doesn't satisfy the condition.",
491+
))
451492
specs.append(CommandSpec(
452493
"AC_assert_duration", "Flow", "Assert Duration (perf budget)",
453494
fields=(

je_auto_control/utils/assertion/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
assert_pixel,
2020
assert_process,
2121
assert_text,
22+
assert_variable,
2223
assert_window,
2324
)
2425
from je_auto_control.utils.assertion.combinators import (
@@ -45,6 +46,7 @@
4546
"assert_pixel",
4647
"assert_process",
4748
"assert_text",
49+
"assert_variable",
4850
"assert_window",
4951
"run_assertion_spec",
5052
]

je_auto_control/utils/assertion/assertions.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,56 @@ def assert_by_description(description: str,
503503
)
504504

505505

506+
def _variable_satisfies(value: Any, op: str, expected: Any) -> bool:
507+
"""Return True when ``value op expected`` holds (eq/ne/contains/...)."""
508+
import re
509+
comparators = {
510+
"eq": lambda a, b: a == b,
511+
"ne": lambda a, b: a != b,
512+
"lt": lambda a, b: a < b,
513+
"le": lambda a, b: a <= b,
514+
"gt": lambda a, b: a > b,
515+
"ge": lambda a, b: a >= b,
516+
"contains": lambda a, b: b in a,
517+
"startswith": lambda a, b: isinstance(a, str) and a.startswith(b),
518+
"endswith": lambda a, b: isinstance(a, str) and a.endswith(b),
519+
"regex": lambda a, b: re.search(str(b), str(a)) is not None,
520+
}
521+
comparator = comparators.get(op)
522+
if comparator is None:
523+
raise AutoControlAssertionException(
524+
f"assert_var: unknown op {op!r}; expected one of "
525+
f"{sorted(comparators)}"
526+
)
527+
try:
528+
return bool(comparator(value, expected))
529+
except TypeError:
530+
return False
531+
532+
533+
def assert_variable(value: Any, op: str = "eq", expected: Any = None,
534+
name: str = "variable",
535+
raise_on_fail: bool = True) -> AssertionResult:
536+
"""Assert that ``value`` satisfies ``op expected`` (eq/ne/contains/regex/…).
537+
538+
The assertion-DSL companion to the flow ``if_var`` / ``while_var``
539+
conditions: instead of branching, fail loudly when a variable doesn't
540+
hold the expected value — handy after ``ocr_to_var`` / ``shell_to_var``.
541+
"""
542+
passed = _variable_satisfies(value, op, expected)
543+
message = (
544+
f"assert_var passed: {name}={value!r} {op} {expected!r}"
545+
if passed else
546+
f"assert_var failed: expected {name}={value!r} to satisfy "
547+
f"{op} {expected!r}"
548+
)
549+
return _finalize(
550+
"variable", passed, message,
551+
expected={"op": op, "value": expected}, actual=value,
552+
raise_on_fail=raise_on_fail, capture_on_fail=False,
553+
)
554+
555+
506556
def assert_duration(action: Callable[[], Any], max_ms: float,
507557
min_ms: float = 0.0,
508558
raise_on_fail: bool = True,

je_auto_control/utils/executor/action_executor.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,31 @@ def _move_to_trash(path: str) -> Dict[str, Any]:
20152015
return {"trashed": move_to_trash(path)}
20162016

20172017

2018+
def _read_qr(region: Optional[Union[List[int], str]] = None) -> Dict[str, Any]:
2019+
"""Executor adapter: decode QR codes in a screen region.
2020+
2021+
``region`` is ``[x1, y1, x2, y2]`` (or a JSON string for the builder);
2022+
omit it to scan the whole screen.
2023+
"""
2024+
import json
2025+
import os
2026+
import tempfile
2027+
from je_auto_control.utils.qr import read_qr_codes
2028+
from je_auto_control.wrapper.auto_control_screen import screenshot
2029+
if isinstance(region, str):
2030+
region = json.loads(region) if region.strip() else None
2031+
handle, tmp = tempfile.mkstemp(prefix="qr_", suffix=".png")
2032+
os.close(handle)
2033+
try:
2034+
screenshot(tmp, screen_region=region)
2035+
return {"codes": read_qr_codes(tmp)}
2036+
finally:
2037+
try:
2038+
os.unlink(tmp)
2039+
except OSError:
2040+
pass
2041+
2042+
20182043
def _scroll_to_find(target: str, kind: str = "image", direction: str = "down",
20192044
max_scrolls: int = 10,
20202045
scroll_amount: int = 3) -> Dict[str, Any]:
@@ -2484,6 +2509,9 @@ def __init__(self):
24842509
# Recoverable deletion (move a file to the OS recycle bin)
24852510
"AC_move_to_trash": _move_to_trash,
24862511

2512+
# QR code decoding from a screen region
2513+
"AC_read_qr": _read_qr,
2514+
24872515
# Scroll until a target image / text is visible
24882516
"AC_scroll_to_find": _scroll_to_find,
24892517

je_auto_control/utils/executor/flow_control.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,48 @@ def exec_shell_to_var(executor: Any, args: Mapping[str, Any]) -> Dict[str, Any]:
389389
"returncode": completed.returncode}
390390

391391

392+
def _now():
393+
import datetime as _dt
394+
return _dt.datetime.now()
395+
396+
397+
def exec_now_to_var(executor: Any, args: Mapping[str, Any]) -> Dict[str, Any]:
398+
"""Store the current local time (strftime format) in a flow variable."""
399+
value = _now().strftime(str(args.get("format", "%Y-%m-%d %H:%M:%S")))
400+
var_name = args.get("var", "now")
401+
executor.variables.set(var_name, value)
402+
return {"var": var_name, "value": value}
403+
404+
405+
def exec_random_to_var(executor: Any,
406+
args: Mapping[str, Any]) -> Dict[str, Any]:
407+
"""Store a random value (int / float / choice) in a flow variable."""
408+
import random
409+
rng = random.Random(args.get("seed"))
410+
kind = str(args.get("kind", "int"))
411+
if kind == "choice":
412+
value: Any = rng.choice(list(args.get("choices") or [None]))
413+
elif kind == "float":
414+
value = rng.uniform(float(args.get("min", 0.0)),
415+
float(args.get("max", 1.0)))
416+
else:
417+
value = rng.randint(int(args.get("min", 0)), int(args.get("max", 100)))
418+
var_name = args.get("var", "random")
419+
executor.variables.set(var_name, value)
420+
return {"var": var_name, "value": value}
421+
422+
423+
def exec_assert_var(executor: Any, args: Mapping[str, Any]) -> Dict[str, Any]:
424+
"""Assert a flow variable satisfies a condition (assertion DSL)."""
425+
from je_auto_control.utils.assertion import assert_variable
426+
name = args["name"]
427+
return assert_variable(
428+
executor.variables.get_value(name), op=str(args.get("op", "eq")),
429+
expected=args.get("value"), name=name,
430+
raise_on_fail=bool(args.get("raise_on_fail", True)),
431+
).to_dict()
432+
433+
392434
def exec_read_file_to_var(executor: Any,
393435
args: Mapping[str, Any]) -> Dict[str, Any]:
394436
"""Read a file's text content into a flow variable."""
@@ -603,4 +645,7 @@ def exec_call_macro(executor: Any, args: Mapping[str, Any]) -> Any:
603645
"AC_read_file_to_var": exec_read_file_to_var,
604646
"AC_http_to_var": exec_http_to_var,
605647
"AC_transform_var": exec_transform_var,
648+
"AC_now_to_var": exec_now_to_var,
649+
"AC_random_to_var": exec_random_to_var,
650+
"AC_assert_var": exec_assert_var,
606651
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Decode QR codes from an image or screen region (OpenCV)."""
2+
from je_auto_control.utils.qr.qr import read_qr_codes
3+
4+
__all__ = ["read_qr_codes"]

je_auto_control/utils/qr/qr.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Decode QR codes from an image or screen region using OpenCV.
2+
3+
OpenCV's ``QRCodeDetector`` ships with ``je_open_cv`` (already a
4+
dependency), so no extra package is needed. The decoder is injectable so
5+
the wrapper is unit-testable without a real QR image. GUI-free.
6+
"""
7+
import io
8+
from pathlib import Path
9+
from typing import Any, Callable, List, Optional, Sequence, Union
10+
11+
from PIL import Image
12+
13+
ImageSource = Union[str, Path, bytes, Image.Image]
14+
QRDecoder = Callable[[Any], List[str]]
15+
16+
17+
def _load_np(source: ImageSource, region: Optional[Sequence[int]]):
18+
import numpy as np
19+
if isinstance(source, Image.Image):
20+
image = source
21+
elif isinstance(source, bytes):
22+
image = Image.open(io.BytesIO(source))
23+
else:
24+
image = Image.open(str(source))
25+
image = image.convert("RGB")
26+
if region is not None:
27+
image = image.crop(tuple(int(v) for v in region))
28+
return np.array(image)
29+
30+
31+
def _default_decoder(image_np: Any) -> List[str]:
32+
import cv2
33+
detector = cv2.QRCodeDetector()
34+
ok, decoded, _points, _straight = detector.detectAndDecodeMulti(image_np)
35+
if not ok or decoded is None:
36+
return []
37+
return [text for text in decoded if text]
38+
39+
40+
def read_qr_codes(source: ImageSource,
41+
region: Optional[Sequence[int]] = None, *,
42+
decoder: Optional[QRDecoder] = None) -> List[str]:
43+
"""Decode every QR code in ``source`` (or a sub-region); return the texts.
44+
45+
``source`` is a path, PNG bytes, or a PIL image. ``decoder`` is
46+
injectable for tests; the default uses OpenCV's ``QRCodeDetector``.
47+
"""
48+
image_np = _load_np(source, region)
49+
return (decoder or _default_decoder)(image_np)

test/unit_test/headless/test_flow_var_commands.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
"""Tests for flow variable commands: read-file, http, transform."""
1+
"""Tests for flow variable commands: read-file, http, transform, now,
2+
random, assert-var."""
3+
import datetime
4+
5+
import pytest
6+
7+
from je_auto_control.utils.exception.exceptions import (
8+
AutoControlAssertionException,
9+
)
210
from je_auto_control.utils.executor import flow_control
311
from je_auto_control.utils.executor.action_executor import Executor
412
from je_auto_control.utils.executor.flow_control import (
5-
exec_http_to_var, exec_read_file_to_var, exec_transform_var,
13+
exec_assert_var, exec_http_to_var, exec_now_to_var, exec_random_to_var,
14+
exec_read_file_to_var, exec_transform_var,
615
)
716

817

@@ -62,3 +71,44 @@ def test_transform_var_replace_and_slice():
6271
exec_transform_var(executor, {"name": "v", "op": "slice",
6372
"start": 0, "end": 3, "into": "s"})
6473
assert executor.variables.get_value("s") == "foo"
74+
75+
76+
def test_now_to_var_formats_injected_clock(monkeypatch):
77+
monkeypatch.setattr(flow_control, "_now",
78+
lambda: datetime.datetime(2026, 1, 2, 3, 4, 5))
79+
executor = Executor()
80+
result = exec_now_to_var(executor, {"format": "%Y-%m-%d", "var": "d"})
81+
assert result["value"] == "2026-01-02"
82+
assert executor.variables.get_value("d") == "2026-01-02"
83+
84+
85+
def test_random_to_var_int_within_fixed_range():
86+
executor = Executor()
87+
exec_random_to_var(executor, {"kind": "int", "min": 1, "max": 1, "var": "r"})
88+
assert executor.variables.get_value("r") == 1
89+
90+
91+
def test_random_to_var_choice():
92+
executor = Executor()
93+
exec_random_to_var(executor, {"kind": "choice", "choices": ["only"],
94+
"var": "c"})
95+
assert executor.variables.get_value("c") == "only"
96+
97+
98+
def test_assert_var_passes_then_raises():
99+
executor = Executor()
100+
executor.variables.set("v", "Sam")
101+
result = exec_assert_var(executor, {"name": "v", "op": "eq",
102+
"value": "Sam"})
103+
assert result["passed"] is True
104+
with pytest.raises(AutoControlAssertionException):
105+
exec_assert_var(executor, {"name": "v", "op": "eq", "value": "Nope"})
106+
107+
108+
def test_assert_var_regex_match():
109+
executor = Executor()
110+
executor.variables.set("v", "Order #12345")
111+
result = exec_assert_var(executor, {"name": "v", "op": "regex",
112+
"value": r"#\d+",
113+
"raise_on_fail": False})
114+
assert result["passed"] is True

0 commit comments

Comments
 (0)