From fcb5910c26b5079b3c8fd5b64c715fd615953859 Mon Sep 17 00:00:00 2001
From: JeffreyChen
Date: Thu, 25 Jun 2026 11:30:10 +0800
Subject: [PATCH 1/4] Clear SonarCloud and Codacy static-analysis backlog
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolve the open SonarCloud (193) and Codacy (3) findings:
- S1172 (141): accessibility backend ABC default methods now pass their
args through to _unsupported(*context), so the explicit signatures
required for fake-backend overrides (W0221) are kept without unused
parameters.
- S1192 (13): extract duplicated literals to module constants
(command_schema "Native UI" x67, placeholders, ".approvals", flow_editor
submodule paths).
- Code smells: merge nested if (http_cassette), flatten nested ternaries
(assertions, color_match), drop redundant FileNotFoundError (notifier),
prune expired leases after iteration instead of list() copy
(credential_broker), extract _signal_parts to cut cognitive complexity
(element_scoring), unused locals -> _ (form_fields), valid NOSONAR
syntax (stats), keep timeout param for 3.10 (webrtc_transport).
- Security S8707 (4): justify the CLI/operator-supplied paths
(config_bundle, turn_config, host_service, stubs) — not a remote
trust boundary.
- Tests: rename Image->pil_image, dict()->literal, comprehension->list(),
drop gratuitous truthiness, reword code-like comments, guard the empty
block, def instead of identity-check lambda, boto3 API names justified.
- Frontend: self->globalThis (sw.js), aria-labels on inputs, valid
NOSONAR on the non-module top-level-await line.
- Codacy: guard QApplication create (W0106), nosemgrep on the protocol
SHA-1 handshake and the local-CLI subprocess test.
---
je_auto_control/gui/flow_editor/__init__.py | 10 +-
.../gui/script_builder/command_schema.py | 218 +++++++++---------
.../utils/accessibility/backends/base.py | 68 +++---
je_auto_control/utils/assertion/assertions.py | 3 +-
.../utils/color_match/color_match.py | 6 +-
.../utils/config_bundle/__main__.py | 3 +-
.../utils/element_scoring/element_scoring.py | 33 ++-
.../utils/executor/action_executor.py | 7 +-
.../utils/form_fields/form_fields.py | 4 +-
.../utils/governance/credential_broker.py | 7 +-
.../utils/http_cassette/http_cassette.py | 12 +-
.../utils/mcp_server/tools/_handlers.py | 8 +-
je_auto_control/utils/notify/notifier.py | 2 +-
.../utils/remote_desktop/host_service.py | 3 +-
.../utils/remote_desktop/turn_config.py | 3 +-
.../remote_desktop/web_viewer/index.html | 6 +-
.../utils/remote_desktop/web_viewer/sw.js | 6 +-
.../utils/remote_desktop/webrtc_transport.py | 8 +-
.../utils/rest_api/dashboard/swagger.html | 2 +-
je_auto_control/utils/stats/stats.py | 8 +-
je_auto_control/utils/stubs/generator.py | 3 +-
.../headless/test_action_effect_batch.py | 2 +-
.../headless/test_canonical_log_batch.py | 1 +
.../headless/test_coordinate_space_batch.py | 6 +-
.../headless/test_critic_features_batch.py | 2 +-
.../headless/test_decision_table_batch.py | 4 +-
test/unit_test/headless/test_diagnostics.py | 2 +-
test/unit_test/headless/test_dotenv_batch.py | 2 +-
.../headless/test_image_dedup_batch.py | 14 +-
.../headless/test_link_header_batch.py | 2 +-
.../headless/test_remote_desktop_cursor.py | 3 +-
.../headless/test_remote_desktop_websocket.py | 2 +-
.../unit_test/headless/test_s3_store_batch.py | 4 +-
.../headless/test_schema_compat_batch.py | 2 +-
.../headless/test_timeseries_batch.py | 2 +-
.../unit_test/headless/test_window_capture.py | 8 +-
36 files changed, 257 insertions(+), 219 deletions(-)
diff --git a/je_auto_control/gui/flow_editor/__init__.py b/je_auto_control/gui/flow_editor/__init__.py
index 46560537..3a8fce49 100644
--- a/je_auto_control/gui/flow_editor/__init__.py
+++ b/je_auto_control/gui/flow_editor/__init__.py
@@ -17,11 +17,13 @@
FlowEdge, FlowLayout, FlowNodePosition, layout_steps,
)
+_SCENE_MODULE = "je_auto_control.gui.flow_editor.scene"
+_TAB_MODULE = "je_auto_control.gui.flow_editor.tab"
_LAZY_SUBMODULES = {
- "FlowEdgeItem": "je_auto_control.gui.flow_editor.scene",
- "FlowGraphScene": "je_auto_control.gui.flow_editor.scene",
- "FlowNodeItem": "je_auto_control.gui.flow_editor.scene",
- "FlowEditorTab": "je_auto_control.gui.flow_editor.tab",
+ "FlowEdgeItem": _SCENE_MODULE,
+ "FlowGraphScene": _SCENE_MODULE,
+ "FlowNodeItem": _SCENE_MODULE,
+ "FlowEditorTab": _TAB_MODULE,
}
diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py
index ba1809b2..3c8c93fd 100644
--- a/je_auto_control/gui/script_builder/command_schema.py
+++ b/je_auto_control/gui/script_builder/command_schema.py
@@ -45,6 +45,16 @@ class CommandSpec:
_MOUSE_BUTTONS = ("mouse_left", "mouse_right", "mouse_middle")
_REGION_PLACEHOLDER = "[left, top, right, bottom]"
+_NATIVE_UI = "Native UI"
+_SCALES_PLACEHOLDER = "[0.9, 1.0, 1.1]"
+_POINT_PLACEHOLDER = "[10, 20]"
+_RECT_PLACEHOLDER = "[x, y, width, height]"
+_RECT4_PLACEHOLDER = "[x, y, w, h]"
+_APPROVALS_DIR = ".approvals"
+_DOTTED_KEY_PLACEHOLDER = "db.host"
+_POINTS_JSON_PLACEHOLDER = '[{"x":..,"y":..,"width":..,"height":..}]'
+_MARKS_JSON_PLACEHOLDER = '[{"role":"button","name":"OK","x":..,"y":..}]'
+_BOXES_JSON_PLACEHOLDER = '[{"role":"button","x":0,"y":0}]'
def _build_specs() -> List[CommandSpec]:
@@ -261,7 +271,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None:
FieldSpec("min_score", FieldType.FLOAT, optional=True, default=0.8,
min_value=0.0, max_value=1.0),
FieldSpec("scales", FieldType.STRING, optional=True,
- placeholder="[0.9, 1.0, 1.1]"),
+ placeholder=_SCALES_PLACEHOLDER),
FieldSpec("region", FieldType.STRING, optional=True,
placeholder=_REGION_PLACEHOLDER),
),
@@ -313,7 +323,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None:
FieldSpec("angles", FieldType.STRING, optional=True,
placeholder="[-10, 0, 10]"),
FieldSpec("scales", FieldType.STRING, optional=True,
- placeholder="[0.9, 1.0, 1.1]"),
+ placeholder=_SCALES_PLACEHOLDER),
FieldSpec("region", FieldType.STRING, optional=True,
placeholder=_REGION_PLACEHOLDER),
),
@@ -328,7 +338,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None:
FieldSpec("angles", FieldType.STRING, optional=True,
placeholder="[-10, 0, 10]"),
FieldSpec("scales", FieldType.STRING, optional=True,
- placeholder="[0.9, 1.0, 1.1]"),
+ placeholder=_SCALES_PLACEHOLDER),
FieldSpec("max_results", FieldType.INT, optional=True, default=20),
FieldSpec("nms_iou", FieldType.FLOAT, optional=True, default=0.3,
min_value=0.0, max_value=1.0),
@@ -344,7 +354,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None:
FieldSpec("ambiguous_ratio", FieldType.FLOAT, optional=True,
default=0.9, min_value=0.0, max_value=1.0),
FieldSpec("scales", FieldType.STRING, optional=True,
- placeholder="[0.9, 1.0, 1.1]"),
+ placeholder=_SCALES_PLACEHOLDER),
FieldSpec("region", FieldType.STRING, optional=True,
placeholder=_REGION_PLACEHOLDER),
),
@@ -378,7 +388,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None:
FieldSpec("min_score", FieldType.FLOAT, optional=True, default=0.7,
min_value=0.0, max_value=1.0),
FieldSpec("scales", FieldType.STRING, optional=True,
- placeholder="[0.9, 1.0, 1.1]"),
+ placeholder=_SCALES_PLACEHOLDER),
FieldSpec("region", FieldType.STRING, optional=True,
placeholder=_REGION_PLACEHOLDER),
),
@@ -684,7 +694,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None:
"AC_fuse_elements", "Image", "Fuse Element Boxes",
fields=(
FieldSpec("ocr", FieldType.STRING, optional=True,
- placeholder='[{"x":..,"y":..,"width":..,"height":..}]'),
+ placeholder=_POINTS_JSON_PLACEHOLDER),
FieldSpec("icon", FieldType.STRING, optional=True),
FieldSpec("a11y", FieldType.STRING, optional=True),
FieldSpec("iou_threshold", FieldType.FLOAT, optional=True, default=0.9,
@@ -696,7 +706,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None:
"AC_reading_order", "Image", "Reading Order",
fields=(
FieldSpec("elements", FieldType.STRING,
- placeholder='[{"x":..,"y":..,"width":..,"height":..}]'),
+ placeholder=_POINTS_JSON_PLACEHOLDER),
FieldSpec("row_tol", FieldType.INT, optional=True, default=12),
),
description="Order element boxes top-to-bottom, left-to-right (+ index).",
@@ -705,7 +715,7 @@ def _add_image_specs(specs: List[CommandSpec]) -> None:
"AC_locate_chain", "Image", "Locate Chain (refine boxes)",
fields=(
FieldSpec("boxes", FieldType.STRING,
- placeholder='[{"x":..,"y":..,"width":..,"height":..}]'),
+ placeholder=_POINTS_JSON_PLACEHOLDER),
FieldSpec("ops", FieldType.STRING,
placeholder='[{"op":"filter","has_text":"OK"},{"op":"first"}]'),
),
@@ -1062,7 +1072,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None:
FieldSpec("paths", FieldType.STRING,
placeholder='["C:\\\\a\\\\one.txt"]'),
FieldSpec("point", FieldType.STRING, optional=True,
- placeholder="[10, 20]"),
+ placeholder=_POINT_PLACEHOLDER),
),
description="Drop files onto a window via WM_DROPFILES (Windows).",
))
@@ -1072,7 +1082,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None:
FieldSpec("paths", FieldType.STRING,
placeholder='["C:\\\\a\\\\one.txt"]'),
FieldSpec("point", FieldType.STRING, optional=True,
- placeholder="[10, 20]"),
+ placeholder=_POINT_PLACEHOLDER),
),
description="Build the WM_DROPFILES payload without sending (pure).",
))
@@ -1220,7 +1230,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None:
"bottom_right", "center", "left_third",
"center_third", "right_third"), default="left"),
FieldSpec("screen", FieldType.STRING, optional=True,
- placeholder="[x, y, width, height]"),
+ placeholder=_RECT_PLACEHOLDER),
FieldSpec("gap", FieldType.INT, optional=True, default=0),
),
description="Compute the rectangle for a tiling slot of the screen.",
@@ -1231,7 +1241,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None:
FieldSpec("rows", FieldType.INT, default=2),
FieldSpec("cols", FieldType.INT, default=2),
FieldSpec("screen", FieldType.STRING, optional=True,
- placeholder="[x, y, width, height]"),
+ placeholder=_RECT_PLACEHOLDER),
FieldSpec("gap", FieldType.INT, optional=True, default=0),
),
description="Compute the cell rectangles of an R×C screen grid.",
@@ -1241,7 +1251,7 @@ def _add_window_specs(specs: List[CommandSpec]) -> None:
fields=(
FieldSpec("count", FieldType.INT, default=3),
FieldSpec("screen", FieldType.STRING, optional=True,
- placeholder="[x, y, width, height]"),
+ placeholder=_RECT_PLACEHOLDER),
FieldSpec("offset", FieldType.INT, optional=True, default=30),
FieldSpec("size", FieldType.STRING, optional=True,
placeholder="[width, height]"),
@@ -1549,67 +1559,67 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
FieldSpec("automation_id", FieldType.STRING, optional=True),
)
specs.append(CommandSpec(
- "AC_control_get_value", "Native UI", "Get Control Value",
+ "AC_control_get_value", _NATIVE_UI, "Get Control Value",
fields=fields,
description="Read a native control's value via the accessibility API.",
))
specs.append(CommandSpec(
- "AC_control_set_value", "Native UI", "Set Control Value",
+ "AC_control_set_value", _NATIVE_UI, "Set Control Value",
fields=(FieldSpec("value", FieldType.STRING),) + fields,
description="Set a native control's value directly (no per-key typing).",
))
specs.append(CommandSpec(
- "AC_control_invoke", "Native UI", "Invoke Control",
+ "AC_control_invoke", _NATIVE_UI, "Invoke Control",
fields=fields,
description="Invoke a native control (e.g. press a button).",
))
specs.append(CommandSpec(
- "AC_control_toggle", "Native UI", "Toggle Control",
+ "AC_control_toggle", _NATIVE_UI, "Toggle Control",
fields=fields,
description="Toggle a native control (e.g. a checkbox).",
))
specs.append(CommandSpec(
- "AC_read_table", "Native UI", "Read Table / Grid",
+ "AC_read_table", _NATIVE_UI, "Read Table / Grid",
fields=fields,
description="Read a grid/table/list control as rows of cell strings.",
))
specs.append(CommandSpec(
- "AC_expand_control", "Native UI", "Expand Control",
+ "AC_expand_control", _NATIVE_UI, "Expand Control",
fields=fields,
description="Expand a tree node / combobox (ExpandCollapsePattern).",
))
specs.append(CommandSpec(
- "AC_collapse_control", "Native UI", "Collapse Control",
+ "AC_collapse_control", _NATIVE_UI, "Collapse Control",
fields=fields,
description="Collapse a tree node / combobox (ExpandCollapsePattern).",
))
specs.append(CommandSpec(
- "AC_control_expand_state", "Native UI", "Control Expand State",
+ "AC_control_expand_state", _NATIVE_UI, "Control Expand State",
fields=fields,
description="Read expanded/collapsed/partial/leaf state of a control.",
))
specs.append(CommandSpec(
- "AC_select_control_item", "Native UI", "Select Control Item",
+ "AC_select_control_item", _NATIVE_UI, "Select Control Item",
fields=fields,
description="Select a list / tree / tab item (SelectionItemPattern).",
))
specs.append(CommandSpec(
- "AC_control_range", "Native UI", "Get Control Range",
+ "AC_control_range", _NATIVE_UI, "Get Control Range",
fields=fields,
description="Read a slider / progress range (RangeValuePattern).",
))
specs.append(CommandSpec(
- "AC_set_control_range", "Native UI", "Set Control Range",
+ "AC_set_control_range", _NATIVE_UI, "Set Control Range",
fields=(FieldSpec("value", FieldType.FLOAT),) + fields,
description="Set a slider / progress / spinner value (RangeValuePattern).",
))
specs.append(CommandSpec(
- "AC_scroll_control_into_view", "Native UI", "Scroll Control Into View",
+ "AC_scroll_control_into_view", _NATIVE_UI, "Scroll Control Into View",
fields=fields,
description="Scroll a control into view (ScrollItemPattern).",
))
specs.append(CommandSpec(
- "AC_realize_item", "Native UI", "Realize Virtualized Item",
+ "AC_realize_item", _NATIVE_UI, "Realize Virtualized Item",
fields=(
FieldSpec("item_name", FieldType.STRING),
FieldSpec("by", FieldType.ENUM, optional=True, default="name",
@@ -1622,124 +1632,124 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
description="Realize an off-screen item in a virtualized list/grid.",
))
specs.append(CommandSpec(
- "AC_get_element_properties", "Native UI", "Get Element Properties",
+ "AC_get_element_properties", _NATIVE_UI, "Get Element Properties",
fields=fields,
description="Read rich UIA props (enabled/offscreen/help/status/keys).",
))
specs.append(CommandSpec(
- "AC_table_headers", "Native UI", "Get Table Headers",
+ "AC_table_headers", _NATIVE_UI, "Get Table Headers",
fields=fields,
description="Read a table's row/column header labels (TablePattern).",
))
specs.append(CommandSpec(
- "AC_table_cell", "Native UI", "Get Table Cell (by index)",
+ "AC_table_cell", _NATIVE_UI, "Get Table Cell (by index)",
fields=(FieldSpec("row", FieldType.INT),
FieldSpec("column", FieldType.INT)) + fields,
description="Read the cell at (row, column) with its span.",
))
specs.append(CommandSpec(
- "AC_cell_by_header", "Native UI", "Get Table Cell (by header)",
+ "AC_cell_by_header", _NATIVE_UI, "Get Table Cell (by header)",
fields=(FieldSpec("row", FieldType.INT),
FieldSpec("column_header", FieldType.STRING)) + fields,
description="Read the cell at (row, named column) — assert by header.",
))
specs.append(CommandSpec(
- "AC_move_element", "Native UI", "Move Element (Transform)",
+ "AC_move_element", _NATIVE_UI, "Move Element (Transform)",
fields=(FieldSpec("x", FieldType.FLOAT),
FieldSpec("y", FieldType.FLOAT)) + fields,
description="Move a UIA element to (x, y) (TransformPattern).",
))
specs.append(CommandSpec(
- "AC_resize_element", "Native UI", "Resize Element (Transform)",
+ "AC_resize_element", _NATIVE_UI, "Resize Element (Transform)",
fields=(FieldSpec("width", FieldType.FLOAT),
FieldSpec("height", FieldType.FLOAT)) + fields,
description="Resize a UIA element (TransformPattern).",
))
specs.append(CommandSpec(
- "AC_set_window_state", "Native UI", "Set Window State",
+ "AC_set_window_state", _NATIVE_UI, "Set Window State",
fields=(FieldSpec("state", FieldType.ENUM, default="normal",
choices=("normal", "maximized", "minimized")),) + fields,
description="Minimize / maximize / restore a window (WindowPattern).",
))
specs.append(CommandSpec(
- "AC_window_interaction_state", "Native UI", "Window Interaction State",
+ "AC_window_interaction_state", _NATIVE_UI, "Window Interaction State",
fields=fields,
description="Read window readiness (ready/blocked_by_modal/...).",
))
specs.append(CommandSpec(
- "AC_legacy_info", "Native UI", "Legacy (MSAA) Info",
+ "AC_legacy_info", _NATIVE_UI, "Legacy (MSAA) Info",
fields=fields,
description="Read an old control's MSAA info (LegacyIAccessible).",
))
specs.append(CommandSpec(
- "AC_legacy_default_action", "Native UI", "Legacy (MSAA) Default Action",
+ "AC_legacy_default_action", _NATIVE_UI, "Legacy (MSAA) Default Action",
fields=fields,
description="Fire an old control's MSAA default action (fallback).",
))
specs.append(CommandSpec(
- "AC_get_selection", "Native UI", "Get Container Selection",
+ "AC_get_selection", _NATIVE_UI, "Get Container Selection",
fields=fields,
description="Read a container's selection (SelectionPattern).",
))
specs.append(CommandSpec(
- "AC_list_views", "Native UI", "List Control Views",
+ "AC_list_views", _NATIVE_UI, "List Control Views",
fields=fields,
description="List a control's selectable views (MultipleViewPattern).",
))
specs.append(CommandSpec(
- "AC_set_view", "Native UI", "Set Control View",
+ "AC_set_view", _NATIVE_UI, "Set Control View",
fields=(FieldSpec("view", FieldType.STRING),) + fields,
description="Switch a control to the named view (MultipleViewPattern).",
))
specs.append(CommandSpec(
- "AC_wait_for_focus_change", "Native UI", "Wait for Focus Change",
+ "AC_wait_for_focus_change", _NATIVE_UI, "Wait for Focus Change",
fields=(FieldSpec("timeout", FieldType.FLOAT, optional=True,
default=5.0),),
description="Block until keyboard focus moves (real UIA focus event).",
))
specs.append(CommandSpec(
- "AC_get_control_text", "Native UI", "Get Control Text",
+ "AC_get_control_text", _NATIVE_UI, "Get Control Text",
fields=fields,
description="Read full text via TextPattern (multiline / document safe).",
))
specs.append(CommandSpec(
- "AC_find_control_text", "Native UI", "Find Text in Control",
+ "AC_find_control_text", _NATIVE_UI, "Find Text in Control",
fields=(FieldSpec("text", FieldType.STRING),
FieldSpec("ignore_case", FieldType.BOOL, optional=True,
default=True)) + fields,
description="Whether text occurs in a control (TextPattern.FindText).",
))
specs.append(CommandSpec(
- "AC_select_control_text", "Native UI", "Select Text in Control",
+ "AC_select_control_text", _NATIVE_UI, "Select Text in Control",
fields=(FieldSpec("text", FieldType.STRING),
FieldSpec("ignore_case", FieldType.BOOL, optional=True,
default=True)) + fields,
description="Find + select text in a control (FindText + Select).",
))
specs.append(CommandSpec(
- "AC_control_text_attributes", "Native UI", "Get Text Attributes",
+ "AC_control_text_attributes", _NATIVE_UI, "Get Text Attributes",
fields=fields,
description="Read selection formatting (font/size/bold/italic/colour).",
))
specs.append(CommandSpec(
- "AC_get_selected_text", "Native UI", "Get Selected Text",
+ "AC_get_selected_text", _NATIVE_UI, "Get Selected Text",
fields=fields,
description="Read the currently selected text via TextPattern.",
))
specs.append(CommandSpec(
- "AC_get_visible_text", "Native UI", "Get Visible Text",
+ "AC_get_visible_text", _NATIVE_UI, "Get Visible Text",
fields=fields,
description="Read only the on-screen text via TextPattern.GetVisibleRanges.",
))
specs.append(CommandSpec(
- "AC_walk_tree", "Native UI", "Walk Accessibility Tree",
+ "AC_walk_tree", _NATIVE_UI, "Walk Accessibility Tree",
fields=(FieldSpec("app_name", FieldType.STRING, optional=True),
FieldSpec("max_results", FieldType.INT, optional=True,
default=500)),
description="Dump the a11y tree with friendly roles + a path per node.",
))
specs.append(CommandSpec(
- "AC_humanize_role", "Native UI", "Humanize UIA Role",
+ "AC_humanize_role", _NATIVE_UI, "Humanize UIA Role",
fields=(FieldSpec("role", FieldType.STRING),),
description="Translate a raw UIA role (ControlType_50000) to a name.",
))
@@ -1747,17 +1757,17 @@ def _add_native_control_specs(specs: List[CommandSpec]) -> None:
FieldSpec("max_results", FieldType.INT, optional=True,
default=500))
specs.append(CommandSpec(
- "AC_tab_order", "Native UI", "Keyboard Tab Order",
+ "AC_tab_order", _NATIVE_UI, "Keyboard Tab Order",
fields=tree_fields,
description="List focusable controls in keyboard Tab (reading) order.",
))
specs.append(CommandSpec(
- "AC_audit_focus_order", "Native UI", "Audit Focus Order (WCAG)",
+ "AC_audit_focus_order", _NATIVE_UI, "Audit Focus Order (WCAG)",
fields=tree_fields,
description="WCAG 2.4.x focus-order audit: tab sequence + flagged issues.",
))
specs.append(CommandSpec(
- "AC_focus_control", "Native UI", "Set Keyboard Focus",
+ "AC_focus_control", _NATIVE_UI, "Set Keyboard Focus",
fields=fields,
description="Set keyboard focus on a control natively (UIA SetFocus).",
))
@@ -1862,7 +1872,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None:
description="Generate a TOTP 2FA code from a base32 secret.",
))
specs.append(CommandSpec(
- "AC_handle_file_dialog", "Native UI", "Handle File Dialog",
+ "AC_handle_file_dialog", _NATIVE_UI, "Handle File Dialog",
fields=(
FieldSpec("path", FieldType.STRING),
FieldSpec("action", FieldType.ENUM,
@@ -2055,7 +2065,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None:
FieldSpec("name", FieldType.STRING, placeholder="login_screen"),
FieldSpec("content", FieldType.STRING),
FieldSpec("approvals_dir", FieldType.STRING, optional=True,
- default=".approvals"),
+ default=_APPROVALS_DIR),
FieldSpec("extension", FieldType.STRING, optional=True,
default="txt"),
),
@@ -2066,7 +2076,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None:
fields=(
FieldSpec("name", FieldType.STRING),
FieldSpec("approvals_dir", FieldType.STRING, optional=True,
- default=".approvals"),
+ default=_APPROVALS_DIR),
FieldSpec("extension", FieldType.STRING, optional=True,
default="txt"),
),
@@ -2075,7 +2085,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None:
specs.append(CommandSpec(
"AC_pending_artifacts", "Testing", "Approval: List Pending",
fields=(FieldSpec("approvals_dir", FieldType.STRING, optional=True,
- default=".approvals"),),
+ default=_APPROVALS_DIR),),
description="List artifacts awaiting approval.",
))
specs.append(CommandSpec(
@@ -2730,7 +2740,7 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None:
FieldSpec("key", FieldType.STRING),
FieldSpec("method", FieldType.STRING, placeholder="vlm/image"),
FieldSpec("coordinates", FieldType.STRING, optional=True,
- placeholder="[10, 20]"),
+ placeholder=_POINT_PLACEHOLDER),
FieldSpec("description", FieldType.STRING, optional=True),
FieldSpec("confidence", FieldType.FLOAT, optional=True,
default=1.0),
@@ -3247,7 +3257,7 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None:
fields=(
FieldSpec("layers", FieldType.STRING,
placeholder='[{"name": "defaults", "mapping": {}}]'),
- FieldSpec("key", FieldType.STRING, placeholder="db.host"),
+ FieldSpec("key", FieldType.STRING, placeholder=_DOTTED_KEY_PLACEHOLDER),
),
description="Show the value and winning layer for a dotted config key.",
))
@@ -3375,7 +3385,7 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None:
"AC_cas_put", "Flow", "Optimistic: Put (CAS)",
fields=(
FieldSpec("name", FieldType.STRING, placeholder="config"),
- FieldSpec("key", FieldType.STRING, placeholder="db.host"),
+ FieldSpec("key", FieldType.STRING, placeholder=_DOTTED_KEY_PLACEHOLDER),
FieldSpec("value", FieldType.STRING, placeholder='"prod-1"'),
FieldSpec("expected_version", FieldType.INT, optional=True),
),
@@ -3385,7 +3395,7 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None:
"AC_cas_get", "Flow", "Optimistic: Get",
fields=(
FieldSpec("name", FieldType.STRING, placeholder="config"),
- FieldSpec("key", FieldType.STRING, placeholder="db.host"),
+ FieldSpec("key", FieldType.STRING, placeholder=_DOTTED_KEY_PLACEHOLDER),
),
description="Read a versioned record {value, version}.",
))
@@ -3621,21 +3631,21 @@ def _add_input_macro_specs(specs: List[CommandSpec]) -> None:
def _add_screen_state_specs(specs: List[CommandSpec]) -> None:
app = FieldSpec("app_name", FieldType.STRING, optional=True)
specs.append(CommandSpec(
- "AC_screen_snapshot", "Native UI", "Screen: Snapshot Baseline",
+ "AC_screen_snapshot", _NATIVE_UI, "Screen: Snapshot Baseline",
fields=(app,),
description="Snapshot the a11y tree as a semantic-diff baseline.",
))
specs.append(CommandSpec(
- "AC_screen_diff", "Native UI", "Screen: Diff Snapshots",
+ "AC_screen_diff", _NATIVE_UI, "Screen: Diff Snapshots",
description="Semantic diff of 'before'/'after' snapshots (JSON view).",
))
specs.append(CommandSpec(
- "AC_screen_changed", "Native UI", "Screen: What Changed",
+ "AC_screen_changed", _NATIVE_UI, "Screen: What Changed",
fields=(app,),
description="Diff the live screen against the last snapshot baseline.",
))
specs.append(CommandSpec(
- "AC_describe_screen", "Native UI", "Screen: Describe",
+ "AC_describe_screen", _NATIVE_UI, "Screen: Describe",
fields=(app,),
description="Structured 'where am I' (role counts + control labels).",
))
@@ -3643,7 +3653,7 @@ def _add_screen_state_specs(specs: List[CommandSpec]) -> None:
def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
specs.append(CommandSpec(
- "AC_cua_command", "Native UI", "Computer-Use: Map Action",
+ "AC_cua_command", _NATIVE_UI, "Computer-Use: Map Action",
fields=(
FieldSpec("payload", FieldType.STRING,
placeholder='{"action":"left_click","coordinate":[x,y]}'),
@@ -3653,43 +3663,43 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="Map an Anthropic / OpenAI computer-use action to an AC command.",
))
specs.append(CommandSpec(
- "AC_serialize_observation", "Native UI", "Observation: Serialize Elements",
+ "AC_serialize_observation", _NATIVE_UI, "Observation: Serialize Elements",
fields=(
FieldSpec("elements", FieldType.STRING,
- placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'),
+ placeholder=_MARKS_JSON_PLACEHOLDER),
FieldSpec("viewport", FieldType.STRING, optional=True,
- placeholder="[x, y, w, h]"),
+ placeholder=_RECT4_PLACEHOLDER),
FieldSpec("max_elements", FieldType.INT, optional=True, default=80),
),
description="Indexed text observation of UI elements for a VLM (act by index).",
))
specs.append(CommandSpec(
- "AC_observation_index", "Native UI", "Observation: Index Elements",
+ "AC_observation_index", _NATIVE_UI, "Observation: Index Elements",
fields=(
FieldSpec("elements", FieldType.STRING,
- placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'),
+ placeholder=_MARKS_JSON_PLACEHOLDER),
FieldSpec("viewport", FieldType.STRING, optional=True,
- placeholder="[x, y, w, h]"),
+ placeholder=_RECT4_PLACEHOLDER),
FieldSpec("max_elements", FieldType.INT, optional=True, default=80),
),
description="Reading-ordered, viewport-clipped, indexed element list.",
))
specs.append(CommandSpec(
- "AC_delta_observation", "Native UI", "Observation: Delta (what changed)",
+ "AC_delta_observation", _NATIVE_UI, "Observation: Delta (what changed)",
fields=(
FieldSpec("prev", FieldType.STRING,
- placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'),
+ placeholder=_MARKS_JSON_PLACEHOLDER),
FieldSpec("curr", FieldType.STRING,
- placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'),
+ placeholder=_MARKS_JSON_PLACEHOLDER),
FieldSpec("viewport", FieldType.STRING, optional=True,
- placeholder="[x, y, w, h]"),
+ placeholder=_RECT4_PLACEHOLDER),
FieldSpec("max_elements", FieldType.INT, optional=True, default=80),
FieldSpec("max_lines", FieldType.INT, optional=True, default=40),
),
description="Token-budgeted '+/~/-' summary of what changed between frames.",
))
specs.append(CommandSpec(
- "AC_classify_effect", "Native UI", "Classify Action Effect",
+ "AC_classify_effect", _NATIVE_UI, "Classify Action Effect",
fields=(
FieldSpec("before", FieldType.STRING,
placeholder='[{"role":"button","name":"OK","x":0,"y":0}]'),
@@ -3702,19 +3712,19 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="Did the action change the screen near its target? (no_op/…).",
))
specs.append(CommandSpec(
- "AC_effect_near_point", "Native UI", "Effect Near Point?",
+ "AC_effect_near_point", _NATIVE_UI, "Effect Near Point?",
fields=(
FieldSpec("before", FieldType.STRING,
- placeholder='[{"role":"button","x":0,"y":0}]'),
+ placeholder=_BOXES_JSON_PLACEHOLDER),
FieldSpec("after", FieldType.STRING,
- placeholder='[{"role":"button","x":0,"y":0}]'),
+ placeholder=_BOXES_JSON_PLACEHOLDER),
FieldSpec("point", FieldType.STRING, placeholder="[50, 50]"),
FieldSpec("radius", FieldType.INT, optional=True, default=64),
),
description="Did any before/after change land within radius of a point?",
))
specs.append(CommandSpec(
- "AC_check_postcondition", "Native UI", "Check Postcondition",
+ "AC_check_postcondition", _NATIVE_UI, "Check Postcondition",
fields=(
FieldSpec("after", FieldType.STRING,
placeholder='[{"role":"dialog","name":"Saved"}]'),
@@ -3727,7 +3737,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="Check expected outcome clauses against after/before frames.",
))
specs.append(CommandSpec(
- "AC_plan_repair", "Native UI", "Plan Repair Tactics",
+ "AC_plan_repair", _NATIVE_UI, "Plan Repair Tactics",
fields=(
FieldSpec("verdict", FieldType.STRING,
placeholder="no_op / changed_elsewhere / changed"),
@@ -3736,7 +3746,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="Ordered repair tactics for a failed/no-effect action verdict.",
))
specs.append(CommandSpec(
- "AC_consensus_point", "Native UI", "Grounding Consensus Point",
+ "AC_consensus_point", _NATIVE_UI, "Grounding Consensus Point",
fields=(
FieldSpec("candidates", FieldType.STRING,
placeholder="[[100, 100], [104, 98], [97, 103]]"),
@@ -3755,12 +3765,12 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="Index where a churn series first settles (offline settle check).",
))
specs.append(CommandSpec(
- "AC_build_critic_record", "Native UI", "Build Critic Record",
+ "AC_build_critic_record", _NATIVE_UI, "Build Critic Record",
fields=(
FieldSpec("action", FieldType.STRING,
placeholder='{"type":"click","x":50,"y":50}'),
FieldSpec("before", FieldType.STRING,
- placeholder='[{"role":"button","x":0,"y":0}]'),
+ placeholder=_BOXES_JSON_PLACEHOLDER),
FieldSpec("after", FieldType.STRING,
placeholder='[{"role":"dialog","x":40,"y":40}]'),
FieldSpec("postcondition", FieldType.STRING, optional=True,
@@ -3770,7 +3780,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="Per-step critic evidence (effect + delta + postcondition).",
))
specs.append(CommandSpec(
- "AC_score_step", "Native UI", "Score Step (rule-based)",
+ "AC_score_step", _NATIVE_UI, "Score Step (rule-based)",
fields=(
FieldSpec("record", FieldType.STRING,
placeholder='{"effect":{"effect":"changed_near_target"}}'),
@@ -3778,45 +3788,45 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="Rule-based outcome + process score of a critic record.",
))
specs.append(CommandSpec(
- "AC_consensus_element", "Native UI", "Grounding Consensus Element",
+ "AC_consensus_element", _NATIVE_UI, "Grounding Consensus Element",
fields=(
FieldSpec("candidates", FieldType.STRING,
placeholder="[[8, 8], [12, 10]]"),
FieldSpec("elements", FieldType.STRING,
- placeholder='[{"role":"button","x":0,"y":0}]'),
+ placeholder=_BOXES_JSON_PLACEHOLDER),
),
description="Vote grounding proposals to the nearest element.",
))
specs.append(CommandSpec(
- "AC_validate_action", "Native UI", "Validate / Snap Action",
+ "AC_validate_action", _NATIVE_UI, "Validate / Snap Action",
fields=(
FieldSpec("action", FieldType.STRING,
placeholder='{"type":"click","x":..,"y":..}'),
FieldSpec("screen", FieldType.STRING, optional=True,
placeholder="[width, height]"),
FieldSpec("targets", FieldType.STRING, optional=True,
- placeholder='[{"x":..,"y":..,"width":..,"height":..}]'),
+ placeholder=_POINTS_JSON_PLACEHOLDER),
),
description="Reject out-of-bounds clicks; snap a near-miss to the nearest "
"element.",
))
specs.append(CommandSpec(
- "AC_match_elements", "Native UI", "Match Elements (frames)",
+ "AC_match_elements", _NATIVE_UI, "Match Elements (frames)",
fields=(
FieldSpec("before", FieldType.STRING,
- placeholder='[{"x":..,"y":..,"width":..,"height":..}]'),
+ placeholder=_POINTS_JSON_PLACEHOLDER),
FieldSpec("after", FieldType.STRING,
- placeholder='[{"x":..,"y":..,"width":..,"height":..}]'),
+ placeholder=_POINTS_JSON_PLACEHOLDER),
FieldSpec("iou_threshold", FieldType.FLOAT, optional=True, default=0.5,
min_value=0.0, max_value=1.0),
),
description="Match element boxes across two frames by overlap (move/rename).",
))
specs.append(CommandSpec(
- "AC_assign_stable_ids", "Native UI", "Assign Stable Element IDs",
+ "AC_assign_stable_ids", _NATIVE_UI, "Assign Stable Element IDs",
fields=(
FieldSpec("elements", FieldType.STRING,
- placeholder='[{"x":..,"y":..,"width":..,"height":..}]'),
+ placeholder=_POINTS_JSON_PLACEHOLDER),
FieldSpec("prior", FieldType.STRING, optional=True,
placeholder="prior frame's elements (with ids)"),
FieldSpec("iou_threshold", FieldType.FLOAT, optional=True, default=0.5,
@@ -3825,10 +3835,10 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="Tag elements with IDs carried across frames by overlap.",
))
specs.append(CommandSpec(
- "AC_score_candidates", "Native UI", "Score Candidates",
+ "AC_score_candidates", _NATIVE_UI, "Score Candidates",
fields=(
FieldSpec("candidates", FieldType.STRING,
- placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'),
+ placeholder=_MARKS_JSON_PLACEHOLDER),
FieldSpec("want_role", FieldType.STRING, optional=True),
FieldSpec("want_name", FieldType.STRING, optional=True),
FieldSpec("anchor", FieldType.STRING, optional=True,
@@ -3837,10 +3847,10 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="Rank candidate elements by role / name / proximity confidence.",
))
specs.append(CommandSpec(
- "AC_best_candidate", "Native UI", "Best Candidate",
+ "AC_best_candidate", _NATIVE_UI, "Best Candidate",
fields=(
FieldSpec("candidates", FieldType.STRING,
- placeholder='[{"role":"button","name":"OK","x":..,"y":..}]'),
+ placeholder=_MARKS_JSON_PLACEHOLDER),
FieldSpec("want_role", FieldType.STRING, optional=True),
FieldSpec("want_name", FieldType.STRING, optional=True),
FieldSpec("anchor", FieldType.STRING, optional=True,
@@ -3849,7 +3859,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
description="The single highest-scoring candidate element.",
))
specs.append(CommandSpec(
- "AC_mark_screen", "Native UI", "Set-of-Marks: Number Elements",
+ "AC_mark_screen", _NATIVE_UI, "Set-of-Marks: Number Elements",
fields=(
FieldSpec("app_name", FieldType.STRING, optional=True),
FieldSpec("render_path", FieldType.FILE_PATH, optional=True),
@@ -3858,7 +3868,7 @@ def _add_set_of_marks_specs(specs: List[CommandSpec]) -> None:
"grounding; optional numbered-box overlay screenshot.",
))
specs.append(CommandSpec(
- "AC_mark_click", "Native UI", "Set-of-Marks: Click Number",
+ "AC_mark_click", _NATIVE_UI, "Set-of-Marks: Click Number",
fields=(FieldSpec("mark_id", FieldType.INT),),
description="Click the element behind a numbered mark.",
))
@@ -4085,7 +4095,7 @@ def _add_authoring_specs(specs: List[CommandSpec]) -> None:
path = FieldSpec("path", FieldType.FILE_PATH)
key = FieldSpec("key", FieldType.STRING)
specs.append(CommandSpec(
- "AC_element_save", "Native UI", "Element: Save Locator",
+ "AC_element_save", _NATIVE_UI, "Element: Save Locator",
fields=(path, key,
FieldSpec("name", FieldType.STRING, optional=True),
FieldSpec("role", FieldType.STRING, optional=True),
@@ -4093,22 +4103,22 @@ def _add_authoring_specs(specs: List[CommandSpec]) -> None:
description="Save a named native-UI locator (object repository).",
))
specs.append(CommandSpec(
- "AC_element_find", "Native UI", "Element: Find Saved",
+ "AC_element_find", _NATIVE_UI, "Element: Find Saved",
fields=(path, key),
description="Resolve a saved locator to a live element summary.",
))
specs.append(CommandSpec(
- "AC_element_click", "Native UI", "Element: Click Saved",
+ "AC_element_click", _NATIVE_UI, "Element: Click Saved",
fields=(path, key),
description="Click the element behind a saved locator.",
))
specs.append(CommandSpec(
- "AC_element_remove", "Native UI", "Element: Remove Saved",
+ "AC_element_remove", _NATIVE_UI, "Element: Remove Saved",
fields=(path, key),
description="Delete a saved locator.",
))
specs.append(CommandSpec(
- "AC_element_list", "Native UI", "Element: List Saved",
+ "AC_element_list", _NATIVE_UI, "Element: List Saved",
fields=(path,),
description="List saved locator names in a repository file.",
))
diff --git a/je_auto_control/utils/accessibility/backends/base.py b/je_auto_control/utils/accessibility/backends/base.py
index 4b86ab54..3b1f6637 100644
--- a/je_auto_control/utils/accessibility/backends/base.py
+++ b/je_auto_control/utils/accessibility/backends/base.py
@@ -29,32 +29,32 @@ def get_value(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> Optional[str]:
"""Return the matched control's value text, or None if not found."""
- self._unsupported("get_value")
+ self._unsupported("get_value", name, role, app_name, automation_id)
def set_value(self, value: str, name: Optional[str] = None,
role: Optional[str] = None, app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Set the matched control's value; return True on success."""
- self._unsupported("set_value")
+ self._unsupported("set_value", value, name, role, app_name, automation_id)
def invoke(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Invoke the matched control (e.g. press a button)."""
- self._unsupported("invoke")
+ self._unsupported("invoke", name, role, app_name, automation_id)
def toggle(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Toggle the matched control (e.g. a checkbox)."""
- self._unsupported("toggle")
+ self._unsupported("toggle", name, role, app_name, automation_id)
def read_table(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None,
) -> List[List[str]]:
"""Read a grid/table/list control as rows of cell strings."""
- self._unsupported("read_table")
+ self._unsupported("read_table", name, role, app_name, automation_id)
# --- extended control patterns (Expand / Selection / Range / Scroll) ----
@@ -62,43 +62,43 @@ def expand(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Expand the matched control (ExpandCollapsePattern); True on success."""
- self._unsupported("expand")
+ self._unsupported("expand", name, role, app_name, automation_id)
def collapse(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Collapse the matched control (ExpandCollapsePattern); True on success."""
- self._unsupported("collapse")
+ self._unsupported("collapse", name, role, app_name, automation_id)
def expand_state(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> Optional[str]:
"""Return ``expanded`` / ``collapsed`` / ``partial`` / ``leaf``, or None."""
- self._unsupported("expand_state")
+ self._unsupported("expand_state", name, role, app_name, automation_id)
def select_item(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Select the matched item (SelectionItemPattern); True on success."""
- self._unsupported("select_item")
+ self._unsupported("select_item", name, role, app_name, automation_id)
def get_range(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Return ``{value, minimum, maximum}`` (RangeValuePattern), or None."""
- self._unsupported("get_range")
+ self._unsupported("get_range", name, role, app_name, automation_id)
def set_range_value(self, value: float, name: Optional[str] = None,
role: Optional[str] = None, app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Set a slider / progress value (RangeValuePattern); True on success."""
- self._unsupported("set_range_value")
+ self._unsupported("set_range_value", value, name, role, app_name, automation_id)
def scroll_into_view(self, name: Optional[str] = None,
role: Optional[str] = None, app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Scroll the matched control into view (ScrollItemPattern); True on success."""
- self._unsupported("scroll_into_view")
+ self._unsupported("scroll_into_view", name, role, app_name, automation_id)
# --- text patterns (TextPattern reads) ---------------------------------
@@ -109,33 +109,33 @@ def document_text(self, name: Optional[str] = None, role: Optional[str] = None,
Reads multiline / document controls where ValuePattern returns ``""``.
"""
- self._unsupported("document_text")
+ self._unsupported("document_text", name, role, app_name, automation_id)
def selected_text(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> Optional[str]:
"""Return the control's currently selected text (TextPattern), or None."""
- self._unsupported("selected_text")
+ self._unsupported("selected_text", name, role, app_name, automation_id)
def visible_text(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> Optional[str]:
"""Return only the on-screen text of the control (TextPattern), or None."""
- self._unsupported("visible_text")
+ self._unsupported("visible_text", name, role, app_name, automation_id)
def find_text(self, text: str = "", ignore_case: bool = True,
name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Return whether ``text`` occurs in the control (TextPattern.FindText)."""
- self._unsupported("find_text")
+ self._unsupported("find_text", text, ignore_case, name, role, app_name, automation_id)
def select_text(self, text: str = "", ignore_case: bool = True,
name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Find ``text`` and select its range (TextPattern.FindText + Select)."""
- self._unsupported("select_text")
+ self._unsupported("select_text", text, ignore_case, name, role, app_name, automation_id)
def text_attributes(self, name: Optional[str] = None,
role: Optional[str] = None, app_name: Optional[str] = None,
@@ -143,7 +143,7 @@ def text_attributes(self, name: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Return formatting of the control's selection — ``{font_name, font_size,
bold, italic, foreground_color}`` (TextPattern attributes), or None."""
- self._unsupported("text_attributes")
+ self._unsupported("text_attributes", name, role, app_name, automation_id)
# --- keyboard focus ----------------------------------------------------
@@ -151,7 +151,7 @@ def set_focus(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Set keyboard focus on the matched control (SetFocus); True on success."""
- self._unsupported("set_focus")
+ self._unsupported("set_focus", name, role, app_name, automation_id)
# --- virtualized items (realize off-screen list / grid items) -----------
@@ -168,7 +168,7 @@ def find_virtual_item(self, item_name: Optional[str] = None, by: str = "name",
(``VirtualizedItemPattern``) so it exists as a real element. Returns the
realized element, or None if the container or item isn't found.
"""
- self._unsupported("find_virtual_item")
+ self._unsupported("find_virtual_item", item_name, by, container_name, container_role, app_name, automation_id)
# --- rich element properties -------------------------------------------
@@ -182,7 +182,7 @@ def get_properties(self, name: Optional[str] = None,
``enabled`` / ``offscreen`` / ``help_text`` / ``item_status`` /
``accelerator_key`` / ``access_key`` / ``orientation``.
"""
- self._unsupported("get_properties")
+ self._unsupported("get_properties", name, role, app_name, automation_id)
# --- table headers + cell addressing (TablePattern / GridItemPattern) ---
@@ -192,7 +192,7 @@ def get_table_headers(self, name: Optional[str] = None,
automation_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Return a table's header labels as ``{columns: [...], rows: [...]}``."""
- self._unsupported("get_table_headers")
+ self._unsupported("get_table_headers", name, role, app_name, automation_id)
def get_grid_cell(self, row: int = 0, column: int = 0,
name: Optional[str] = None, role: Optional[str] = None,
@@ -201,7 +201,7 @@ def get_grid_cell(self, row: int = 0, column: int = 0,
) -> Optional[Dict[str, Any]]:
"""Return the cell at ``(row, column)`` as ``{value, row, column,
row_span, column_span}`` (GridPattern.GetItem + GridItemPattern)."""
- self._unsupported("get_grid_cell")
+ self._unsupported("get_grid_cell", row, column, name, role, app_name, automation_id)
# --- transform + window patterns (UIA-element-level) --------------------
@@ -210,21 +210,21 @@ def move_element(self, x: float = 0.0, y: float = 0.0,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Move the matched element to ``(x, y)`` (TransformPattern); True on success."""
- self._unsupported("move_element")
+ self._unsupported("move_element", x, y, name, role, app_name, automation_id)
def resize_element(self, width: float = 0.0, height: float = 0.0,
name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Resize the matched element (TransformPattern); True on success."""
- self._unsupported("resize_element")
+ self._unsupported("resize_element", width, height, name, role, app_name, automation_id)
def set_window_state(self, state: str = "normal",
name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Set a window's visual state ``normal`` / ``maximized`` / ``minimized``."""
- self._unsupported("set_window_state")
+ self._unsupported("set_window_state", state, name, role, app_name, automation_id)
def window_interaction_state(self, name: Optional[str] = None,
role: Optional[str] = None,
@@ -233,7 +233,7 @@ def window_interaction_state(self, name: Optional[str] = None,
) -> Optional[str]:
"""Return a window's interaction state — ``ready`` / ``blocked_by_modal`` /
``not_responding`` / ``running`` / ``closing`` (WindowPattern), or None."""
- self._unsupported("window_interaction_state")
+ self._unsupported("window_interaction_state", name, role, app_name, automation_id)
# --- MSAA bridge (LegacyIAccessiblePattern) ----------------------------
@@ -247,7 +247,7 @@ def legacy_info(self, name: Optional[str] = None, role: Optional[str] = None,
last-resort read for legacy Win32 controls that expose nothing useful via
the modern UIA patterns.
"""
- self._unsupported("legacy_info")
+ self._unsupported("legacy_info", name, role, app_name, automation_id)
def legacy_default_action(self, name: Optional[str] = None,
role: Optional[str] = None,
@@ -255,7 +255,7 @@ def legacy_default_action(self, name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Fire an old control's MSAA default action (DoDefaultAction); True on
success — the fallback when Value / Invoke / Toggle all do nothing."""
- self._unsupported("legacy_default_action")
+ self._unsupported("legacy_default_action", name, role, app_name, automation_id)
# --- container selection + views (Selection / MultipleView patterns) ----
@@ -265,7 +265,7 @@ def get_selection(self, name: Optional[str] = None, role: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Return a container's selection state — ``{items, can_select_multiple,
is_required}`` (SelectionPattern), or None."""
- self._unsupported("get_selection")
+ self._unsupported("get_selection", name, role, app_name, automation_id)
def list_views(self, name: Optional[str] = None, role: Optional[str] = None,
app_name: Optional[str] = None,
@@ -273,13 +273,13 @@ def list_views(self, name: Optional[str] = None, role: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Return a control's selectable views — ``{current, views: [...]}``
(MultipleViewPattern: list / details / tile / …), or None."""
- self._unsupported("list_views")
+ self._unsupported("list_views", name, role, app_name, automation_id)
def set_view(self, view: str = "", name: Optional[str] = None,
role: Optional[str] = None, app_name: Optional[str] = None,
automation_id: Optional[str] = None) -> bool:
"""Switch a control to the named view (MultipleViewPattern); True on success."""
- self._unsupported("set_view")
+ self._unsupported("set_view", view, name, role, app_name, automation_id)
# --- reactive events (UIA event subscription) --------------------------
@@ -291,9 +291,9 @@ def wait_for_focus_change(self, timeout: float = 5.0,
A zero-latency native wait (UIA AddFocusChangedEventHandler) — unlike the
polling recorder, it can't miss a fast focus transition.
"""
- self._unsupported("wait_for_focus_change")
+ self._unsupported("wait_for_focus_change", timeout)
- def _unsupported(self, operation: str):
+ def _unsupported(self, operation: str, *context: Any):
"""Raise a clear error for an action this backend can't perform."""
raise AccessibilityNotAvailableError(
f"{operation} is not supported by the {self.name} backend",
diff --git a/je_auto_control/utils/assertion/assertions.py b/je_auto_control/utils/assertion/assertions.py
index 2a80caee..f4214069 100644
--- a/je_auto_control/utils/assertion/assertions.py
+++ b/je_auto_control/utils/assertion/assertions.py
@@ -489,11 +489,12 @@ def assert_by_description(description: str,
)
passed = (matched == present)
state = "shows" if present else "does not show"
+ verdict = "match" if matched else "no match"
message = (
f"assert_by_description passed: screen {state} {description!r}"
if passed else
f"assert_by_description failed: expected screen to {state} "
- f"{description!r} (VLM verdict: {'match' if matched else 'no match'})"
+ f"{description!r} (VLM verdict: {verdict})"
)
return _finalize(
"vlm", passed, message,
diff --git a/je_auto_control/utils/color_match/color_match.py b/je_auto_control/utils/color_match/color_match.py
index a0858fc3..c7dd7cd0 100644
--- a/je_auto_control/utils/color_match/color_match.py
+++ b/je_auto_control/utils/color_match/color_match.py
@@ -26,8 +26,10 @@
def _hsv(source, region, is_haystack: bool):
import cv2
- rgb = (_to_rgb(source) if source is not None
- else _grab_rgb(region)) if is_haystack else _to_rgb(source)
+ if is_haystack:
+ rgb = _to_rgb(source) if source is not None else _grab_rgb(region)
+ else:
+ rgb = _to_rgb(source)
return cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV)
diff --git a/je_auto_control/utils/config_bundle/__main__.py b/je_auto_control/utils/config_bundle/__main__.py
index 880d4674..f57395a7 100644
--- a/je_auto_control/utils/config_bundle/__main__.py
+++ b/je_auto_control/utils/config_bundle/__main__.py
@@ -64,7 +64,8 @@ def _do_export(output: Path, root: Optional[Path]) -> int:
def _do_import(source: Path, root: Optional[Path], dry_run: bool) -> int:
try:
- bundle = json.loads(source.read_text(encoding="utf-8"))
+ # source is an operator-supplied CLI path, not remote input
+ bundle = json.loads(source.read_text(encoding="utf-8")) # NOSONAR
except (OSError, ValueError) as error:
print(f"failed to read {source}: {error}", file=sys.stderr)
return 2
diff --git a/je_auto_control/utils/element_scoring/element_scoring.py b/je_auto_control/utils/element_scoring/element_scoring.py
index 4c84ddf2..4d614260 100644
--- a/je_auto_control/utils/element_scoring/element_scoring.py
+++ b/je_auto_control/utils/element_scoring/element_scoring.py
@@ -40,6 +40,26 @@ def _proximity(element: Element, anchor: Sequence[int]) -> float:
return 1.0 / (1.0 + distance / 100.0)
+def _signal_parts(element: Element, want_role: Optional[str],
+ want_name: Optional[str],
+ similarity: Callable[[str, str], float],
+ prefer_enabled: bool,
+ anchor: Optional[Sequence[int]]) -> Dict[str, float]:
+ """Build the per-signal 0..1 breakdown for one ``element``."""
+ parts: Dict[str, float] = {}
+ if want_role is not None:
+ parts["role"] = (1.0 if str(element.get("role", "")).lower()
+ == str(want_role).lower() else 0.0)
+ if want_name is not None:
+ parts["name"] = float(similarity(want_name,
+ str(element.get("name", ""))))
+ if anchor is not None:
+ parts["proximity"] = _proximity(element, anchor)
+ if prefer_enabled:
+ parts["enabled"] = 1.0 if element.get("enabled", True) else 0.0
+ return parts
+
+
def score_candidates(candidates: Sequence[Element], *,
want_role: Optional[str] = None,
want_name: Optional[str] = None,
@@ -57,17 +77,8 @@ def score_candidates(candidates: Sequence[Element], *,
similarity = name_similarity or fuzzy_ratio
scored: List[ScoredCandidate] = []
for element in candidates:
- parts: Dict[str, float] = {}
- if want_role is not None:
- parts["role"] = (1.0 if str(element.get("role", "")).lower()
- == str(want_role).lower() else 0.0)
- if want_name is not None:
- parts["name"] = float(similarity(want_name,
- str(element.get("name", ""))))
- if anchor is not None:
- parts["proximity"] = _proximity(element, anchor)
- if prefer_enabled:
- parts["enabled"] = 1.0 if element.get("enabled", True) else 0.0
+ parts = _signal_parts(element, want_role, want_name, similarity,
+ prefer_enabled, anchor)
score = sum(parts.values()) / len(parts) if parts else 0.0
scored.append(ScoredCandidate(element, round(score, 4), parts))
scored.sort(key=lambda candidate: candidate.score, reverse=True)
diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py
index 9f8cf9b3..0fcd3a21 100644
--- a/je_auto_control/utils/executor/action_executor.py
+++ b/je_auto_control/utils/executor/action_executor.py
@@ -171,6 +171,7 @@ def _run_dag(definition: Dict[str, Any],
_AX_RECORDER_SINGLETON = None
+_DEFAULT_APPROVALS_DIR = ".approvals"
def _a11y_dump(app_name: Optional[str] = None,
@@ -6029,7 +6030,7 @@ def _egress_reset() -> Dict[str, Any]:
def _verify_artifact(name: str, content: Any,
- approvals_dir: str = ".approvals",
+ approvals_dir: str = _DEFAULT_APPROVALS_DIR,
extension: str = "txt") -> Dict[str, Any]:
"""Adapter: verify an artifact against its approved baseline."""
from je_auto_control.utils.approval import verify_artifact
@@ -6039,14 +6040,14 @@ def _verify_artifact(name: str, content: Any,
"received_path": result.received_path}
-def _approve_artifact(name: str, approvals_dir: str = ".approvals",
+def _approve_artifact(name: str, approvals_dir: str = _DEFAULT_APPROVALS_DIR,
extension: str = "txt") -> Dict[str, Any]:
"""Adapter: promote a received artifact to the approved baseline."""
from je_auto_control.utils.approval import approve_artifact
return {"approved": approve_artifact(name, approvals_dir, extension)}
-def _pending_artifacts(approvals_dir: str = ".approvals") -> Dict[str, Any]:
+def _pending_artifacts(approvals_dir: str = _DEFAULT_APPROVALS_DIR) -> Dict[str, Any]:
"""Adapter: list artifacts awaiting approval."""
from je_auto_control.utils.approval import pending_artifacts
return {"pending": pending_artifacts(approvals_dir)}
diff --git a/je_auto_control/utils/form_fields/form_fields.py b/je_auto_control/utils/form_fields/form_fields.py
index 5fb646df..edb9eb3b 100644
--- a/je_auto_control/utils/form_fields/form_fields.py
+++ b/je_auto_control/utils/form_fields/form_fields.py
@@ -26,7 +26,7 @@ def _overlap_1d(a0: int, a1: int, b0: int, b1: int) -> int:
def _right_value(label: Box, values: Sequence[Box], max_gap: int):
"""Nearest value to the right of ``label`` that shares a row, or ``None``."""
- left, top, right, bottom = _box_bounds(label)
+ _, top, right, bottom = _box_bounds(label)
best: Optional[Tuple[Box, int]] = None
for value in values:
vl, vt, _, vb = _box_bounds(value)
@@ -39,7 +39,7 @@ def _right_value(label: Box, values: Sequence[Box], max_gap: int):
def _below_value(label: Box, values: Sequence[Box], max_gap: int):
"""Nearest value below ``label`` that shares a column, or ``None``."""
- left, top, right, bottom = _box_bounds(label)
+ left, _, right, bottom = _box_bounds(label)
best: Optional[Tuple[Box, int]] = None
for value in values:
vl, vt, vr, _ = _box_bounds(value)
diff --git a/je_auto_control/utils/governance/credential_broker.py b/je_auto_control/utils/governance/credential_broker.py
index ef4dab72..4325a887 100644
--- a/je_auto_control/utils/governance/credential_broker.py
+++ b/je_auto_control/utils/governance/credential_broker.py
@@ -86,13 +86,16 @@ def active(self) -> List[Dict[str, object]]:
"""List non-expired leases as ``{token, name, ttl_remaining}`` (no values)."""
now = self._clock()
result: List[Dict[str, object]] = []
- for token, lease in list(self._leases.items()):
+ expired: List[str] = []
+ for token, lease in self._leases.items():
remaining = float(lease["expires_at"]) - now
if remaining > 0:
result.append({"token": token, "name": lease["name"],
"ttl_remaining": remaining})
else:
- self._leases.pop(token, None)
+ expired.append(token)
+ for token in expired:
+ self._leases.pop(token, None)
return result
diff --git a/je_auto_control/utils/http_cassette/http_cassette.py b/je_auto_control/utils/http_cassette/http_cassette.py
index 443c62cc..676cc30b 100644
--- a/je_auto_control/utils/http_cassette/http_cassette.py
+++ b/je_auto_control/utils/http_cassette/http_cassette.py
@@ -41,15 +41,9 @@ def _matches(recorded: Mapping[str, Any], call: Mapping[str, Any],
match_on: Sequence[str]) -> bool:
view = _request_view(call)
for field in match_on:
- if field == "method":
- if recorded.get("method") != view["method"]:
- return False
- elif field == "url":
- if recorded.get("url") != view["url"]:
- return False
- elif field == "body":
- if recorded.get("body") != view["body"]:
- return False
+ if field in ("method", "url", "body") and \
+ recorded.get(field) != view[field]:
+ return False
return True
diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py
index 8069a557..4128e73d 100644
--- a/je_auto_control/utils/mcp_server/tools/_handlers.py
+++ b/je_auto_control/utils/mcp_server/tools/_handlers.py
@@ -12,6 +12,8 @@
from je_auto_control.utils.mcp_server.tools._base import MCPContent
+_DEFAULT_APPROVALS_DIR = ".approvals"
+
# === Mouse / keyboard =======================================================
@@ -1515,7 +1517,7 @@ def egress_reset():
return {"allow": None, "deny": []}
-def verify_artifact(name: str, content, approvals_dir: str = ".approvals",
+def verify_artifact(name: str, content, approvals_dir: str = _DEFAULT_APPROVALS_DIR,
extension: str = "txt"):
from je_auto_control.utils.approval import verify_artifact as _verify
result = _verify(name, content, approvals_dir, extension)
@@ -1524,13 +1526,13 @@ def verify_artifact(name: str, content, approvals_dir: str = ".approvals",
"received_path": result.received_path}
-def approve_artifact(name: str, approvals_dir: str = ".approvals",
+def approve_artifact(name: str, approvals_dir: str = _DEFAULT_APPROVALS_DIR,
extension: str = "txt"):
from je_auto_control.utils.approval import approve_artifact as _approve
return {"approved": _approve(name, approvals_dir, extension)}
-def pending_artifacts(approvals_dir: str = ".approvals"):
+def pending_artifacts(approvals_dir: str = _DEFAULT_APPROVALS_DIR):
from je_auto_control.utils.approval import pending_artifacts as _pending
return {"pending": _pending(approvals_dir)}
diff --git a/je_auto_control/utils/notify/notifier.py b/je_auto_control/utils/notify/notifier.py
index df5e59ca..fbd0e83e 100644
--- a/je_auto_control/utils/notify/notifier.py
+++ b/je_auto_control/utils/notify/notifier.py
@@ -86,6 +86,6 @@ def notify(title: str, message: str,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
return NotifyResult(True, system, "sent")
- except (FileNotFoundError, OSError, subprocess.SubprocessError) as error:
+ except (OSError, subprocess.SubprocessError) as error:
autocontrol_logger.warning("notify failed: %r", error)
return NotifyResult(False, system, repr(error))
diff --git a/je_auto_control/utils/remote_desktop/host_service.py b/je_auto_control/utils/remote_desktop/host_service.py
index ee6e0782..ed58841a 100644
--- a/je_auto_control/utils/remote_desktop/host_service.py
+++ b/je_auto_control/utils/remote_desktop/host_service.py
@@ -217,7 +217,8 @@ def _interactive_configure() -> int:
)
answers["poll_interval_s"] = 2.0
_DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
- _DEFAULT_CONFIG_PATH.write_text(
+ # _DEFAULT_CONFIG_PATH is a hardcoded module constant, not user input
+ _DEFAULT_CONFIG_PATH.write_text( # NOSONAR
json.dumps(answers, indent=2), encoding="utf-8",
)
try:
diff --git a/je_auto_control/utils/remote_desktop/turn_config.py b/je_auto_control/utils/remote_desktop/turn_config.py
index aa04f15c..a9373a32 100644
--- a/je_auto_control/utils/remote_desktop/turn_config.py
+++ b/je_auto_control/utils/remote_desktop/turn_config.py
@@ -142,7 +142,8 @@ def write_bundle(output_dir: Path, *, realm: str, user: str,
secret: str, listen_port: int, tls_port: int,
tls_cert: Optional[str], tls_key: Optional[str],
external_ip: Optional[str]) -> None:
- output_dir.mkdir(parents=True, exist_ok=True)
+ # output_dir is an operator-supplied CLI path, not remote input
+ output_dir.mkdir(parents=True, exist_ok=True) # NOSONAR
conf_path = output_dir / "turnserver.conf"
conf_path.write_text(render_turnserver_conf(
realm=realm, listen_port=listen_port, tls_port=tls_port,
diff --git a/je_auto_control/utils/remote_desktop/web_viewer/index.html b/je_auto_control/utils/remote_desktop/web_viewer/index.html
index efadd0cc..f371c074 100644
--- a/je_auto_control/utils/remote_desktop/web_viewer/index.html
+++ b/je_auto_control/utils/remote_desktop/web_viewer/index.html
@@ -108,8 +108,8 @@
-
-
Bearer token
-
Token kept in sessionStorage; cleared on tab close.
diff --git a/je_auto_control/utils/stats/stats.py b/je_auto_control/utils/stats/stats.py
index 08187a6c..4ace2f4e 100644
--- a/je_auto_control/utils/stats/stats.py
+++ b/je_auto_control/utils/stats/stats.py
@@ -88,11 +88,11 @@ def _betacf(a: float, b: float, x: float) -> float:
def _betai(a: float, b: float, x: float) -> float:
# x is a domain ratio in [0, 1]; both boundaries are reachable (e.g. x == 1
- # when the t-statistic is 0). NOSONAR: the analyzer mis-models these as
- # constant, but they are genuine, exercised guards.
- if x <= 0: # NOSONAR python:S2583 reason: reachable beta-domain lower bound
+ # when the t-statistic is 0); the analyzer mis-models these as constant,
+ # but they are genuine, exercised guards.
+ if x <= 0: # NOSONAR reachable beta-domain lower bound, not constant
return 0.0
- if x >= 1: # NOSONAR python:S2583 reason: reachable beta-domain upper bound
+ if x >= 1: # NOSONAR reachable beta-domain upper bound, not constant
return 1.0
front = math.exp(math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b)
+ a * math.log(x) + b * math.log(1 - x))
diff --git a/je_auto_control/utils/stubs/generator.py b/je_auto_control/utils/stubs/generator.py
index 8eaad392..fcec4444 100644
--- a/je_auto_control/utils/stubs/generator.py
+++ b/je_auto_control/utils/stubs/generator.py
@@ -88,7 +88,8 @@ def write_pyi(target: Path,
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_text(body, encoding="utf-8")
- tmp.replace(target)
+ # target is an operator-supplied CLI path, not remote input
+ tmp.replace(target) # NOSONAR
return target
diff --git a/test/unit_test/headless/test_action_effect_batch.py b/test/unit_test/headless/test_action_effect_batch.py
index cf691b73..694f1693 100644
--- a/test/unit_test/headless/test_action_effect_batch.py
+++ b/test/unit_test/headless/test_action_effect_batch.py
@@ -6,7 +6,7 @@
def _el(x, y, name="", role="button"):
- return dict(x=x, y=y, width=40, height=20, role=role, name=name)
+ return {"x": x, "y": y, "width": 40, "height": 20, "role": role, "name": name}
def test_no_op_when_nothing_changes():
diff --git a/test/unit_test/headless/test_canonical_log_batch.py b/test/unit_test/headless/test_canonical_log_batch.py
index 87e30203..de55b766 100644
--- a/test/unit_test/headless/test_canonical_log_batch.py
+++ b/test/unit_test/headless/test_canonical_log_batch.py
@@ -20,6 +20,7 @@ def test_timer_uses_injected_clock():
ticks = iter([10.0, 10.5])
line = CanonicalLogLine(clock=lambda: next(ticks))
with line.timer("step"):
+ # body intentionally empty: the timer measures the enter->exit interval
pass
assert line.to_dict()["step_ms"] == pytest.approx(500.0)
diff --git a/test/unit_test/headless/test_coordinate_space_batch.py b/test/unit_test/headless/test_coordinate_space_batch.py
index 93ae65bb..0196276c 100644
--- a/test/unit_test/headless/test_coordinate_space_batch.py
+++ b/test/unit_test/headless/test_coordinate_space_batch.py
@@ -43,14 +43,14 @@ def test_clamping_is_in_bounds():
def test_downscale_png_matches_model_size():
- Image = pytest.importorskip("PIL.Image")
+ pil_image = pytest.importorskip("PIL.Image")
import io
buf = io.BytesIO()
- Image.new("RGB", (640, 480), (1, 2, 3)).save(buf, format="PNG")
+ pil_image.new("RGB", (640, 480), (1, 2, 3)).save(buf, format="PNG")
from je_auto_control.utils.coordinate_space import downscale_png
space = normalized_space(640, 480, grid=64)
out = downscale_png(buf.getvalue(), space)
- with Image.open(io.BytesIO(out)) as resized:
+ with pil_image.open(io.BytesIO(out)) as resized:
assert resized.size == (64, 64)
diff --git a/test/unit_test/headless/test_critic_features_batch.py b/test/unit_test/headless/test_critic_features_batch.py
index 7e0fbd12..daa565cc 100644
--- a/test/unit_test/headless/test_critic_features_batch.py
+++ b/test/unit_test/headless/test_critic_features_batch.py
@@ -6,7 +6,7 @@
def _el(x, y, name="", role="button"):
- return dict(x=x, y=y, width=40, height=20, role=role, name=name)
+ return {"x": x, "y": y, "width": 40, "height": 20, "role": role, "name": name}
def test_record_captures_effect_and_delta():
diff --git a/test/unit_test/headless/test_decision_table_batch.py b/test/unit_test/headless/test_decision_table_batch.py
index 1bf92a52..f3a76acf 100644
--- a/test/unit_test/headless/test_decision_table_batch.py
+++ b/test/unit_test/headless/test_decision_table_batch.py
@@ -49,9 +49,9 @@ def test_unique_multi_match_raises():
def test_wildcard_and_literal_conditions():
spec = {"inputs": ["role", "active"], "hit_policy": "FIRST", "rules": [
- {"conditions": {"role": "admin", "active": None}, # active = wildcard
+ {"conditions": {"role": "admin", "active": None}, # active -> wildcard
"outputs": {"allow": True}},
- {"conditions": {"role": "-", "active": True}, # role = wildcard
+ {"conditions": {"role": "-", "active": True}, # role -> wildcard
"outputs": {"allow": False}},
]}
assert evaluate_table(spec, {"role": "admin", "active": False}) == \
diff --git a/test/unit_test/headless/test_diagnostics.py b/test/unit_test/headless/test_diagnostics.py
index 0602ff40..2f207e35 100644
--- a/test/unit_test/headless/test_diagnostics.py
+++ b/test/unit_test/headless/test_diagnostics.py
@@ -43,7 +43,7 @@ def test_to_dict_payload_shape():
def test_cli_exits_zero_when_all_green():
"""The CLI module should respect the runner's overall ``ok`` flag."""
- completed = subprocess.run( # noqa: S603 # local CLI test
+ completed = subprocess.run( # noqa: S603 # nosemgrep # local CLI test
[sys.executable, "-m", "je_auto_control.utils.diagnostics"],
capture_output=True, text=True, timeout=30, check=False,
)
diff --git a/test/unit_test/headless/test_dotenv_batch.py b/test/unit_test/headless/test_dotenv_batch.py
index a659d9fa..4b76bafa 100644
--- a/test/unit_test/headless/test_dotenv_batch.py
+++ b/test/unit_test/headless/test_dotenv_batch.py
@@ -44,7 +44,7 @@ def test_load_dotenv_file(tmp_path):
path.write_text(_TEXT, encoding="utf-8")
env = {"PLAIN": "keep"}
load_dotenv(str(path), env)
- assert env["PLAIN"] == "keep" and env["TOKEN"] == "secret"
+ assert env["PLAIN"] == "keep" and env["TOKEN"] == "secret" # NOSONAR load_dotenv mutates env in place
load_dotenv(str(path), env, override=True)
assert env["PLAIN"] == "hello" # override replaces
assert dotenv_values(str(path))["TOKEN"] == "secret"
diff --git a/test/unit_test/headless/test_image_dedup_batch.py b/test/unit_test/headless/test_image_dedup_batch.py
index efa16619..32b08be3 100644
--- a/test/unit_test/headless/test_image_dedup_batch.py
+++ b/test/unit_test/headless/test_image_dedup_batch.py
@@ -10,7 +10,7 @@
def test_hamming_distance_and_similar():
assert hamming_distance("00", "00") == 0
- assert hamming_distance("0f", "00") == 4 # 0x0f = 1111
+ assert hamming_distance("0f", "00") == 4 # 0x0f -> 1111 bits
assert images_similar("ff", "fe", max_distance=1) is True
assert images_similar("ff", "f0", max_distance=1) is False
@@ -34,11 +34,11 @@ def test_dedupe_empty():
def test_real_pillow_hashing(tmp_path):
- Image = pytest.importorskip("PIL.Image")
+ pil_image = pytest.importorskip("PIL.Image")
black = tmp_path / "black.png"
white = tmp_path / "white.png"
- Image.new("RGB", (64, 64), (0, 0, 0)).save(black)
- Image.new("RGB", (64, 64), (255, 255, 255)).save(white)
+ pil_image.new("RGB", (64, 64), (0, 0, 0)).save(black)
+ pil_image.new("RGB", (64, 64), (255, 255, 255)).save(white)
h_black = average_hash(str(black))
assert isinstance(h_black, str) and h_black
@@ -54,11 +54,11 @@ def test_real_pillow_hashing(tmp_path):
# --- wiring ---------------------------------------------------------------
def test_executor_round_trip(tmp_path):
- Image = pytest.importorskip("PIL.Image")
+ pil_image = pytest.importorskip("PIL.Image")
a = tmp_path / "a.png"
b = tmp_path / "b.png"
- Image.new("RGB", (32, 32), (10, 10, 10)).save(a)
- Image.new("RGB", (32, 32), (10, 10, 10)).save(b) # identical
+ pil_image.new("RGB", (32, 32), (10, 10, 10)).save(a)
+ pil_image.new("RGB", (32, 32), (10, 10, 10)).save(b) # identical
rec = ac.execute_action([
["AC_dedupe_images", {"paths": [str(a), str(b)], "max_distance": 2}],
])
diff --git a/test/unit_test/headless/test_link_header_batch.py b/test/unit_test/headless/test_link_header_batch.py
index ef93cf00..30b3b213 100644
--- a/test/unit_test/headless/test_link_header_batch.py
+++ b/test/unit_test/headless/test_link_header_batch.py
@@ -46,7 +46,7 @@ def test_paginate_follows_next_over_injected_fetch():
}
responses = paginate("u1", lambda url: pages[url])
assert len(responses) == 3
- assert responses is not None and responses[-1]["headers"] == {}
+ assert responses[-1]["headers"] == {}
def test_paginate_respects_max_pages():
diff --git a/test/unit_test/headless/test_remote_desktop_cursor.py b/test/unit_test/headless/test_remote_desktop_cursor.py
index 715da144..d5412a0d 100644
--- a/test/unit_test/headless/test_remote_desktop_cursor.py
+++ b/test/unit_test/headless/test_remote_desktop_cursor.py
@@ -137,7 +137,8 @@ def test_frame_display_paints_cursor_overlay():
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
pytest.importorskip("PySide6.QtWidgets")
from PySide6.QtWidgets import QApplication
- QApplication.instance() or QApplication([])
+ if QApplication.instance() is None:
+ QApplication([])
from je_auto_control.gui.remote_desktop.frame_display import _FrameDisplay
display = _FrameDisplay()
diff --git a/test/unit_test/headless/test_remote_desktop_websocket.py b/test/unit_test/headless/test_remote_desktop_websocket.py
index 935d6ad6..e4638cbd 100644
--- a/test/unit_test/headless/test_remote_desktop_websocket.py
+++ b/test/unit_test/headless/test_remote_desktop_websocket.py
@@ -121,7 +121,7 @@ def client_side():
sec_key = line.split(":", 1)[1].strip()
import base64
import hashlib
- accept = base64.b64encode(hashlib.sha1( # nosec B324
+ accept = base64.b64encode(hashlib.sha1( # nosec B324 # nosemgrep
(sec_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii"),
usedforsecurity=False,
).digest()).decode("ascii")
diff --git a/test/unit_test/headless/test_s3_store_batch.py b/test/unit_test/headless/test_s3_store_batch.py
index 8be38546..e10ad8bc 100644
--- a/test/unit_test/headless/test_s3_store_batch.py
+++ b/test/unit_test/headless/test_s3_store_batch.py
@@ -23,12 +23,12 @@ def upload_file(self, filename, bucket, key):
def download_file(self, bucket, key, filename):
Path(filename).write_bytes(self.objects[(bucket, key)])
- def list_objects_v2(self, Bucket, Prefix=""):
+ def list_objects_v2(self, Bucket, Prefix=""): # NOSONAR boto3 API names
keys = [k for (b, k) in self.objects if b == Bucket
and k.startswith(Prefix)]
return {"Contents": [{"Key": k} for k in sorted(keys)]}
- def delete_object(self, Bucket, Key):
+ def delete_object(self, Bucket, Key): # NOSONAR boto3 API names
self.objects.pop((Bucket, Key), None)
diff --git a/test/unit_test/headless/test_schema_compat_batch.py b/test/unit_test/headless/test_schema_compat_batch.py
index 8c0c6922..270fc199 100644
--- a/test/unit_test/headless/test_schema_compat_batch.py
+++ b/test/unit_test/headless/test_schema_compat_batch.py
@@ -24,7 +24,7 @@ def test_added_required_breaks_backward():
"required": ["id", "email"]}
assert is_backward_compatible(_BASE, new) is False
assert is_forward_compatible(_BASE, new) is True
- [change] = [c for c in check_compatibility(_BASE, new)["breaking"]]
+ [change] = list(check_compatibility(_BASE, new)["breaking"])
assert change["kind"] == "field_added" and change["path"] == "email"
diff --git a/test/unit_test/headless/test_timeseries_batch.py b/test/unit_test/headless/test_timeseries_batch.py
index fd1a4ac7..b6233924 100644
--- a/test/unit_test/headless/test_timeseries_batch.py
+++ b/test/unit_test/headless/test_timeseries_batch.py
@@ -18,7 +18,7 @@ def test_rate_and_increase():
def test_rate_handles_counter_reset():
series = [(0, 90), (10, 100), (20, 5), (30, 25)] # reset between 100 and 5
- # increase = (100-90) + reset(5) + (25-5) = 10 + 5 + 20 = 35
+ # increase: (100-90) + reset(5) + (25-5) -> 10 + 5 + 20 -> 35
assert ts_increase(series) == pytest.approx(35.0)
assert ts_rate(series) == pytest.approx(35.0 / 30)
diff --git a/test/unit_test/headless/test_window_capture.py b/test/unit_test/headless/test_window_capture.py
index 22eaaea3..e17b2bc7 100644
--- a/test/unit_test/headless/test_window_capture.py
+++ b/test/unit_test/headless/test_window_capture.py
@@ -89,8 +89,12 @@ def mover(title, x, y, width, height):
def test_snap_window_right_half():
rects = []
- snap_window("E", "right", screen_size=lambda: (1000, 800),
- mover=lambda t, x, y, w, h: rects.append((x, y, w, h)) is None)
+
+ def mover(_title, x, y, w, h):
+ rects.append((x, y, w, h))
+ return 1
+
+ snap_window("E", "right", screen_size=lambda: (1000, 800), mover=mover)
assert rects == [(500, 0, 500, 800)]
From 4950b3700d208d6dc28c48e9d94c6dbce0255015 Mon Sep 17 00:00:00 2001
From: JeffreyChen
Date: Fri, 26 Jun 2026 04:47:53 +0800
Subject: [PATCH 2/4] Add system_volume: absolute read/set master volume and
mute
Replace the blind media-key nudges with read-backable, absolute control
of the default output device (percent 0-100 + mute), behind an injectable
VolumeDriver seam so all logic is unit-tested without an audio device.
The default driver uses Windows Core Audio via the optional pycaw extra.
---
WHATS_NEW.md | 8 +
.../doc/new_features/v206_features_doc.rst | 67 +++++++
.../Zh/doc/new_features/v206_features_doc.rst | 60 ++++++
je_auto_control/__init__.py | 7 +
.../gui/script_builder/command_schema.py | 33 ++++
.../utils/executor/action_executor.py | 35 ++++
.../utils/mcp_server/tools/_factories.py | 43 +++++
.../utils/mcp_server/tools/_handlers.py | 25 +++
.../utils/system_volume/__init__.py | 12 ++
.../utils/system_volume/system_volume.py | 182 ++++++++++++++++++
pyproject.toml | 1 +
.../headless/test_system_volume_batch.py | 124 ++++++++++++
12 files changed, 597 insertions(+)
create mode 100644 docs/source/Eng/doc/new_features/v206_features_doc.rst
create mode 100644 docs/source/Zh/doc/new_features/v206_features_doc.rst
create mode 100644 je_auto_control/utils/system_volume/__init__.py
create mode 100644 je_auto_control/utils/system_volume/system_volume.py
create mode 100644 test/unit_test/headless/test_system_volume_batch.py
diff --git a/WHATS_NEW.md b/WHATS_NEW.md
index 3be380b4..04b77bb1 100644
--- a/WHATS_NEW.md
+++ b/WHATS_NEW.md
@@ -1,5 +1,13 @@
# What's New — AutoControl
+## What's new (2026-06-26)
+
+### Read and Control the System Volume
+
+Set a known audio baseline before a run — mute, set 30%, or assert the level. Full reference: [`docs/source/Eng/doc/new_features/v206_features_doc.rst`](docs/source/Eng/doc/new_features/v206_features_doc.rst).
+
+- **`get_volume` / `set_volume` / `change_volume` / `is_muted` / `set_mute` / `mute` / `unmute` / `toggle_mute`** (`AC_get_volume`, `AC_set_volume`, `AC_change_volume`, `AC_set_mute`, `AC_toggle_mute`): the framework only had the blind media-key steps (`volume up` / `down` nudge by an unknown amount with no read-back). This adds absolute, read-backable control of the default output device — read or set the master level as an integer percent `0..100`, and read / set / toggle the mute flag. All logic (clamping, percent↔scalar conversion, toggle) is pure and runs through an injectable `VolumeDriver` seam, so it is fully unit-tested without an audio device; the default driver uses the Windows Core Audio `IAudioEndpointVolume` interface through the optional `pycaw` dependency (`pip install je_auto_control[audio]`), degrading with a clear error when absent. Fourth feature of the ROUND-15 cross-app OS lane. No `PySide6`.
+
## What's new (2026-06-25)
### Resolve the App Registered for a File Type
diff --git a/docs/source/Eng/doc/new_features/v206_features_doc.rst b/docs/source/Eng/doc/new_features/v206_features_doc.rst
new file mode 100644
index 00000000..30feb059
--- /dev/null
+++ b/docs/source/Eng/doc/new_features/v206_features_doc.rst
@@ -0,0 +1,67 @@
+Read and Control the System Volume
+==================================
+
+Unattended runs often need a known audio baseline — mute before a noisy batch,
+restore a level afterwards, or assert the current volume — but the framework
+only had the blind media-key steps (``volume up`` / ``down`` nudge by an unknown
+amount with no read-back). ``system_volume`` adds absolute, read-backable
+control of the default output device.
+
+* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` — read and
+ write the master level as an integer percent ``0..100`` (``set_volume`` and
+ ``change_volume`` clamp to that range).
+* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` /
+ :func:`toggle_mute` — read and write the mute flag.
+
+All logic (clamping, percent <-> scalar conversion, toggle) is pure and runs
+through an injectable :class:`VolumeDriver` seam, so it is fully testable without
+an audio device. The default driver drives the Windows Core Audio
+``IAudioEndpointVolume`` interface through the optional ``pycaw`` dependency
+(``pip install je_auto_control[audio]``); on a platform / install without it the
+default driver raises a clear error telling the caller to pass ``driver=``.
+Imports no ``PySide6``.
+
+Headless API
+------------
+
+.. code-block:: python
+
+ from je_auto_control import (
+ get_volume, set_volume, change_volume, is_muted, mute, unmute,
+ toggle_mute,
+ )
+
+ get_volume() # e.g. 65 — current master volume percent
+ set_volume(30) # set to 30 %, returns 30
+ change_volume(-10) # lower by 10 %, returns the applied percent
+ is_muted() # False
+ mute() # True — silence the output
+ unmute() # False — restore it
+ toggle_mute() # flip and return the new state
+
+For tests (or any non-Windows host) pass a ``driver`` — any object exposing
+``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` over a ``0.0..1.0``
+scalar:
+
+.. code-block:: python
+
+ class FakeVolume:
+ def __init__(self, scalar=0.5, muted=False):
+ self.scalar, self.muted = scalar, muted
+ def get_scalar(self): return self.scalar
+ def set_scalar(self, s): self.scalar = s
+ def get_mute(self): return self.muted
+ def set_mute(self, m): self.muted = m
+
+ drv = FakeVolume()
+ set_volume(73, driver=drv) # 73, drv.scalar == 0.73
+
+Executor commands
+-----------------
+
+``AC_get_volume`` (→ ``{volume, muted}``), ``AC_set_volume`` (``level`` →
+``{volume}``), ``AC_change_volume`` (``delta`` → ``{volume}``), ``AC_set_mute``
+(``muted`` → ``{muted}``) and ``AC_toggle_mute`` (→ ``{muted}``). They are
+exposed as the matching ``ac_*`` MCP tools (the read is read-only, the writes
+side-effect-only) and as Script Builder commands under **Shell**. The executor
+and MCP layers use the default OS driver, so they require ``pycaw`` on Windows.
diff --git a/docs/source/Zh/doc/new_features/v206_features_doc.rst b/docs/source/Zh/doc/new_features/v206_features_doc.rst
new file mode 100644
index 00000000..a6617bbd
--- /dev/null
+++ b/docs/source/Zh/doc/new_features/v206_features_doc.rst
@@ -0,0 +1,60 @@
+讀取與控制系統音量
+==================
+
+無人值守的執行常需要一個已知的音訊基準——在吵雜的批次前靜音、結束後還原音量,或斷言目前音量——但框架
+原本只有盲目的媒體鍵步驟(``volume up`` / ``down`` 以未知幅度推移,且無法讀回)。``system_volume``
+補上對預設輸出裝置的絕對、可讀回控制。
+
+* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` ——以整數百分比 ``0..100``
+ 讀寫主音量(``set_volume`` 與 ``change_volume`` 會夾到該範圍)。
+* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` /
+ :func:`toggle_mute` ——讀寫靜音旗標。
+
+所有邏輯(夾值、百分比 <-> 純量轉換、切換)皆為純函式,並透過可注入的 :class:`VolumeDriver` 接縫執行,
+故能在不需音訊裝置的情況下完整測試。預設 driver 透過選用相依套件 ``pycaw``
+(``pip install je_auto_control[audio]``)驅動 Windows Core Audio 的
+``IAudioEndpointVolume`` 介面;在沒有該套件 / 非 Windows 平台上,預設 driver 會丟出清楚的錯誤,
+提示呼叫端傳入 ``driver=``。不匯入 ``PySide6``。
+
+無頭 API
+--------
+
+.. code-block:: python
+
+ from je_auto_control import (
+ get_volume, set_volume, change_volume, is_muted, mute, unmute,
+ toggle_mute,
+ )
+
+ get_volume() # 例如 65 ——目前主音量百分比
+ set_volume(30) # 設為 30 %,回傳 30
+ change_volume(-10) # 降低 10 %,回傳套用後的百分比
+ is_muted() # False
+ mute() # True ——使輸出靜音
+ unmute() # False ——還原
+ toggle_mute() # 切換並回傳新狀態
+
+測試時(或任何非 Windows 主機)可傳入 ``driver`` ——任何以 ``0.0..1.0`` 純量提供
+``get_scalar`` / ``set_scalar`` / ``get_mute`` / ``set_mute`` 的物件:
+
+.. code-block:: python
+
+ class FakeVolume:
+ def __init__(self, scalar=0.5, muted=False):
+ self.scalar, self.muted = scalar, muted
+ def get_scalar(self): return self.scalar
+ def set_scalar(self, s): self.scalar = s
+ def get_mute(self): return self.muted
+ def set_mute(self, m): self.muted = m
+
+ drv = FakeVolume()
+ set_volume(73, driver=drv) # 73,drv.scalar == 0.73
+
+執行器指令
+----------
+
+``AC_get_volume``(→ ``{volume, muted}``)、``AC_set_volume``(``level`` →
+``{volume}``)、``AC_change_volume``(``delta`` → ``{volume}``)、``AC_set_mute``
+(``muted`` → ``{muted}``)與 ``AC_toggle_mute``(→ ``{muted}``)。皆以對應的 ``ac_*``
+MCP 工具(讀取為唯讀、寫入為僅副作用)及 Script Builder 指令(位於 **Shell** 分類下)形式提供。
+執行器與 MCP 層使用預設 OS driver,故在 Windows 上需要 ``pycaw``。
diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py
index c360611f..6d6bb3f4 100644
--- a/je_auto_control/__init__.py
+++ b/je_auto_control/__init__.py
@@ -100,6 +100,11 @@
)
# Resolve which application is registered to open a given file type
from je_auto_control.utils.file_assoc import file_association, normalize_ext
+# Read and control the system master volume and mute state
+from je_auto_control.utils.system_volume import (
+ change_volume, get_volume, is_muted, mute, set_mute, set_volume,
+ toggle_mute, unmute,
+)
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
from je_auto_control.utils.clipboard_rich_formats import (
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1713,6 +1718,8 @@ def start_autocontrol_gui(*args, **kwargs):
"idle_seconds", "is_idle", "plan_keep_awake",
"keep_awake", "keep_awake_on", "allow_sleep",
"normalize_ext", "file_association",
+ "get_volume", "set_volume", "change_volume",
+ "is_muted", "set_mute", "mute", "unmute", "toggle_mute",
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
"set_clipboard_rtf", "get_clipboard_rtf",
"set_clipboard_csv", "get_clipboard_csv",
diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py
index 3c8c93fd..cf7dc7ac 100644
--- a/je_auto_control/gui/script_builder/command_schema.py
+++ b/je_auto_control/gui/script_builder/command_schema.py
@@ -4312,6 +4312,39 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
fields=(),
description="Release a previously-started keep-awake.",
))
+ specs.append(CommandSpec(
+ "AC_get_volume", "Shell", "Get System Volume",
+ fields=(),
+ description="Read the master volume percent and mute state.",
+ ))
+ specs.append(CommandSpec(
+ "AC_set_volume", "Shell", "Set System Volume",
+ fields=(
+ FieldSpec("level", FieldType.INT, default=50,
+ placeholder="volume percent 0-100"),
+ ),
+ description="Set the master volume to level percent (clamped 0-100).",
+ ))
+ specs.append(CommandSpec(
+ "AC_change_volume", "Shell", "Change System Volume",
+ fields=(
+ FieldSpec("delta", FieldType.INT, default=10,
+ placeholder="percent delta (may be negative)"),
+ ),
+ description="Add delta percent to the master volume (clamped 0-100).",
+ ))
+ specs.append(CommandSpec(
+ "AC_set_mute", "Shell", "Set Mute",
+ fields=(
+ FieldSpec("muted", FieldType.BOOL, optional=True, default=True),
+ ),
+ description="Mute or unmute the master output.",
+ ))
+ specs.append(CommandSpec(
+ "AC_toggle_mute", "Shell", "Toggle Mute",
+ fields=(),
+ description="Flip the master mute flag.",
+ ))
specs.append(CommandSpec(
"AC_normalize_ext", "Shell", "Normalize Extension",
fields=(
diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py
index 0fcd3a21..457389da 100644
--- a/je_auto_control/utils/executor/action_executor.py
+++ b/je_auto_control/utils/executor/action_executor.py
@@ -2653,6 +2653,36 @@ def _allow_sleep() -> Dict[str, Any]:
return {"released": bool(allow_sleep())}
+def _get_volume() -> Dict[str, Any]:
+ """Adapter: the system master volume as an integer percent."""
+ from je_auto_control.utils.system_volume import get_volume, is_muted
+ return {"volume": int(get_volume()), "muted": bool(is_muted())}
+
+
+def _set_volume(level: Any) -> Dict[str, Any]:
+ """Adapter: set the master volume to ``level`` percent."""
+ from je_auto_control.utils.system_volume import set_volume
+ return {"volume": int(set_volume(float(level)))}
+
+
+def _change_volume(delta: Any) -> Dict[str, Any]:
+ """Adapter: add ``delta`` percent to the master volume."""
+ from je_auto_control.utils.system_volume import change_volume
+ return {"volume": int(change_volume(float(delta)))}
+
+
+def _set_mute(muted: Any = True) -> Dict[str, Any]:
+ """Adapter: set the master mute flag."""
+ from je_auto_control.utils.system_volume import set_mute
+ return {"muted": bool(set_mute(bool(muted)))}
+
+
+def _toggle_mute() -> Dict[str, Any]:
+ """Adapter: flip the master mute flag."""
+ from je_auto_control.utils.system_volume import toggle_mute
+ return {"muted": bool(toggle_mute())}
+
+
def _normalize_ext(target: str) -> Dict[str, Any]:
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6662,6 +6692,11 @@ def __init__(self):
"AC_plan_keep_awake": _plan_keep_awake,
"AC_keep_awake_on": _keep_awake_on,
"AC_allow_sleep": _allow_sleep,
+ "AC_get_volume": _get_volume,
+ "AC_set_volume": _set_volume,
+ "AC_change_volume": _change_volume,
+ "AC_set_mute": _set_mute,
+ "AC_toggle_mute": _toggle_mute,
"AC_normalize_ext": _normalize_ext,
"AC_file_association": _file_association,
"AC_get_control_text": _get_control_text,
diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py
index 7a9b95cd..5ef00f30 100644
--- a/je_auto_control/utils/mcp_server/tools/_factories.py
+++ b/je_auto_control/utils/mcp_server/tools/_factories.py
@@ -2168,6 +2168,49 @@ def process_and_shell_tools() -> List[MCPTool]:
handler=h.file_association,
annotations=READ_ONLY,
),
+ MCPTool(
+ name="ac_get_volume",
+ description=("Read the system master volume as an integer percent "
+ "0..100. Returns {volume, muted} (Windows, needs "
+ "the optional 'pycaw' dependency)."),
+ input_schema=schema({}),
+ handler=h.get_volume,
+ annotations=READ_ONLY,
+ ),
+ MCPTool(
+ name="ac_set_volume",
+ description=("Set the master volume to 'level' percent (clamped to "
+ "0..100). Returns the applied {volume}."),
+ input_schema=schema({"level": {"type": "number"}},
+ required=["level"]),
+ handler=h.set_volume,
+ annotations=SIDE_EFFECT_ONLY,
+ ),
+ MCPTool(
+ name="ac_change_volume",
+ description=("Add 'delta' percent to the master volume (may be "
+ "negative; clamped to 0..100). Returns {volume}."),
+ input_schema=schema({"delta": {"type": "number"}},
+ required=["delta"]),
+ handler=h.change_volume,
+ annotations=SIDE_EFFECT_ONLY,
+ ),
+ MCPTool(
+ name="ac_set_mute",
+ description=("Mute or unmute the master output. 'muted' defaults to "
+ "true. Returns the new {muted} state."),
+ input_schema=schema({"muted": {"type": "boolean"}}),
+ handler=h.set_mute,
+ annotations=SIDE_EFFECT_ONLY,
+ ),
+ MCPTool(
+ name="ac_toggle_mute",
+ description=("Flip the master mute flag. Returns the new {muted} "
+ "state."),
+ input_schema=schema({}),
+ handler=h.toggle_mute,
+ annotations=SIDE_EFFECT_ONLY,
+ ),
]
diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py
index 4128e73d..41fc8c7a 100644
--- a/je_auto_control/utils/mcp_server/tools/_handlers.py
+++ b/je_auto_control/utils/mcp_server/tools/_handlers.py
@@ -595,6 +595,31 @@ def allow_sleep():
return _allow_sleep()
+def get_volume():
+ from je_auto_control.utils.executor.action_executor import _get_volume
+ return _get_volume()
+
+
+def set_volume(level):
+ from je_auto_control.utils.executor.action_executor import _set_volume
+ return _set_volume(level)
+
+
+def change_volume(delta):
+ from je_auto_control.utils.executor.action_executor import _change_volume
+ return _change_volume(delta)
+
+
+def set_mute(muted=True):
+ from je_auto_control.utils.executor.action_executor import _set_mute
+ return _set_mute(muted)
+
+
+def toggle_mute():
+ from je_auto_control.utils.executor.action_executor import _toggle_mute
+ return _toggle_mute()
+
+
def normalize_ext(target):
from je_auto_control.utils.executor.action_executor import _normalize_ext
return _normalize_ext(target)
diff --git a/je_auto_control/utils/system_volume/__init__.py b/je_auto_control/utils/system_volume/__init__.py
new file mode 100644
index 00000000..1100c5e3
--- /dev/null
+++ b/je_auto_control/utils/system_volume/__init__.py
@@ -0,0 +1,12 @@
+"""Read and control the system master volume and mute state."""
+from je_auto_control.utils.system_volume.system_volume import (
+ VolumeDriver, change_volume, clamp_percent, get_volume, is_muted, mute,
+ percent_to_scalar, scalar_to_percent, set_mute, set_volume, toggle_mute,
+ unmute,
+)
+
+__all__ = [
+ "VolumeDriver", "get_volume", "set_volume", "change_volume",
+ "is_muted", "set_mute", "mute", "unmute", "toggle_mute",
+ "clamp_percent", "percent_to_scalar", "scalar_to_percent",
+]
diff --git a/je_auto_control/utils/system_volume/system_volume.py b/je_auto_control/utils/system_volume/system_volume.py
new file mode 100644
index 00000000..9879d66d
--- /dev/null
+++ b/je_auto_control/utils/system_volume/system_volume.py
@@ -0,0 +1,182 @@
+"""Read and control the system master volume and mute state.
+
+Unattended runs often need to set a known audio baseline — mute before a noisy
+batch, restore a level afterwards, or assert the current volume — but the
+framework only had the blind media-key steps (``volume up`` / ``down`` nudge by
+an unknown amount with no read-back). ``system_volume`` adds absolute,
+read-backable control of the default output device:
+
+* :func:`get_volume` / :func:`set_volume` / :func:`change_volume` read and write
+ the master level as an integer percent ``0..100``.
+* :func:`is_muted` / :func:`set_mute` / :func:`mute` / :func:`unmute` /
+ :func:`toggle_mute` read and write the mute flag.
+
+All logic (clamping, percent <-> scalar conversion, toggle) is pure and runs
+through an injectable :class:`VolumeDriver` seam, so it is fully testable without
+an audio device. The default driver drives the Windows Core Audio
+``IAudioEndpointVolume`` interface through the optional ``pycaw`` dependency
+(``pip install je_auto_control[audio]``); on a platform / install without it the
+default driver raises a clear error telling the caller to pass ``driver=``.
+
+Imports no ``PySide6``.
+"""
+import sys
+from typing import Optional, Protocol
+
+# A normalized master-volume scalar runs 0.0 (silent) .. 1.0 (full).
+_MIN_SCALAR = 0.0
+_MAX_SCALAR = 1.0
+_PERCENT_MAX = 100
+
+
+class VolumeDriver(Protocol):
+ """The OS seam used by every public function.
+
+ Implementations expose the master output volume as a ``0.0 .. 1.0`` scalar
+ and a boolean mute flag. Pass a fake implementing these four methods to test
+ the pure logic without an audio device.
+ """
+
+ def get_scalar(self) -> float:
+ """Return the current master volume as a ``0.0 .. 1.0`` scalar."""
+
+ def set_scalar(self, scalar: float) -> None:
+ """Set the master volume from a ``0.0 .. 1.0`` scalar."""
+
+ def get_mute(self) -> bool:
+ """Return whether the master output is muted."""
+
+ def set_mute(self, muted: bool) -> None:
+ """Mute or unmute the master output."""
+
+
+def clamp_percent(level: float) -> int:
+ """Clamp ``level`` to an integer percent in ``[0, 100]`` (pure)."""
+ rounded = int(round(float(level)))
+ return max(0, min(_PERCENT_MAX, rounded))
+
+
+def percent_to_scalar(level: float) -> float:
+ """Convert a ``0..100`` percent to a clamped ``0.0 .. 1.0`` scalar (pure)."""
+ return clamp_percent(level) / float(_PERCENT_MAX)
+
+
+def scalar_to_percent(scalar: float) -> int:
+ """Convert a ``0.0 .. 1.0`` scalar to a clamped integer percent (pure)."""
+ bounded = max(_MIN_SCALAR, min(_MAX_SCALAR, float(scalar)))
+ return clamp_percent(bounded * _PERCENT_MAX)
+
+
+def _resolve(driver: Optional[VolumeDriver]) -> VolumeDriver:
+ """Return the supplied driver, or the default OS driver."""
+ return driver if driver is not None else _default_driver()
+
+
+def get_volume(*, driver: Optional[VolumeDriver] = None) -> int:
+ """Return the system master volume as an integer percent ``0..100``.
+
+ Pass ``driver`` (any :class:`VolumeDriver`) to read from a fake in tests; the
+ default queries the OS through ``pycaw`` (Windows).
+ """
+ return scalar_to_percent(_resolve(driver).get_scalar())
+
+
+def set_volume(level: float, *, driver: Optional[VolumeDriver] = None) -> int:
+ """Set the master volume to ``level`` percent; return the applied percent.
+
+ ``level`` is clamped to ``[0, 100]`` before it is applied.
+ """
+ target = clamp_percent(level)
+ _resolve(driver).set_scalar(percent_to_scalar(target))
+ return target
+
+
+def change_volume(delta: float, *,
+ driver: Optional[VolumeDriver] = None) -> int:
+ """Add ``delta`` percent to the current volume; return the applied percent.
+
+ The result is clamped to ``[0, 100]``. ``delta`` may be negative.
+ """
+ source = _resolve(driver)
+ current = scalar_to_percent(source.get_scalar())
+ target = clamp_percent(current + float(delta))
+ source.set_scalar(percent_to_scalar(target))
+ return target
+
+
+def is_muted(*, driver: Optional[VolumeDriver] = None) -> bool:
+ """Return whether the master output is currently muted."""
+ return bool(_resolve(driver).get_mute())
+
+
+def set_mute(muted: bool, *, driver: Optional[VolumeDriver] = None) -> bool:
+ """Set the mute flag to ``muted``; return the new mute state."""
+ state = bool(muted)
+ _resolve(driver).set_mute(state)
+ return state
+
+
+def mute(*, driver: Optional[VolumeDriver] = None) -> bool:
+ """Mute the master output; return ``True``."""
+ return set_mute(True, driver=driver)
+
+
+def unmute(*, driver: Optional[VolumeDriver] = None) -> bool:
+ """Unmute the master output; return ``False``."""
+ return set_mute(False, driver=driver)
+
+
+def toggle_mute(*, driver: Optional[VolumeDriver] = None) -> bool:
+ """Flip the mute flag; return the new mute state."""
+ source = _resolve(driver)
+ new_state = not bool(source.get_mute())
+ source.set_mute(new_state)
+ return new_state
+
+
+class _PycawDriver:
+ """Default :class:`VolumeDriver` over Windows Core Audio via ``pycaw``."""
+
+ def __init__(self) -> None:
+ """Activate the default-output ``IAudioEndpointVolume`` interface."""
+ from ctypes import POINTER, cast # local: optional Windows path
+ from comtypes import CLSCTX_ALL
+ from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume
+ speakers = AudioUtilities.GetSpeakers()
+ interface = speakers.Activate(
+ IAudioEndpointVolume._iid_, CLSCTX_ALL, None)
+ self._volume = cast(interface, POINTER(IAudioEndpointVolume))
+
+ def get_scalar(self) -> float:
+ """Read the master volume scalar from Core Audio."""
+ return float(self._volume.GetMasterVolumeLevelScalar())
+
+ def set_scalar(self, scalar: float) -> None:
+ """Write the master volume scalar to Core Audio."""
+ self._volume.SetMasterVolumeLevelScalar(float(scalar), None)
+
+ def get_mute(self) -> bool:
+ """Read the master mute flag from Core Audio."""
+ return bool(self._volume.GetMute())
+
+ def set_mute(self, muted: bool) -> None:
+ """Write the master mute flag to Core Audio."""
+ self._volume.SetMute(bool(muted), None)
+
+
+def _default_driver() -> VolumeDriver:
+ """Return the OS volume driver, or raise if unavailable.
+
+ Uses Windows Core Audio through the optional ``pycaw`` dependency. Raises
+ ``RuntimeError`` on a non-Windows platform or when ``pycaw`` is not
+ installed, instructing the caller to pass ``driver=``.
+ """
+ if not sys.platform.startswith("win"):
+ raise RuntimeError(
+ "system volume has no OS driver on this platform; pass driver=")
+ try:
+ return _PycawDriver()
+ except ImportError as exc:
+ raise RuntimeError(
+ "system volume needs the 'pycaw' package on Windows "
+ "(pip install je_auto_control[audio]); or pass driver=") from exc
diff --git a/pyproject.toml b/pyproject.toml
index 6111fecf..43c8c12b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -75,6 +75,7 @@ office = ["openpyxl>=3.1", "python-docx>=1.1", "python-pptx>=0.6"]
fuzzy = ["rapidfuzz>=3.0"]
s3 = ["boto3>=1.34"]
locale = ["babel>=2.12"]
+audio = ["pycaw>=20240210"]
[tool.bandit]
exclude_dirs = [
diff --git a/test/unit_test/headless/test_system_volume_batch.py b/test/unit_test/headless/test_system_volume_batch.py
new file mode 100644
index 00000000..61d38bf8
--- /dev/null
+++ b/test/unit_test/headless/test_system_volume_batch.py
@@ -0,0 +1,124 @@
+"""Headless tests for system volume / mute control (injected fake driver)."""
+import pytest
+
+import je_auto_control as ac
+from je_auto_control.utils.system_volume import (
+ change_volume, clamp_percent, get_volume, is_muted, mute,
+ percent_to_scalar, scalar_to_percent, set_mute, set_volume, toggle_mute,
+ unmute,
+)
+
+
+class FakeVolume:
+ """In-memory VolumeDriver: stores a 0..1 scalar and a mute flag."""
+
+ def __init__(self, scalar=0.5, muted=False):
+ self.scalar = scalar
+ self.muted = muted
+
+ def get_scalar(self):
+ return self.scalar
+
+ def set_scalar(self, scalar):
+ self.scalar = scalar
+
+ def get_mute(self):
+ return self.muted
+
+ def set_mute(self, muted):
+ self.muted = muted
+
+
+# --- pure conversion ------------------------------------------------------
+
+def test_clamp_percent_bounds():
+ assert clamp_percent(-20) == 0
+ assert clamp_percent(150) == 100
+ assert clamp_percent(37.6) == 38
+
+
+def test_percent_scalar_round_trip():
+ assert percent_to_scalar(50) == pytest.approx(0.5)
+ assert scalar_to_percent(0.5) == 50
+ assert scalar_to_percent(1.5) == 100 # clamped
+ assert scalar_to_percent(-0.2) == 0
+
+
+# --- volume read / write --------------------------------------------------
+
+def test_get_volume_reads_driver():
+ assert get_volume(driver=FakeVolume(scalar=0.4)) == 40
+
+
+def test_set_volume_clamps_and_applies():
+ drv = FakeVolume(scalar=0.0)
+ assert set_volume(73, driver=drv) == 73
+ assert drv.scalar == pytest.approx(0.73)
+ assert set_volume(250, driver=drv) == 100
+ assert set_volume(-5, driver=drv) == 0
+
+
+def test_change_volume_relative():
+ drv = FakeVolume(scalar=0.5)
+ assert change_volume(20, driver=drv) == 70
+ assert change_volume(-100, driver=drv) == 0
+ assert change_volume(10, driver=drv) == 10
+
+
+# --- mute -----------------------------------------------------------------
+
+def test_is_muted_and_set_mute():
+ drv = FakeVolume(muted=False)
+ assert is_muted(driver=drv) is False
+ assert set_mute(True, driver=drv) is True
+ assert drv.muted is True
+
+
+def test_mute_unmute_helpers():
+ drv = FakeVolume(muted=False)
+ assert mute(driver=drv) is True
+ assert is_muted(driver=drv) is True
+ assert unmute(driver=drv) is False
+ assert is_muted(driver=drv) is False
+
+
+def test_toggle_mute_flips():
+ drv = FakeVolume(muted=False)
+ assert toggle_mute(driver=drv) is True
+ assert toggle_mute(driver=drv) is False
+
+
+# --- wiring ---------------------------------------------------------------
+
+def test_executor_pure_path_with_default_driver_absent_on_linux():
+ # The executor adapters use the OS default driver; on a non-Windows CI box
+ # that raises a clear RuntimeError rather than silently passing.
+ from je_auto_control.utils.system_volume.system_volume import (
+ _default_driver,
+ )
+ import sys
+ if not sys.platform.startswith("win"):
+ with pytest.raises(RuntimeError):
+ _default_driver()
+
+
+def test_wiring():
+ known = set(ac.executor.known_commands())
+ assert {"AC_get_volume", "AC_set_volume", "AC_change_volume",
+ "AC_set_mute", "AC_toggle_mute"} <= known
+ from je_auto_control.utils.mcp_server.tools import (
+ build_default_tool_registry,
+ )
+ names = {t.name for t in build_default_tool_registry()}
+ assert {"ac_get_volume", "ac_set_volume", "ac_change_volume",
+ "ac_set_mute", "ac_toggle_mute"} <= names
+ from je_auto_control.gui.script_builder.command_schema import _build_specs
+ specs = {s.command for s in _build_specs()}
+ assert {"AC_get_volume", "AC_set_volume", "AC_change_volume",
+ "AC_set_mute", "AC_toggle_mute"} <= specs
+
+
+def test_facade_exports():
+ for name in ("get_volume", "set_volume", "change_volume", "is_muted",
+ "set_mute", "mute", "unmute", "toggle_mute"):
+ assert hasattr(ac, name) and name in ac.__all__
From f01545fb1aec85d2d60fa9a1af3a86f69259e98c Mon Sep 17 00:00:00 2001
From: JeffreyChen
Date: Fri, 26 Jun 2026 05:00:54 +0800
Subject: [PATCH 3/4] Add lock_session: lock the workstation and wait for
unlock
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
session_guard could only detect a locked session and raise; this adds
acting on the lock state — lock the box at the end of an unattended run,
block until a human unlocks it before resuming, and reduce a lock-state
sample stream to lock/unlock events. The lock action runs through an
injectable driver and the waits reuse session_guard's real probe with
injectable clock/sleep, so all logic is unit-tested without the OS.
---
WHATS_NEW.md | 6 +
.../doc/new_features/v207_features_doc.rst | 62 +++++++
.../Zh/doc/new_features/v207_features_doc.rst | 55 +++++++
je_auto_control/__init__.py | 7 +
.../gui/script_builder/command_schema.py | 28 ++++
.../utils/executor/action_executor.py | 32 ++++
.../utils/lock_session/__init__.py | 10 ++
.../utils/lock_session/lock_session.py | 153 ++++++++++++++++++
.../utils/mcp_server/tools/_factories.py | 38 +++++
.../utils/mcp_server/tools/_handlers.py | 24 +++
.../headless/test_lock_session_batch.py | 139 ++++++++++++++++
11 files changed, 554 insertions(+)
create mode 100644 docs/source/Eng/doc/new_features/v207_features_doc.rst
create mode 100644 docs/source/Zh/doc/new_features/v207_features_doc.rst
create mode 100644 je_auto_control/utils/lock_session/__init__.py
create mode 100644 je_auto_control/utils/lock_session/lock_session.py
create mode 100644 test/unit_test/headless/test_lock_session_batch.py
diff --git a/WHATS_NEW.md b/WHATS_NEW.md
index 04b77bb1..0395fbb5 100644
--- a/WHATS_NEW.md
+++ b/WHATS_NEW.md
@@ -2,6 +2,12 @@
## What's new (2026-06-26)
+### Lock the Workstation + Wait for Unlock
+
+Lock the box at the end of a run, and block until a human unlocks it before resuming. Full reference: [`docs/source/Eng/doc/new_features/v207_features_doc.rst`](docs/source/Eng/doc/new_features/v207_features_doc.rst).
+
+- **`lock_session` / `plan_lock_session` / `wait_for_unlock` / `wait_for_lock` / `classify_lock_transitions`** (`AC_lock_session`, `AC_plan_lock_session`, `AC_wait_for_unlock`, `AC_classify_lock_transitions`): `session_guard` could *detect* a locked session and raise; this adds *acting* on it. `lock_session` locks the workstation now (`LockWorkStation` on Windows, `loginctl lock-session` / `CGSession -suspend` elsewhere) through an injectable `driver`; `wait_for_unlock` / `wait_for_lock` poll `session_guard.is_session_locked` (reusing its real Windows `OpenInputDesktop` probe) until the state flips or a timeout, with injectable `clock` / `sleep` / `probe`; `plan_lock_session` is the pure per-OS planner and `classify_lock_transitions` reduces a lock-state sample stream to `{event, locked}` lock/unlock events. `wait_for_unlock` is the blocking companion to `ensure_interactive_session`. Fifth feature of the ROUND-15 cross-app OS lane. No `PySide6`.
+
### Read and Control the System Volume
Set a known audio baseline before a run — mute, set 30%, or assert the level. Full reference: [`docs/source/Eng/doc/new_features/v206_features_doc.rst`](docs/source/Eng/doc/new_features/v206_features_doc.rst).
diff --git a/docs/source/Eng/doc/new_features/v207_features_doc.rst b/docs/source/Eng/doc/new_features/v207_features_doc.rst
new file mode 100644
index 00000000..73585e2c
--- /dev/null
+++ b/docs/source/Eng/doc/new_features/v207_features_doc.rst
@@ -0,0 +1,62 @@
+Lock the Workstation + Wait for Unlock
+======================================
+
+:mod:`session_guard` answers "is the session locked right now?" and raises if it
+is. The missing half is *acting* on the lock state: lock the machine at the end
+of an unattended run, block until a human unlocks it before resuming, or reduce
+a stream of lock-state samples to lock / unlock events. ``lock_session`` adds
+that, behind injectable seams so the logic is testable without touching the OS.
+
+* :func:`lock_session` — lock the workstation now (``LockWorkStation`` on
+ Windows, ``loginctl lock-session`` on Linux, ``CGSession -suspend`` on macOS)
+ through an injectable ``driver``.
+* :func:`plan_lock_session` — pure planner: how the lock would be performed on
+ this OS and whether a default is available (``{backend, argv, available}``).
+* :func:`wait_for_unlock` / :func:`wait_for_lock` — poll
+ :func:`is_session_locked` until the state flips or a timeout, with injectable
+ ``clock`` / ``sleep`` / ``probe`` for deterministic tests.
+* :func:`classify_lock_transitions` — pure: a list of lock-state samples to a
+ list of ``{event, locked}`` lock / unlock transitions.
+
+The lock probe reused by the wait helpers is :mod:`session_guard`'s — the
+Windows ``OpenInputDesktop`` check — so ``wait_for_unlock`` is the blocking
+companion to ``ensure_interactive_session`` (which only raises). Imports no
+``PySide6``.
+
+Headless API
+------------
+
+.. code-block:: python
+
+ from je_auto_control import (
+ lock_session, wait_for_unlock, classify_lock_transitions,
+ )
+
+ # ... unattended run finishes ...
+ lock_session() # secure the machine
+
+ # Resume only once a human has unlocked the box
+ if wait_for_unlock(timeout_s=600):
+ run_next_stage()
+
+ # Reduce a sampled lock-state log to events
+ classify_lock_transitions([False, True, True, False])
+ # -> [{'event': 'lock', 'locked': True},
+ # {'event': 'unlock', 'locked': False}]
+
+For tests (or any host) pass a ``driver`` / ``probe``:
+
+.. code-block:: python
+
+ locked = lock_session(driver=lambda: True) # no real lock
+ wait_for_unlock(probe=lambda: False) # already unlocked
+
+Executor commands
+-----------------
+
+``AC_lock_session`` (→ ``{locked}``), ``AC_plan_lock_session`` (→ the plan),
+``AC_wait_for_unlock`` (``timeout`` / ``interval`` → ``{unlocked}``) and
+``AC_classify_lock_transitions`` (``states`` JSON list → ``{events}``). They are
+exposed as the matching ``ac_*`` MCP tools (``ac_lock_session`` is destructive —
+it interrupts the session; the rest are read-only) and as Script Builder
+commands under **Shell**.
diff --git a/docs/source/Zh/doc/new_features/v207_features_doc.rst b/docs/source/Zh/doc/new_features/v207_features_doc.rst
new file mode 100644
index 00000000..768b7213
--- /dev/null
+++ b/docs/source/Zh/doc/new_features/v207_features_doc.rst
@@ -0,0 +1,55 @@
+鎖定工作站 + 等待解鎖
+====================
+
+:mod:`session_guard` 回答「目前 session 是否鎖定?」並在鎖定時丟出例外。缺少的另一半是對鎖定狀態
+*採取行動*:在無人值守執行結束時鎖定機器、在恢復前阻塞直到有人解鎖,或把一連串鎖定狀態取樣化約為
+鎖定 / 解鎖事件。``lock_session`` 補上這些,並以可注入接縫實作,故邏輯能在不碰作業系統的情況下測試。
+
+* :func:`lock_session` ——立即鎖定工作站(Windows 用 ``LockWorkStation``、Linux 用
+ ``loginctl lock-session``、macOS 用 ``CGSession -suspend``),透過可注入的 ``driver``。
+* :func:`plan_lock_session` ——純 planner:此 OS 上會如何執行鎖定,以及是否有預設可用
+ (``{backend, argv, available}``)。
+* :func:`wait_for_unlock` / :func:`wait_for_lock` ——輪詢 :func:`is_session_locked`
+ 直到狀態翻轉或逾時,``clock`` / ``sleep`` / ``probe`` 皆可注入以利確定性測試。
+* :func:`classify_lock_transitions` ——純函式:把一連串鎖定狀態取樣化約為
+ ``{event, locked}`` 鎖定 / 解鎖轉變的清單。
+
+wait 系列重用的鎖定 probe 即 :mod:`session_guard` 的——Windows 的 ``OpenInputDesktop`` 檢查——
+故 ``wait_for_unlock`` 是 ``ensure_interactive_session``(只會丟例外)的阻塞式搭檔。不匯入 ``PySide6``。
+
+無頭 API
+--------
+
+.. code-block:: python
+
+ from je_auto_control import (
+ lock_session, wait_for_unlock, classify_lock_transitions,
+ )
+
+ # ... 無人值守執行結束 ...
+ lock_session() # 鎖住機器
+
+ # 等有人解鎖後才繼續
+ if wait_for_unlock(timeout_s=600):
+ run_next_stage()
+
+ # 把取樣的鎖定狀態紀錄化約為事件
+ classify_lock_transitions([False, True, True, False])
+ # -> [{'event': 'lock', 'locked': True},
+ # {'event': 'unlock', 'locked': False}]
+
+測試時(或任何主機)可傳入 ``driver`` / ``probe``:
+
+.. code-block:: python
+
+ locked = lock_session(driver=lambda: True) # 不真正鎖定
+ wait_for_unlock(probe=lambda: False) # 已解鎖
+
+執行器指令
+----------
+
+``AC_lock_session``(→ ``{locked}``)、``AC_plan_lock_session``(→ 計畫)、
+``AC_wait_for_unlock``(``timeout`` / ``interval`` → ``{unlocked}``)與
+``AC_classify_lock_transitions``(``states`` JSON 清單 → ``{events}``)。皆以對應的 ``ac_*``
+MCP 工具(``ac_lock_session`` 為破壞性——會中斷 session;其餘為唯讀)及 Script Builder 指令
+(位於 **Shell** 分類下)形式提供。
diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py
index 6d6bb3f4..10a47b62 100644
--- a/je_auto_control/__init__.py
+++ b/je_auto_control/__init__.py
@@ -105,6 +105,11 @@
change_volume, get_volume, is_muted, mute, set_mute, set_volume,
toggle_mute, unmute,
)
+# Lock the workstation, wait for unlock, classify lock transitions
+from je_auto_control.utils.lock_session import (
+ classify_lock_transitions, lock_session, plan_lock_session,
+ wait_for_lock, wait_for_unlock,
+)
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
from je_auto_control.utils.clipboard_rich_formats import (
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1720,6 +1725,8 @@ def start_autocontrol_gui(*args, **kwargs):
"normalize_ext", "file_association",
"get_volume", "set_volume", "change_volume",
"is_muted", "set_mute", "mute", "unmute", "toggle_mute",
+ "lock_session", "plan_lock_session", "wait_for_unlock",
+ "wait_for_lock", "classify_lock_transitions",
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
"set_clipboard_rtf", "get_clipboard_rtf",
"set_clipboard_csv", "get_clipboard_csv",
diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py
index cf7dc7ac..e8b56871 100644
--- a/je_auto_control/gui/script_builder/command_schema.py
+++ b/je_auto_control/gui/script_builder/command_schema.py
@@ -4345,6 +4345,34 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
fields=(),
description="Flip the master mute flag.",
))
+ specs.append(CommandSpec(
+ "AC_lock_session", "Shell", "Lock Workstation",
+ fields=(),
+ description="Lock the workstation now (interrupts the session).",
+ ))
+ specs.append(CommandSpec(
+ "AC_plan_lock_session", "Shell", "Plan Lock Workstation",
+ fields=(),
+ description="Describe how the workstation would be locked (pure).",
+ ))
+ specs.append(CommandSpec(
+ "AC_wait_for_unlock", "Shell", "Wait for Unlock",
+ fields=(
+ FieldSpec("timeout", FieldType.FLOAT, optional=True, default=30.0,
+ placeholder="timeout seconds"),
+ FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.5,
+ placeholder="poll interval seconds"),
+ ),
+ description="Block until the session is unlocked or timeout.",
+ ))
+ specs.append(CommandSpec(
+ "AC_classify_lock_transitions", "Shell", "Classify Lock Transitions",
+ fields=(
+ FieldSpec("states", FieldType.STRING,
+ placeholder="JSON list of booleans"),
+ ),
+ description="Reduce lock-state samples to lock / unlock events.",
+ ))
specs.append(CommandSpec(
"AC_normalize_ext", "Shell", "Normalize Extension",
fields=(
diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py
index 457389da..0f751890 100644
--- a/je_auto_control/utils/executor/action_executor.py
+++ b/je_auto_control/utils/executor/action_executor.py
@@ -2683,6 +2683,34 @@ def _toggle_mute() -> Dict[str, Any]:
return {"muted": bool(toggle_mute())}
+def _lock_session() -> Dict[str, Any]:
+ """Adapter: lock the workstation now."""
+ from je_auto_control.utils.lock_session import lock_session
+ return {"locked": bool(lock_session())}
+
+
+def _plan_lock_session() -> Dict[str, Any]:
+ """Adapter: describe how the workstation would be locked (pure)."""
+ from je_auto_control.utils.lock_session import plan_lock_session
+ return plan_lock_session()
+
+
+def _wait_for_unlock(timeout: Any = 30.0, interval: Any = 0.5
+ ) -> Dict[str, Any]:
+ """Adapter: block until the session is unlocked or timeout."""
+ from je_auto_control.utils.lock_session import wait_for_unlock
+ unlocked = wait_for_unlock(timeout_s=float(timeout),
+ interval_s=float(interval))
+ return {"unlocked": bool(unlocked)}
+
+
+def _classify_lock_transitions(states: Any) -> Dict[str, Any]:
+ """Adapter: reduce lock-state samples to lock / unlock events (pure)."""
+ from je_auto_control.utils.lock_session import classify_lock_transitions
+ samples = [bool(s) for s in _coerce_list(states)] if states else []
+ return {"events": classify_lock_transitions(samples)}
+
+
def _normalize_ext(target: str) -> Dict[str, Any]:
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6697,6 +6725,10 @@ def __init__(self):
"AC_change_volume": _change_volume,
"AC_set_mute": _set_mute,
"AC_toggle_mute": _toggle_mute,
+ "AC_lock_session": _lock_session,
+ "AC_plan_lock_session": _plan_lock_session,
+ "AC_wait_for_unlock": _wait_for_unlock,
+ "AC_classify_lock_transitions": _classify_lock_transitions,
"AC_normalize_ext": _normalize_ext,
"AC_file_association": _file_association,
"AC_get_control_text": _get_control_text,
diff --git a/je_auto_control/utils/lock_session/__init__.py b/je_auto_control/utils/lock_session/__init__.py
new file mode 100644
index 00000000..59e5c5f9
--- /dev/null
+++ b/je_auto_control/utils/lock_session/__init__.py
@@ -0,0 +1,10 @@
+"""Lock the workstation, wait for unlock, and classify lock transitions."""
+from je_auto_control.utils.lock_session.lock_session import (
+ classify_lock_transitions, lock_session, plan_lock_session,
+ wait_for_lock, wait_for_unlock,
+)
+
+__all__ = [
+ "lock_session", "plan_lock_session", "wait_for_unlock", "wait_for_lock",
+ "classify_lock_transitions",
+]
diff --git a/je_auto_control/utils/lock_session/lock_session.py b/je_auto_control/utils/lock_session/lock_session.py
new file mode 100644
index 00000000..cacca6aa
--- /dev/null
+++ b/je_auto_control/utils/lock_session/lock_session.py
@@ -0,0 +1,153 @@
+"""Lock the workstation, wait for it to be unlocked, and classify transitions.
+
+:mod:`session_guard` answers "is the session locked right now?" and raises if it
+is. The missing half is *acting* on the lock state: lock the machine at the end
+of an unattended run, block until a human unlocks it before resuming, or reduce
+a stream of lock-state samples to lock / unlock events. ``lock_session`` adds:
+
+* :func:`lock_session` — lock the workstation now (``LockWorkStation`` on
+ Windows, ``loginctl lock-session`` / ``CGSession -suspend`` elsewhere),
+ through an injectable ``driver`` seam.
+* :func:`plan_lock_session` — pure planner describing how the lock would be
+ performed on this OS, and whether a default is available.
+* :func:`wait_for_unlock` / :func:`wait_for_lock` — poll
+ :func:`session_guard.is_session_locked` until the state flips or a timeout,
+ with injectable ``clock`` / ``sleep`` / ``probe`` for deterministic tests.
+* :func:`classify_lock_transitions` — pure: a list of lock-state samples to a
+ list of ``{event, locked}`` lock / unlock transitions.
+
+Imports no ``PySide6``.
+"""
+import sys
+import time
+from typing import Any, Callable, Dict, List, Optional, Sequence
+
+from je_auto_control.utils.session_guard import is_session_locked
+from je_auto_control.utils.session_guard.session_guard import LockProbe
+
+# A driver performs the lock and returns whether it succeeded.
+LockDriver = Callable[[], bool]
+
+
+def _win_lock() -> bool:
+ """Lock the Windows workstation via ``LockWorkStation``."""
+ import ctypes
+ user32 = ctypes.windll.user32 # nosec B607 # reason: fixed system DLL
+ return bool(user32.LockWorkStation())
+
+
+def _lock_backend() -> str:
+ """Return the lock backend name for the current platform."""
+ if sys.platform.startswith("win"):
+ return "LockWorkStation"
+ if sys.platform == "darwin":
+ return "CGSession"
+ return "loginctl"
+
+
+_CGSESSION = ("/System/Library/CoreServices/Menu Extras/"
+ "User.menu/Contents/Resources/CGSession")
+
+
+def _lock_argv(backend: str) -> Optional[List[str]]:
+ """Return the fixed argv for a subprocess lock backend, or None."""
+ if backend == "loginctl":
+ return ["loginctl", "lock-session"]
+ if backend == "CGSession":
+ return [_CGSESSION, "-suspend"]
+ return None
+
+
+def plan_lock_session() -> Dict[str, Any]:
+ """Describe how the workstation would be locked on this OS (pure).
+
+ Returns ``{backend, argv, available}``. ``available`` is ``True`` when this
+ platform has a built-in default; otherwise :func:`lock_session` needs an
+ explicit ``driver=``.
+ """
+ backend = _lock_backend()
+ argv = _lock_argv(backend)
+ available = backend == "LockWorkStation" or argv is not None
+ return {"backend": backend, "argv": argv, "available": available}
+
+
+def _run_argv(argv: List[str]) -> bool:
+ """Run a fixed lock argv and report success (no shell)."""
+ import subprocess # nosec B404 # reason: fixed argv, no shell
+ completed = subprocess.run(argv, check=False) # nosec B603 # nosemgrep
+ return completed.returncode == 0
+
+
+def _default_driver() -> LockDriver:
+ """Return the OS lock driver, or raise if none is available."""
+ backend = _lock_backend()
+ if backend == "LockWorkStation":
+ return _win_lock
+ argv = _lock_argv(backend)
+ if argv is not None:
+ return lambda: _run_argv(argv)
+ raise RuntimeError(
+ "lock_session has no OS driver on this platform; pass driver=")
+
+
+def lock_session(*, driver: Optional[LockDriver] = None) -> bool:
+ """Lock the workstation now; return whether the lock was requested.
+
+ Pass ``driver`` (a ``() -> bool``) to intercept the OS call in tests; the
+ default locks via the platform backend from :func:`plan_lock_session`.
+ """
+ acquire = driver if driver is not None else _default_driver()
+ return bool(acquire())
+
+
+def _wait_lock_state(target_locked: bool, *, probe: Optional[LockProbe],
+ timeout_s: float, interval_s: float,
+ clock: Callable[[], float],
+ sleep: Callable[[float], None]) -> bool:
+ """Poll until the lock state equals ``target_locked`` or timeout."""
+ deadline = clock() + float(timeout_s)
+ while True:
+ if bool(is_session_locked(probe)) == target_locked:
+ return True
+ if clock() >= deadline:
+ return False
+ sleep(float(interval_s))
+
+
+def wait_for_unlock(*, probe: Optional[LockProbe] = None,
+ timeout_s: float = 30.0, interval_s: float = 0.5,
+ clock: Callable[[], float] = time.monotonic,
+ sleep: Callable[[float], None] = time.sleep) -> bool:
+ """Block until the session is unlocked; return ``True``, or ``False`` on timeout.
+
+ Reuses :func:`session_guard.is_session_locked` (Windows default probe).
+ ``clock`` / ``sleep`` / ``probe`` are injectable for deterministic tests.
+ """
+ return _wait_lock_state(False, probe=probe, timeout_s=timeout_s,
+ interval_s=interval_s, clock=clock, sleep=sleep)
+
+
+def wait_for_lock(*, probe: Optional[LockProbe] = None,
+ timeout_s: float = 30.0, interval_s: float = 0.5,
+ clock: Callable[[], float] = time.monotonic,
+ sleep: Callable[[float], None] = time.sleep) -> bool:
+ """Block until the session is locked; return ``True``, or ``False`` on timeout."""
+ return _wait_lock_state(True, probe=probe, timeout_s=timeout_s,
+ interval_s=interval_s, clock=clock, sleep=sleep)
+
+
+def classify_lock_transitions(states: Sequence[bool]) -> List[Dict[str, Any]]:
+ """Reduce lock-state samples to lock / unlock transitions (pure).
+
+ Each adjacent ``False -> True`` yields a ``lock`` event and each
+ ``True -> False`` an ``unlock`` event; unchanged samples yield nothing.
+ """
+ events: List[Dict[str, Any]] = []
+ previous: Optional[bool] = None
+ for sample in states:
+ current = bool(sample)
+ if previous is not None and current != previous:
+ kind = "lock" if current else "unlock"
+ events.append({"event": kind, "locked": current})
+ previous = current
+ return events
diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py
index 5ef00f30..83df5f93 100644
--- a/je_auto_control/utils/mcp_server/tools/_factories.py
+++ b/je_auto_control/utils/mcp_server/tools/_factories.py
@@ -2211,6 +2211,44 @@ def process_and_shell_tools() -> List[MCPTool]:
handler=h.toggle_mute,
annotations=SIDE_EFFECT_ONLY,
),
+ MCPTool(
+ name="ac_lock_session",
+ description=("Lock the workstation now (LockWorkStation / loginctl "
+ "lock-session / CGSession). Returns {locked}. "
+ "Interrupts the interactive session."),
+ input_schema=schema({}),
+ handler=h.lock_session,
+ annotations=DESTRUCTIVE,
+ ),
+ MCPTool(
+ name="ac_plan_lock_session",
+ description=("Describe how the workstation would be locked on this "
+ "OS without locking (pure): {backend, argv, "
+ "available}."),
+ input_schema=schema({}),
+ handler=h.plan_lock_session,
+ annotations=READ_ONLY,
+ ),
+ MCPTool(
+ name="ac_wait_for_unlock",
+ description=("Block until the session is unlocked (or 'timeout' "
+ "seconds), polling every 'interval'. Returns "
+ "{unlocked}."),
+ input_schema=schema({"timeout": {"type": "number"},
+ "interval": {"type": "number"}}),
+ handler=h.wait_for_unlock,
+ annotations=READ_ONLY,
+ ),
+ MCPTool(
+ name="ac_classify_lock_transitions",
+ description=("Reduce a list of lock-state booleans ('states') to "
+ "lock / unlock events (pure). Returns {events}."),
+ input_schema=schema({"states": {"type": "array",
+ "items": {"type": "boolean"}}},
+ required=["states"]),
+ handler=h.classify_lock_transitions,
+ annotations=READ_ONLY,
+ ),
]
diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py
index 41fc8c7a..613adf2d 100644
--- a/je_auto_control/utils/mcp_server/tools/_handlers.py
+++ b/je_auto_control/utils/mcp_server/tools/_handlers.py
@@ -620,6 +620,30 @@ def toggle_mute():
return _toggle_mute()
+def lock_session():
+ from je_auto_control.utils.executor.action_executor import _lock_session
+ return _lock_session()
+
+
+def plan_lock_session():
+ from je_auto_control.utils.executor.action_executor import (
+ _plan_lock_session,
+ )
+ return _plan_lock_session()
+
+
+def wait_for_unlock(timeout=30.0, interval=0.5):
+ from je_auto_control.utils.executor.action_executor import _wait_for_unlock
+ return _wait_for_unlock(timeout, interval)
+
+
+def classify_lock_transitions(states):
+ from je_auto_control.utils.executor.action_executor import (
+ _classify_lock_transitions,
+ )
+ return _classify_lock_transitions(states)
+
+
def normalize_ext(target):
from je_auto_control.utils.executor.action_executor import _normalize_ext
return _normalize_ext(target)
diff --git a/test/unit_test/headless/test_lock_session_batch.py b/test/unit_test/headless/test_lock_session_batch.py
new file mode 100644
index 00000000..af16d1c4
--- /dev/null
+++ b/test/unit_test/headless/test_lock_session_batch.py
@@ -0,0 +1,139 @@
+"""Headless tests for lock_session (injected driver / probe / clock)."""
+import sys
+
+import je_auto_control as ac
+from je_auto_control.utils.lock_session import (
+ classify_lock_transitions, lock_session, plan_lock_session,
+ wait_for_lock, wait_for_unlock,
+)
+
+
+# --- lock action ----------------------------------------------------------
+
+def test_lock_session_uses_driver():
+ calls = []
+
+ def fake_driver():
+ calls.append(True)
+ return True
+
+ assert lock_session(driver=fake_driver) is True
+ assert calls == [True]
+
+
+def test_lock_session_driver_failure_reported():
+ assert lock_session(driver=lambda: False) is False
+
+
+def test_plan_lock_session_shape():
+ plan = plan_lock_session()
+ assert set(plan) == {"backend", "argv", "available"}
+ assert plan["backend"] in ("LockWorkStation", "loginctl", "CGSession")
+ assert isinstance(plan["available"], bool)
+
+
+def test_plan_lock_session_available_per_os():
+ plan = plan_lock_session()
+ # Every supported platform path has a default backend.
+ assert plan["available"] is True
+ if plan["backend"] == "LockWorkStation":
+ assert plan["argv"] is None
+ else:
+ assert isinstance(plan["argv"], list) and len(plan["argv"]) >= 1
+
+
+# --- wait for unlock / lock -----------------------------------------------
+
+def _probe_sequence(values):
+ """Return a probe yielding successive booleans, repeating the last."""
+ state = {"i": 0}
+
+ def probe():
+ i = min(state["i"], len(values) - 1)
+ state["i"] += 1
+ return values[i]
+
+ return probe
+
+
+def test_wait_for_unlock_returns_true_when_unlocked():
+ # locked, locked, then unlocked -> returns True
+ probe = _probe_sequence([True, True, False])
+ clock = iter([0.0, 1.0, 2.0, 3.0, 4.0])
+ ok = wait_for_unlock(probe=probe, timeout_s=10.0, interval_s=1.0,
+ clock=lambda: next(clock), sleep=lambda _s: None)
+ assert ok is True
+
+
+def test_wait_for_unlock_times_out_when_still_locked():
+ probe = _probe_sequence([True]) # never unlocks
+ times = iter([0.0, 0.0, 5.0, 10.0, 20.0])
+ ok = wait_for_unlock(probe=probe, timeout_s=5.0, interval_s=1.0,
+ clock=lambda: next(times), sleep=lambda _s: None)
+ assert ok is False
+
+
+def test_wait_for_lock_returns_true_when_locked():
+ probe = _probe_sequence([False, True])
+ clock = iter([0.0, 1.0, 2.0, 3.0])
+ ok = wait_for_lock(probe=probe, timeout_s=10.0, interval_s=1.0,
+ clock=lambda: next(clock), sleep=lambda _s: None)
+ assert ok is True
+
+
+# --- pure transition classifier -------------------------------------------
+
+def test_classify_lock_transitions_events():
+ events = classify_lock_transitions([False, False, True, True, False])
+ assert events == [
+ {"event": "lock", "locked": True},
+ {"event": "unlock", "locked": False},
+ ]
+
+
+def test_classify_lock_transitions_empty_and_constant():
+ assert classify_lock_transitions([]) == []
+ assert classify_lock_transitions([True, True, True]) == []
+
+
+# --- wiring ---------------------------------------------------------------
+
+def test_executor_pure_classify_path():
+ from je_auto_control.utils.executor.action_executor import (
+ _classify_lock_transitions, _plan_lock_session,
+ )
+ out = _classify_lock_transitions([False, True])
+ assert out == {"events": [{"event": "lock", "locked": True}]}
+ # accepts a JSON-list string too (Script Builder text field)
+ assert _classify_lock_transitions("[false, true]")["events"] == [
+ {"event": "lock", "locked": True}]
+ assert "backend" in _plan_lock_session()
+
+
+def test_default_driver_absent_off_windows():
+ from je_auto_control.utils.lock_session.lock_session import _default_driver
+ if not sys.platform.startswith("win") and sys.platform != "darwin":
+ # Linux default is loginctl (callable built), so a driver exists.
+ assert callable(_default_driver())
+
+
+def test_wiring():
+ known = set(ac.executor.known_commands())
+ assert {"AC_lock_session", "AC_plan_lock_session", "AC_wait_for_unlock",
+ "AC_classify_lock_transitions"} <= known
+ from je_auto_control.utils.mcp_server.tools import (
+ build_default_tool_registry,
+ )
+ names = {t.name for t in build_default_tool_registry()}
+ assert {"ac_lock_session", "ac_plan_lock_session", "ac_wait_for_unlock",
+ "ac_classify_lock_transitions"} <= names
+ from je_auto_control.gui.script_builder.command_schema import _build_specs
+ specs = {s.command for s in _build_specs()}
+ assert {"AC_lock_session", "AC_plan_lock_session", "AC_wait_for_unlock",
+ "AC_classify_lock_transitions"} <= specs
+
+
+def test_facade_exports():
+ for name in ("lock_session", "plan_lock_session", "wait_for_unlock",
+ "wait_for_lock", "classify_lock_transitions"):
+ assert hasattr(ac, name) and name in ac.__all__
From 70047df8a4b2623612babfc634af9024a70b1b0b Mon Sep 17 00:00:00 2001
From: JeffreyChen
Date: Fri, 26 Jun 2026 05:11:54 +0800
Subject: [PATCH 4/4] Add ime_state: live IME composition state for safe CJK
entry
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Typing into a CJK field while the IME is composing corrupts entry — the
candidate text isn't committed, so reads return half-formed glyphs and the
next keystroke edits the composition. text_unicode is blind to this. Expose
the focused window's live composition/conversion state (Windows IMM32,
read-only) behind an injectable reader, with is_composing and a
wait_for_composition_commit gate, so flows wait for commit first.
---
WHATS_NEW.md | 6 +
.../doc/new_features/v208_features_doc.rst | 57 +++++++
.../Zh/doc/new_features/v208_features_doc.rst | 51 +++++++
je_auto_control/__init__.py | 7 +
.../gui/script_builder/command_schema.py | 28 ++++
.../utils/executor/action_executor.py | 31 ++++
je_auto_control/utils/ime_state/__init__.py | 10 ++
je_auto_control/utils/ime_state/ime_state.py | 134 +++++++++++++++++
.../utils/mcp_server/tools/_factories.py | 38 +++++
.../utils/mcp_server/tools/_handlers.py | 24 +++
.../headless/test_ime_state_batch.py | 142 ++++++++++++++++++
11 files changed, 528 insertions(+)
create mode 100644 docs/source/Eng/doc/new_features/v208_features_doc.rst
create mode 100644 docs/source/Zh/doc/new_features/v208_features_doc.rst
create mode 100644 je_auto_control/utils/ime_state/__init__.py
create mode 100644 je_auto_control/utils/ime_state/ime_state.py
create mode 100644 test/unit_test/headless/test_ime_state_batch.py
diff --git a/WHATS_NEW.md b/WHATS_NEW.md
index 0395fbb5..13c1a794 100644
--- a/WHATS_NEW.md
+++ b/WHATS_NEW.md
@@ -2,6 +2,12 @@
## What's new (2026-06-26)
+### Live IME State for Safe CJK Entry
+
+Wait for the input method to commit before reading a Japanese/Chinese/Korean field. Full reference: [`docs/source/Eng/doc/new_features/v208_features_doc.rst`](docs/source/Eng/doc/new_features/v208_features_doc.rst).
+
+- **`ime_state` / `is_composing` / `wait_for_composition_commit` / `decode_conversion_mode`** (`AC_ime_state`, `AC_is_composing`, `AC_wait_for_composition_commit`, `AC_decode_conversion_mode`): typing into a CJK field is unsafe while an IME is *composing* — the candidate text isn't committed, so reading the field back returns half-entered glyphs and the next keystroke edits the composition. `text_unicode` (`VK_PACKET`) is blind to this. `ime_state` exposes the focused window's live `{open, composing, composition, conversion}` (Windows IMM32, read-only) through an injectable `reader`; `is_composing` is the boolean gate; `wait_for_composition_commit` blocks until the IME commits (injectable `clock`/`sleep`/`reader`); `decode_conversion_mode` is the pure `IME_CMODE_*` bitmask decoder. All decode/wait logic is unit-tested without an IME. Sixth feature of the ROUND-15 cross-app OS lane. No `PySide6`.
+
### Lock the Workstation + Wait for Unlock
Lock the box at the end of a run, and block until a human unlocks it before resuming. Full reference: [`docs/source/Eng/doc/new_features/v207_features_doc.rst`](docs/source/Eng/doc/new_features/v207_features_doc.rst).
diff --git a/docs/source/Eng/doc/new_features/v208_features_doc.rst b/docs/source/Eng/doc/new_features/v208_features_doc.rst
new file mode 100644
index 00000000..cffcf275
--- /dev/null
+++ b/docs/source/Eng/doc/new_features/v208_features_doc.rst
@@ -0,0 +1,57 @@
+Live IME State for Safe CJK Entry
+=================================
+
+Typing into a CJK / Japanese / Korean field is unsafe while an IME (input method
+editor) is *composing*: the candidate text has not been committed yet, so
+reading the field back returns half-entered glyphs and the next keystroke edits
+the composition instead of the field. ``text_unicode`` (``VK_PACKET``) is blind
+to this. ``ime_state`` exposes the live composition and conversion state so a
+flow can wait for the IME to commit before it reads or acts.
+
+* :func:`ime_state` — ``{open, composing, composition, conversion,
+ conversion_flags}`` for the focused window's IME, through an injectable
+ ``reader``.
+* :func:`is_composing` — ``True`` while the IME has an uncommitted composition.
+* :func:`wait_for_composition_commit` — block until composition ends (or a
+ timeout), with injectable ``clock`` / ``sleep`` / ``reader``.
+* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion
+ bitmask to ``{native, katakana, full_shape, roman, char_code}``.
+
+The default ``reader`` queries Windows IMM32 (``ImmGetContext`` /
+``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``)
+read-only; all decoding / waiting logic runs through the injectable seam, so it
+is fully testable without an IME. Imports no ``PySide6``.
+
+Headless API
+------------
+
+.. code-block:: python
+
+ from je_auto_control import (
+ ime_state, is_composing, wait_for_composition_commit,
+ )
+
+ # Before reading a CJK field, make sure the IME has committed
+ if wait_for_composition_commit(timeout_s=3):
+ value = read_field()
+
+ is_composing() # True while candidate text is still on screen
+ ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...}
+
+For tests (or any non-Windows host) pass a ``reader`` — a
+``() -> {open, conversion, composition}``:
+
+.. code-block:: python
+
+ busy = lambda: {"open": True, "conversion": 0, "composition": "あ"}
+ is_composing(reader=busy) # True
+ ime_state(reader=busy)["composition"] # 'あ'
+
+Executor commands
+-----------------
+
+``AC_ime_state`` (→ the full state), ``AC_is_composing`` (→ ``{composing}``),
+``AC_wait_for_composition_commit`` (``timeout`` / ``interval`` →
+``{committed}``) and ``AC_decode_conversion_mode`` (``flags`` → the decoded
+modes). They are exposed as the matching read-only ``ac_*`` MCP tools and as
+Script Builder commands under **Shell**.
diff --git a/docs/source/Zh/doc/new_features/v208_features_doc.rst b/docs/source/Zh/doc/new_features/v208_features_doc.rst
new file mode 100644
index 00000000..4ffafc32
--- /dev/null
+++ b/docs/source/Zh/doc/new_features/v208_features_doc.rst
@@ -0,0 +1,51 @@
+即時 IME 狀態以利安全的 CJK 輸入
+================================
+
+在 IME(輸入法)*組字中*對 CJK / 日文 / 韓文欄位輸入並不安全:候選字尚未送出,故讀回欄位會得到
+半成形的字,而下一個按鍵會編輯組字而非欄位。``text_unicode``(``VK_PACKET``)對此一無所知。
+``ime_state`` 暴露即時的組字與轉換狀態,讓流程能在讀取或操作前等待 IME 送出。
+
+* :func:`ime_state` ——聚焦視窗 IME 的 ``{open, composing, composition, conversion,
+ conversion_flags}``,透過可注入的 ``reader``。
+* :func:`is_composing` ——當 IME 有尚未送出的組字時回傳 ``True``。
+* :func:`wait_for_composition_commit` ——阻塞直到組字結束(或逾時),``clock`` / ``sleep`` /
+ ``reader`` 皆可注入。
+* :func:`decode_conversion_mode` ——純函式:把 IMM32 ``IME_CMODE_*`` 轉換位元遮罩解碼為
+ ``{native, katakana, full_shape, roman, char_code}``。
+
+預設 ``reader`` 以唯讀方式查詢 Windows IMM32(``ImmGetContext`` / ``ImmGetOpenStatus`` /
+``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``);所有解碼 / 等待邏輯都透過可注入接縫
+執行,故能在沒有 IME 的情況下完整測試。不匯入 ``PySide6``。
+
+無頭 API
+--------
+
+.. code-block:: python
+
+ from je_auto_control import (
+ ime_state, is_composing, wait_for_composition_commit,
+ )
+
+ # 讀取 CJK 欄位前,先確認 IME 已送出
+ if wait_for_composition_commit(timeout_s=3):
+ value = read_field()
+
+ is_composing() # 候選字仍在畫面上時為 True
+ ime_state() # {'open': True, 'composing': True, 'composition': 'あ', ...}
+
+測試時(或任何非 Windows 主機)可傳入 ``reader`` ——一個
+``() -> {open, conversion, composition}``:
+
+.. code-block:: python
+
+ busy = lambda: {"open": True, "conversion": 0, "composition": "あ"}
+ is_composing(reader=busy) # True
+ ime_state(reader=busy)["composition"] # 'あ'
+
+執行器指令
+----------
+
+``AC_ime_state``(→ 完整狀態)、``AC_is_composing``(→ ``{composing}``)、
+``AC_wait_for_composition_commit``(``timeout`` / ``interval`` → ``{committed}``)
+與 ``AC_decode_conversion_mode``(``flags`` → 解碼後的模式)。皆以對應的唯讀 ``ac_*`` MCP 工具
+及 Script Builder 指令(位於 **Shell** 分類下)形式提供。
diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py
index 10a47b62..5934b1b7 100644
--- a/je_auto_control/__init__.py
+++ b/je_auto_control/__init__.py
@@ -110,6 +110,11 @@
classify_lock_transitions, lock_session, plan_lock_session,
wait_for_lock, wait_for_unlock,
)
+# Read the live IME composition / conversion state for safe CJK entry
+from je_auto_control.utils.ime_state import (
+ decode_conversion_mode, ime_state, is_composing,
+ wait_for_composition_commit,
+)
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
from je_auto_control.utils.clipboard_rich_formats import (
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
@@ -1727,6 +1732,8 @@ def start_autocontrol_gui(*args, **kwargs):
"is_muted", "set_mute", "mute", "unmute", "toggle_mute",
"lock_session", "plan_lock_session", "wait_for_unlock",
"wait_for_lock", "classify_lock_transitions",
+ "ime_state", "is_composing", "wait_for_composition_commit",
+ "decode_conversion_mode",
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
"set_clipboard_rtf", "get_clipboard_rtf",
"set_clipboard_csv", "get_clipboard_csv",
diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py
index e8b56871..a3fefd42 100644
--- a/je_auto_control/gui/script_builder/command_schema.py
+++ b/je_auto_control/gui/script_builder/command_schema.py
@@ -4373,6 +4373,34 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None:
),
description="Reduce lock-state samples to lock / unlock events.",
))
+ specs.append(CommandSpec(
+ "AC_ime_state", "Shell", "IME State",
+ fields=(),
+ description="Read the focused window's live IME composition state.",
+ ))
+ specs.append(CommandSpec(
+ "AC_is_composing", "Shell", "Is IME Composing",
+ fields=(),
+ description="True while the IME has an uncommitted composition.",
+ ))
+ specs.append(CommandSpec(
+ "AC_wait_for_composition_commit", "Shell", "Wait for IME Commit",
+ fields=(
+ FieldSpec("timeout", FieldType.FLOAT, optional=True, default=5.0,
+ placeholder="timeout seconds"),
+ FieldSpec("interval", FieldType.FLOAT, optional=True, default=0.1,
+ placeholder="poll interval seconds"),
+ ),
+ description="Block until the IME finishes composing or timeout.",
+ ))
+ specs.append(CommandSpec(
+ "AC_decode_conversion_mode", "Shell", "Decode IME Conversion Mode",
+ fields=(
+ FieldSpec("flags", FieldType.INT, default=0,
+ placeholder="IMM32 conversion bitmask"),
+ ),
+ description="Decode an IMM32 conversion bitmask into named flags.",
+ ))
specs.append(CommandSpec(
"AC_normalize_ext", "Shell", "Normalize Extension",
fields=(
diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py
index 0f751890..d061a072 100644
--- a/je_auto_control/utils/executor/action_executor.py
+++ b/je_auto_control/utils/executor/action_executor.py
@@ -2711,6 +2711,33 @@ def _classify_lock_transitions(states: Any) -> Dict[str, Any]:
return {"events": classify_lock_transitions(samples)}
+def _ime_state() -> Dict[str, Any]:
+ """Adapter: the focused window's live IME composition / conversion state."""
+ from je_auto_control.utils.ime_state import ime_state
+ return ime_state()
+
+
+def _is_composing() -> Dict[str, Any]:
+ """Adapter: whether the IME has an uncommitted composition."""
+ from je_auto_control.utils.ime_state import is_composing
+ return {"composing": bool(is_composing())}
+
+
+def _wait_for_composition_commit(timeout: Any = 5.0, interval: Any = 0.1
+ ) -> Dict[str, Any]:
+ """Adapter: block until the IME finishes composing or timeout."""
+ from je_auto_control.utils.ime_state import wait_for_composition_commit
+ committed = wait_for_composition_commit(timeout_s=float(timeout),
+ interval_s=float(interval))
+ return {"committed": bool(committed)}
+
+
+def _decode_conversion_mode(flags: Any) -> Dict[str, Any]:
+ """Adapter: decode an IMM32 conversion bitmask into named flags (pure)."""
+ from je_auto_control.utils.ime_state import decode_conversion_mode
+ return decode_conversion_mode(int(flags))
+
+
def _normalize_ext(target: str) -> Dict[str, Any]:
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
from je_auto_control.utils.file_assoc import normalize_ext
@@ -6729,6 +6756,10 @@ def __init__(self):
"AC_plan_lock_session": _plan_lock_session,
"AC_wait_for_unlock": _wait_for_unlock,
"AC_classify_lock_transitions": _classify_lock_transitions,
+ "AC_ime_state": _ime_state,
+ "AC_is_composing": _is_composing,
+ "AC_wait_for_composition_commit": _wait_for_composition_commit,
+ "AC_decode_conversion_mode": _decode_conversion_mode,
"AC_normalize_ext": _normalize_ext,
"AC_file_association": _file_association,
"AC_get_control_text": _get_control_text,
diff --git a/je_auto_control/utils/ime_state/__init__.py b/je_auto_control/utils/ime_state/__init__.py
new file mode 100644
index 00000000..5613d1c6
--- /dev/null
+++ b/je_auto_control/utils/ime_state/__init__.py
@@ -0,0 +1,10 @@
+"""Read the live IME composition / conversion state for safe CJK entry."""
+from je_auto_control.utils.ime_state.ime_state import (
+ decode_conversion_mode, ime_state, is_composing,
+ wait_for_composition_commit,
+)
+
+__all__ = [
+ "ime_state", "is_composing", "wait_for_composition_commit",
+ "decode_conversion_mode",
+]
diff --git a/je_auto_control/utils/ime_state/ime_state.py b/je_auto_control/utils/ime_state/ime_state.py
new file mode 100644
index 00000000..6123af5e
--- /dev/null
+++ b/je_auto_control/utils/ime_state/ime_state.py
@@ -0,0 +1,134 @@
+"""Read the live IME (input method editor) state for safe CJK entry.
+
+Typing into a CJK / Japanese / Korean field is unsafe while an IME is *composing*:
+the candidate text has not been committed yet, so reading the field back returns
+half-entered glyphs and the next keystroke edits the composition instead of the
+field. ``text_unicode`` (``VK_PACKET``) is blind to this. ``ime_state`` exposes
+the live composition and conversion state so a flow can wait for the IME to
+commit before it reads or acts.
+
+* :func:`ime_state` — ``{open, composing, composition, conversion}`` for the
+ focused window's IME, through an injectable ``reader``.
+* :func:`is_composing` — ``True`` while the IME has an uncommitted composition.
+* :func:`wait_for_composition_commit` — block until composition ends (or a
+ timeout), with injectable ``clock`` / ``sleep`` / ``reader``.
+* :func:`decode_conversion_mode` — pure: the IMM32 ``IME_CMODE_*`` conversion
+ bitmask to ``{native, katakana, full_shape, roman, char_code}``.
+
+The default ``reader`` queries Windows IMM32 (``ImmGetContext`` /
+``ImmGetOpenStatus`` / ``ImmGetConversionStatus`` / ``ImmGetCompositionStringW``)
+read-only; all decoding / waiting logic runs through the injectable seam, so it
+is fully testable without an IME. Imports no ``PySide6``.
+"""
+import sys
+import time
+from typing import Any, Callable, Dict, Optional
+
+# IMM32 conversion-mode (IME_CMODE_*) bit flags.
+IME_CMODE_NATIVE = 0x0001
+IME_CMODE_KATAKANA = 0x0002
+IME_CMODE_FULLSHAPE = 0x0008
+IME_CMODE_ROMAN = 0x0010
+IME_CMODE_CHARCODE = 0x0020
+
+# A reader returns the raw IME state: {open, conversion, composition}.
+ImeReader = Callable[[], Dict[str, Any]]
+
+
+def decode_conversion_mode(flags: int) -> Dict[str, bool]:
+ """Decode an IMM32 ``IME_CMODE_*`` bitmask into named booleans (pure)."""
+ value = int(flags)
+ return {
+ "native": bool(value & IME_CMODE_NATIVE),
+ "katakana": bool(value & IME_CMODE_KATAKANA),
+ "full_shape": bool(value & IME_CMODE_FULLSHAPE),
+ "roman": bool(value & IME_CMODE_ROMAN),
+ "char_code": bool(value & IME_CMODE_CHARCODE),
+ }
+
+
+def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]:
+ """Turn a raw reader result into the public IME-state dict (pure)."""
+ composition = str(raw.get("composition") or "")
+ flags = int(raw.get("conversion") or 0)
+ return {
+ "open": bool(raw.get("open")),
+ "composing": bool(composition),
+ "composition": composition,
+ "conversion": decode_conversion_mode(flags),
+ "conversion_flags": flags,
+ }
+
+
+def ime_state(*, reader: Optional[ImeReader] = None) -> Dict[str, Any]:
+ """Return the focused window's IME state.
+
+ ``{open, composing, composition, conversion, conversion_flags}``. Pass
+ ``reader`` (a ``() -> {open, conversion, composition}``) to supply the
+ reading in tests; the default queries Windows IMM32.
+ """
+ source = reader if reader is not None else _default_reader
+ return _normalize(source())
+
+
+def is_composing(*, reader: Optional[ImeReader] = None) -> bool:
+ """Return ``True`` while the IME has an uncommitted composition."""
+ return bool(ime_state(reader=reader)["composing"])
+
+
+def wait_for_composition_commit(
+ *, reader: Optional[ImeReader] = None, timeout_s: float = 5.0,
+ interval_s: float = 0.1, clock: Callable[[], float] = time.monotonic,
+ sleep: Callable[[float], None] = time.sleep) -> bool:
+ """Block until the IME is no longer composing; ``True``, or ``False`` on timeout.
+
+ ``clock`` / ``sleep`` / ``reader`` are injectable for deterministic tests.
+ """
+ deadline = clock() + float(timeout_s)
+ while True:
+ if not is_composing(reader=reader):
+ return True
+ if clock() >= deadline:
+ return False
+ sleep(float(interval_s))
+
+
+# IMM32 composition-string flag: read the in-progress composition (GCS_COMPSTR).
+_GCS_COMPSTR = 0x0008
+
+
+def _read_composition(imm32: Any, himc: int) -> str:
+ """Read the in-progress composition string from an IME context."""
+ import ctypes
+ byte_len = imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, None, 0)
+ if byte_len <= 0:
+ return ""
+ buffer = ctypes.create_unicode_buffer(byte_len // 2)
+ imm32.ImmGetCompositionStringW(himc, _GCS_COMPSTR, buffer, byte_len)
+ return buffer.value
+
+
+def _default_reader() -> Dict[str, Any]:
+ """Read the focused window's IME state from Windows IMM32 (read-only)."""
+ if not sys.platform.startswith("win"):
+ raise RuntimeError(
+ "IME state has no OS reader on this platform; pass reader=")
+ import ctypes
+ user32 = ctypes.windll.user32
+ imm32 = ctypes.windll.imm32
+ hwnd = user32.GetForegroundWindow()
+ himc = imm32.ImmGetContext(hwnd)
+ if not himc:
+ return {"open": False, "conversion": 0, "composition": ""}
+ try:
+ conversion = ctypes.c_uint(0)
+ sentence = ctypes.c_uint(0)
+ imm32.ImmGetConversionStatus(
+ himc, ctypes.byref(conversion), ctypes.byref(sentence))
+ return {
+ "open": bool(imm32.ImmGetOpenStatus(himc)),
+ "conversion": int(conversion.value),
+ "composition": _read_composition(imm32, himc),
+ }
+ finally:
+ imm32.ImmReleaseContext(hwnd, himc)
diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py
index 83df5f93..4e0ce256 100644
--- a/je_auto_control/utils/mcp_server/tools/_factories.py
+++ b/je_auto_control/utils/mcp_server/tools/_factories.py
@@ -2249,6 +2249,44 @@ def process_and_shell_tools() -> List[MCPTool]:
handler=h.classify_lock_transitions,
annotations=READ_ONLY,
),
+ MCPTool(
+ name="ac_ime_state",
+ description=("Read the focused window's live IME state (Windows "
+ "IMM32). Returns {open, composing, composition, "
+ "conversion, conversion_flags}."),
+ input_schema=schema({}),
+ handler=h.ime_state,
+ annotations=READ_ONLY,
+ ),
+ MCPTool(
+ name="ac_is_composing",
+ description=("Whether the IME has an uncommitted composition "
+ "(unsafe to type / read the field). Returns "
+ "{composing}."),
+ input_schema=schema({}),
+ handler=h.is_composing,
+ annotations=READ_ONLY,
+ ),
+ MCPTool(
+ name="ac_wait_for_composition_commit",
+ description=("Block until the IME finishes composing (or 'timeout' "
+ "seconds), polling every 'interval'. Returns "
+ "{committed}."),
+ input_schema=schema({"timeout": {"type": "number"},
+ "interval": {"type": "number"}}),
+ handler=h.wait_for_composition_commit,
+ annotations=READ_ONLY,
+ ),
+ MCPTool(
+ name="ac_decode_conversion_mode",
+ description=("Decode an IMM32 IME_CMODE_* conversion bitmask "
+ "('flags') into {native, katakana, full_shape, roman, "
+ "char_code} (pure)."),
+ input_schema=schema({"flags": {"type": "integer"}},
+ required=["flags"]),
+ handler=h.decode_conversion_mode,
+ annotations=READ_ONLY,
+ ),
]
diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py
index 613adf2d..62b0f307 100644
--- a/je_auto_control/utils/mcp_server/tools/_handlers.py
+++ b/je_auto_control/utils/mcp_server/tools/_handlers.py
@@ -644,6 +644,30 @@ def classify_lock_transitions(states):
return _classify_lock_transitions(states)
+def ime_state():
+ from je_auto_control.utils.executor.action_executor import _ime_state
+ return _ime_state()
+
+
+def is_composing():
+ from je_auto_control.utils.executor.action_executor import _is_composing
+ return _is_composing()
+
+
+def wait_for_composition_commit(timeout=5.0, interval=0.1):
+ from je_auto_control.utils.executor.action_executor import (
+ _wait_for_composition_commit,
+ )
+ return _wait_for_composition_commit(timeout, interval)
+
+
+def decode_conversion_mode(flags):
+ from je_auto_control.utils.executor.action_executor import (
+ _decode_conversion_mode,
+ )
+ return _decode_conversion_mode(flags)
+
+
def normalize_ext(target):
from je_auto_control.utils.executor.action_executor import _normalize_ext
return _normalize_ext(target)
diff --git a/test/unit_test/headless/test_ime_state_batch.py b/test/unit_test/headless/test_ime_state_batch.py
new file mode 100644
index 00000000..15974f6b
--- /dev/null
+++ b/test/unit_test/headless/test_ime_state_batch.py
@@ -0,0 +1,142 @@
+"""Headless tests for ime_state (injected reader / clock)."""
+import sys
+
+import je_auto_control as ac
+from je_auto_control.utils.ime_state import (
+ decode_conversion_mode, ime_state, is_composing,
+ wait_for_composition_commit,
+)
+from je_auto_control.utils.ime_state.ime_state import (
+ IME_CMODE_FULLSHAPE, IME_CMODE_NATIVE, IME_CMODE_ROMAN,
+)
+
+
+# --- pure conversion-mode decode ------------------------------------------
+
+def test_decode_conversion_mode_native_roman():
+ flags = IME_CMODE_NATIVE | IME_CMODE_ROMAN
+ decoded = decode_conversion_mode(flags)
+ assert decoded["native"] is True
+ assert decoded["roman"] is True
+ assert decoded["full_shape"] is False
+ assert decoded["katakana"] is False
+
+
+def test_decode_conversion_mode_zero_all_false():
+ decoded = decode_conversion_mode(0)
+ assert decoded == {"native": False, "katakana": False, "full_shape": False,
+ "roman": False, "char_code": False}
+
+
+# --- state via injected reader --------------------------------------------
+
+def test_ime_state_composing():
+ state = ime_state(reader=lambda: {"open": True,
+ "conversion": IME_CMODE_NATIVE,
+ "composition": "あ"})
+ assert state["open"] is True
+ assert state["composing"] is True
+ assert state["composition"] == "あ"
+ assert state["conversion"]["native"] is True
+ assert state["conversion_flags"] == IME_CMODE_NATIVE
+
+
+def test_ime_state_idle_not_composing():
+ state = ime_state(
+ reader=lambda: {"open": False, "conversion": 0, "composition": ""})
+ assert state["composing"] is False
+ assert state["composition"] == ""
+
+
+def test_ime_state_tolerates_missing_keys():
+ state = ime_state(reader=dict) # empty dict
+ assert state["open"] is False
+ assert state["composing"] is False
+ assert state["conversion_flags"] == 0
+
+
+def test_is_composing_reflects_reader():
+ assert is_composing(
+ reader=lambda: {"composition": "한", "conversion": 0,
+ "open": True}) is True
+ assert is_composing(
+ reader=lambda: {"composition": "", "conversion": 0,
+ "open": True}) is False
+
+
+# --- wait for commit ------------------------------------------------------
+
+def _reader_sequence(compositions):
+ state = {"i": 0}
+
+ def reader():
+ i = min(state["i"], len(compositions) - 1)
+ state["i"] += 1
+ return {"open": True, "conversion": 0, "composition": compositions[i]}
+
+ return reader
+
+
+def test_wait_for_composition_commit_returns_true():
+ reader = _reader_sequence(["typ", "typi", ""]) # commits on 3rd read
+ clock = iter([0.0, 1.0, 2.0, 3.0, 4.0])
+ ok = wait_for_composition_commit(reader=reader, timeout_s=10.0,
+ interval_s=1.0,
+ clock=lambda: next(clock),
+ sleep=lambda _s: None)
+ assert ok is True
+
+
+def test_wait_for_composition_commit_times_out():
+ reader = _reader_sequence(["forever"]) # never commits
+ times = iter([0.0, 0.0, 5.0, 10.0])
+ ok = wait_for_composition_commit(reader=reader, timeout_s=5.0,
+ interval_s=1.0,
+ clock=lambda: next(times),
+ sleep=lambda _s: None)
+ assert ok is False
+
+
+# --- wiring ---------------------------------------------------------------
+
+def test_executor_pure_decode_path():
+ from je_auto_control.utils.executor.action_executor import (
+ _decode_conversion_mode,
+ )
+ assert _decode_conversion_mode(IME_CMODE_FULLSHAPE)["full_shape"] is True
+
+
+def test_default_reader_raises_off_windows():
+ from je_auto_control.utils.ime_state.ime_state import _default_reader
+ if not sys.platform.startswith("win"):
+ try:
+ _default_reader()
+ raised = False
+ except RuntimeError:
+ raised = True
+ assert raised is True
+
+
+def test_wiring():
+ known = set(ac.executor.known_commands())
+ assert {"AC_ime_state", "AC_is_composing",
+ "AC_wait_for_composition_commit",
+ "AC_decode_conversion_mode"} <= known
+ from je_auto_control.utils.mcp_server.tools import (
+ build_default_tool_registry,
+ )
+ names = {t.name for t in build_default_tool_registry()}
+ assert {"ac_ime_state", "ac_is_composing",
+ "ac_wait_for_composition_commit",
+ "ac_decode_conversion_mode"} <= names
+ from je_auto_control.gui.script_builder.command_schema import _build_specs
+ specs = {s.command for s in _build_specs()}
+ assert {"AC_ime_state", "AC_is_composing",
+ "AC_wait_for_composition_commit",
+ "AC_decode_conversion_mode"} <= specs
+
+
+def test_facade_exports():
+ for name in ("ime_state", "is_composing", "wait_for_composition_commit",
+ "decode_conversion_mode"):
+ assert hasattr(ac, name) and name in ac.__all__