diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 2732b174..7e97b9ed 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -2,6 +2,12 @@ ## What's new (2026-06-26) +### Colour-Vision-Deficiency Simulation + Collision Check + +Check whether your red/green status colours are distinguishable to colour-blind users. Full reference: [`docs/source/Eng/doc/new_features/v214_features_doc.rst`](docs/source/Eng/doc/new_features/v214_features_doc.rst). + +- **`simulate_cvd` / `colors_collide` / `color_distance`** (`AC_simulate_cvd`, `AC_colors_collide`): status UIs lean on colour (green "ok" vs red "error"), but for the ~8% of men with a colour-vision deficiency those can be indistinguishable — and nothing in the framework could check it. `simulate_cvd` maps an RGB colour through a dichromat simulation matrix (protanopia/deuteranopia/tritanopia) at a given `severity`; `colors_collide` simulates two colours and reports whether they become confusable (a perceptual `redmean` distance below `threshold`); `color_distance` is the underlying metric. Pure standard library — no numpy/OpenCV, operating on plain RGB tuples, fully testable. First feature of the ROUND-15 perception lane. No `PySide6`. + ### Wait Until the App Is Idle Hold off the next click until the busy/wait cursor settles — don't act mid-churn. Full reference: [`docs/source/Eng/doc/new_features/v213_features_doc.rst`](docs/source/Eng/doc/new_features/v213_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v214_features_doc.rst b/docs/source/Eng/doc/new_features/v214_features_doc.rst new file mode 100644 index 00000000..2bae89c4 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v214_features_doc.rst @@ -0,0 +1,48 @@ +Colour-Vision-Deficiency Simulation + Collision Check +===================================================== + +Status UIs lean on colour — a green "ok" vs a red "error" dot, a colour-coded +chart legend. For the ~8% of men with a colour-vision deficiency (CVD) those can +be indistinguishable, and nothing in the framework could check it. +``cvd_simulate`` adds the two primitives an accessibility / design check needs. + +* :func:`simulate_cvd` — map an ``(r, g, b)`` colour through a dichromat + simulation matrix (``protanopia`` / ``deuteranopia`` / ``tritanopia``) at a + given ``severity`` (0 = unaffected, 1 = full dichromacy). +* :func:`colors_collide` — simulate two colours under a CVD type and report + whether they become too similar to tell apart (a perceptual ``redmean`` + distance below ``threshold``). +* :func:`color_distance` — the underlying ``redmean`` colour-difference metric. + +Pure standard library — no numpy / OpenCV — operating on plain RGB tuples, so it +is fully testable. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import simulate_cvd, colors_collide + + # How does the "error red" look to a deuteranope? + simulate_cvd((220, 40, 40), "deuteranopia") # -> (r, g, b) + + # Are my ok-green and error-red distinguishable for them? + report = colors_collide((60, 200, 60), (220, 60, 60), kind="deuteranopia") + report["collide"] # True if the two are confusable + report["distance"] # the perceptual distance after simulation + +``simulate_cvd`` accepts friendly aliases (``protan`` / ``deutan`` / ``tritan``, +or ``red`` / ``green`` / ``blue``). ``severity`` interpolates between the +original colour and the full dichromat simulation, for the milder anomalous +trichromacies. ``colors_collide`` returns ``{collide, distance, kind, severity, +simulated_left, simulated_right}``. + +Executor commands +----------------- + +``AC_simulate_cvd`` (``rgb`` ``[r, g, b]`` + ``kind`` / ``severity`` → +``{rgb}``) and ``AC_colors_collide`` (``left`` / ``right`` ``[r, g, b]`` + +``kind`` / ``severity`` / ``threshold`` → the report). RGB inputs accept a JSON +list. They are the matching read-only ``ac_*`` MCP tools and Script Builder +commands under **Image**. diff --git a/docs/source/Zh/doc/new_features/v214_features_doc.rst b/docs/source/Zh/doc/new_features/v214_features_doc.rst new file mode 100644 index 00000000..74cfd3e6 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v214_features_doc.rst @@ -0,0 +1,42 @@ +色覺辨認障礙模擬 + 碰撞檢查 +========================== + +狀態 UI 仰賴顏色——綠色「正常」對紅色「錯誤」的圓點、以顏色編碼的圖表圖例。對約 8% 有色覺辨認障礙 +(CVD)的男性而言,這些可能難以分辨,而框架原本無從檢查。``cvd_simulate`` 補上無障礙 / 設計檢查 +所需的兩個原語。 + +* :func:`simulate_cvd` ——把 ``(r, g, b)`` 顏色透過二色覺模擬矩陣(``protanopia`` / + ``deuteranopia`` / ``tritanopia``)在給定 ``severity``(0 = 不受影響,1 = 完全二色覺)下映射。 +* :func:`colors_collide` ——在某 CVD 類型下模擬兩個顏色,並回報它們是否變得太相似而難以區分 + (模擬後的感知 ``redmean`` 距離低於 ``threshold``)。 +* :func:`color_distance` ——底層的 ``redmean`` 色差度量。 + +純標準函式庫——不需 numpy / OpenCV——以單純的 RGB tuple 運作,故能完整測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import simulate_cvd, colors_collide + + # 「錯誤紅」在綠色弱者眼中看起來如何? + simulate_cvd((220, 40, 40), "deuteranopia") # -> (r, g, b) + + # 我的正常綠與錯誤紅對他們是否可區分? + report = colors_collide((60, 200, 60), (220, 60, 60), kind="deuteranopia") + report["collide"] # 若兩者易混淆則為 True + report["distance"] # 模擬後的感知距離 + +``simulate_cvd`` 接受友善別名(``protan`` / ``deutan`` / ``tritan``,或 +``red`` / ``green`` / ``blue``)。``severity`` 在原色與完全二色覺模擬之間插值, +用於較輕微的異常三色覺。``colors_collide`` 回傳 ``{collide, distance, kind, severity, +simulated_left, simulated_right}``。 + +執行器指令 +---------- + +``AC_simulate_cvd``(``rgb`` ``[r, g, b]`` 加上 ``kind`` / ``severity`` → +``{rgb}``)與 ``AC_colors_collide``(``left`` / ``right`` ``[r, g, b]`` 加上 +``kind`` / ``severity`` / ``threshold`` → 報告)。RGB 輸入接受 JSON 清單。皆以對應的唯讀 +``ac_*`` MCP 工具及 Script Builder 指令(位於 **Image** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 21dd9129..9930a9e4 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -131,6 +131,10 @@ from je_auto_control.utils.ensure_state import ensure_state, ensure_toggle # Wait until an application stops being busy before the next step from je_auto_control.utils.app_idle import idle_point, wait_until_app_idle +# Colour-vision-deficiency simulation + colour-collision check +from je_auto_control.utils.cvd_simulate import ( + color_distance, colors_collide, simulate_cvd, +) # 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, @@ -1755,6 +1759,7 @@ def start_autocontrol_gui(*args, **kwargs): "recommend_timeout", "timeout_stats", "ensure_state", "ensure_toggle", "wait_until_app_idle", "idle_point", + "simulate_cvd", "colors_collide", "color_distance", "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 96092eb9..996c0594 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4522,6 +4522,31 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: ), description="Index where a busy/idle series first settles idle.", )) + specs.append(CommandSpec( + "AC_simulate_cvd", "Image", "Simulate Colour-Vision Deficiency", + fields=( + FieldSpec("rgb", FieldType.STRING, placeholder="[r, g, b]"), + FieldSpec("kind", FieldType.STRING, optional=True, + default="deuteranopia", + placeholder="protanopia / deuteranopia / tritanopia"), + FieldSpec("severity", FieldType.FLOAT, optional=True, default=1.0), + ), + description="Map an RGB colour through a CVD simulation matrix.", + )) + specs.append(CommandSpec( + "AC_colors_collide", "Image", "Colours Collide Under CVD", + fields=( + FieldSpec("left", FieldType.STRING, placeholder="[r, g, b]"), + FieldSpec("right", FieldType.STRING, placeholder="[r, g, b]"), + FieldSpec("kind", FieldType.STRING, optional=True, + default="deuteranopia", + placeholder="protanopia / deuteranopia / tritanopia"), + FieldSpec("severity", FieldType.FLOAT, optional=True, default=1.0), + FieldSpec("threshold", FieldType.FLOAT, optional=True, + default=40.0), + ), + description="Whether two colours become confusable under a CVD type.", + )) specs.append(CommandSpec( "AC_normalize_ext", "Shell", "Normalize Extension", fields=( diff --git a/je_auto_control/utils/cvd_simulate/__init__.py b/je_auto_control/utils/cvd_simulate/__init__.py new file mode 100644 index 00000000..98c6b8df --- /dev/null +++ b/je_auto_control/utils/cvd_simulate/__init__.py @@ -0,0 +1,6 @@ +"""Simulate colour-vision deficiency and flag colours that collide under it.""" +from je_auto_control.utils.cvd_simulate.cvd_simulate import ( + CVD_KINDS, color_distance, colors_collide, simulate_cvd, +) + +__all__ = ["simulate_cvd", "colors_collide", "color_distance", "CVD_KINDS"] diff --git a/je_auto_control/utils/cvd_simulate/cvd_simulate.py b/je_auto_control/utils/cvd_simulate/cvd_simulate.py new file mode 100644 index 00000000..9835947b --- /dev/null +++ b/je_auto_control/utils/cvd_simulate/cvd_simulate.py @@ -0,0 +1,119 @@ +"""Simulate colour-vision deficiency and flag colours that collide under it. + +Status UIs lean on colour — a green "ok" vs a red "error" dot, a colour-coded +chart legend. For the ~8% of men with a colour-vision deficiency (CVD) those can +be indistinguishable, and nothing in the framework could check it. ``cvd_simulate`` +adds the two primitives an accessibility / design check needs: + +* :func:`simulate_cvd` — map an ``(r, g, b)`` colour through a dichromat + simulation matrix (protanopia / deuteranopia / tritanopia) at a given + ``severity`` (0 = unaffected, 1 = full dichromacy). +* :func:`colors_collide` — simulate two colours under a CVD type and report + whether they become too similar to tell apart (a perceptual ``redmean`` + distance below ``threshold``). + +Pure standard library (no numpy / OpenCV) — operates on plain RGB tuples, so it +is fully testable. Imports no ``PySide6``. +""" +import math +from typing import Any, Dict, List, Sequence, Tuple + +RGB = Tuple[int, int, int] + +# Dichromat simulation matrices (sRGB-space approximation, Brettel/Viénot +# lineage as used by daltonize tooling). Applied at severity 1.0; lower +# severities interpolate toward the identity. +_MATRICES: Dict[str, List[List[float]]] = { + "protanopia": [[0.567, 0.433, 0.000], + [0.558, 0.442, 0.000], + [0.000, 0.242, 0.758]], + "deuteranopia": [[0.625, 0.375, 0.000], + [0.700, 0.300, 0.000], + [0.000, 0.300, 0.700]], + "tritanopia": [[0.950, 0.050, 0.000], + [0.000, 0.433, 0.567], + [0.000, 0.475, 0.525]], +} + +# Friendly aliases for the canonical CVD kinds. +_ALIASES = { + "protan": "protanopia", "protanope": "protanopia", "red": "protanopia", + "deuter": "deuteranopia", "deutan": "deuteranopia", + "deuteranope": "deuteranopia", "green": "deuteranopia", + "tritan": "tritanopia", "tritanope": "tritanopia", "blue": "tritanopia", +} + +CVD_KINDS = tuple(_MATRICES) + + +def _canonical_kind(kind: str) -> str: + """Resolve a CVD kind name / alias to its canonical key.""" + key = str(kind).strip().lower() + canonical = _ALIASES.get(key, key) + if canonical not in _MATRICES: + raise ValueError(f"unknown CVD kind: {kind!r}") + return canonical + + +def _clamp_byte(value: float) -> int: + """Clamp a channel value to an integer in ``[0, 255]``.""" + return max(0, min(255, int(round(value)))) + + +def simulate_cvd(rgb: Sequence[float], kind: str = "deuteranopia", + severity: float = 1.0) -> RGB: + """Return ``rgb`` as seen under ``kind`` colour-vision deficiency. + + ``severity`` interpolates between the original colour (0) and the full + dichromat simulation (1). ``rgb`` channels are ``0..255``. + """ + matrix = _MATRICES[_canonical_kind(kind)] + strength = max(0.0, min(1.0, float(severity))) + channels = [float(rgb[0]), float(rgb[1]), float(rgb[2])] + result = [] + for index in range(3): + row = matrix[index] + simulated = row[0] * channels[0] + row[1] * channels[1] \ + + row[2] * channels[2] + blended = channels[index] * (1.0 - strength) + simulated * strength + result.append(_clamp_byte(blended)) + return result[0], result[1], result[2] + + +def color_distance(left: Sequence[float], right: Sequence[float]) -> float: + """Perceptual ``redmean`` distance between two RGB colours (pure). + + A low-cost approximation of perceived colour difference that weights the + channels by the average red level. + """ + red_mean = (float(left[0]) + float(right[0])) / 2.0 + delta_r = float(left[0]) - float(right[0]) + delta_g = float(left[1]) - float(right[1]) + delta_b = float(left[2]) - float(right[2]) + return math.sqrt((2 + red_mean / 256) * delta_r * delta_r + + 4 * delta_g * delta_g + + (2 + (255 - red_mean) / 256) * delta_b * delta_b) + + +def colors_collide(left: Sequence[float], right: Sequence[float], *, + kind: str = "deuteranopia", severity: float = 1.0, + threshold: float = 40.0) -> Dict[str, Any]: + """Report whether two colours become confusable under ``kind`` CVD. + + Simulates both colours and compares them with :func:`color_distance`; + ``collide`` is ``True`` when that distance is below ``threshold``. Returns + ``{collide, distance, kind, severity, simulated_left, simulated_right}``. + """ + canonical = _canonical_kind(kind) + strength = max(0.0, min(1.0, float(severity))) + sim_left = simulate_cvd(left, canonical, strength) + sim_right = simulate_cvd(right, canonical, strength) + distance = color_distance(sim_left, sim_right) + return { + "collide": distance < float(threshold), + "distance": round(distance, 3), + "kind": canonical, + "severity": strength, + "simulated_left": list(sim_left), + "simulated_right": list(sim_right), + } diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 56f4305b..42a80840 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2839,6 +2839,30 @@ def _idle_point(busy_samples: Any, quiet_samples: Any = 3) -> Dict[str, Any]: return {"index": idle_point(samples, quiet_samples=int(quiet_samples))} +def _coerce_rgb(value: Any) -> tuple: + """Normalise an RGB argument (JSON '[r,g,b]' string / list) to (r, g, b).""" + seq = _coerce_list(value) if isinstance(value, str) else list(value) + return (int(seq[0]), int(seq[1]), int(seq[2])) + + +def _simulate_cvd(rgb: Any, kind: Any = "deuteranopia", + severity: Any = 1.0) -> Dict[str, Any]: + """Adapter: map an RGB colour through a CVD simulation matrix (pure).""" + from je_auto_control.utils.cvd_simulate import simulate_cvd + result = simulate_cvd(_coerce_rgb(rgb), str(kind), float(severity)) + return {"rgb": list(result)} + + +def _colors_collide(left: Any, right: Any, kind: Any = "deuteranopia", + severity: Any = 1.0, threshold: Any = 40.0 + ) -> Dict[str, Any]: + """Adapter: whether two colours become confusable under a CVD type (pure).""" + from je_auto_control.utils.cvd_simulate import colors_collide + return colors_collide(_coerce_rgb(left), _coerce_rgb(right), + kind=str(kind), severity=float(severity), + threshold=float(threshold)) + + 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 @@ -6870,6 +6894,8 @@ def __init__(self): "AC_ensure_field_value": _ensure_field_value, "AC_wait_until_app_idle": _wait_until_app_idle, "AC_idle_point": _idle_point, + "AC_simulate_cvd": _simulate_cvd, + "AC_colors_collide": _colors_collide, "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 91e56b8b..c57aba83 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -4000,6 +4000,36 @@ def img_histogram_tools() -> List[MCPTool]: handler=h.most_salient, annotations=READ_ONLY, ), + MCPTool( + name="ac_simulate_cvd", + description=("Map an 'rgb' colour [r,g,b] through a colour-vision-" + "deficiency simulation (kind=protanopia/deuteranopia/" + "tritanopia, severity 0..1). Returns {rgb}."), + input_schema=schema({"rgb": {"type": "array", + "items": {"type": "integer"}}, + "kind": {"type": "string"}, + "severity": {"type": "number"}}, + required=["rgb"]), + handler=h.simulate_cvd, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_colors_collide", + description=("Whether two colours 'left'/'right' [r,g,b] become " + "confusable under a CVD 'kind' (distance below " + "'threshold'). Returns {collide, distance, kind, " + "severity, simulated_left, simulated_right}."), + input_schema=schema({"left": {"type": "array", + "items": {"type": "integer"}}, + "right": {"type": "array", + "items": {"type": "integer"}}, + "kind": {"type": "string"}, + "severity": {"type": "number"}, + "threshold": {"type": "number"}}, + required=["left", "right"]), + handler=h.colors_collide, + 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 ef5debeb..2d400a6d 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -733,6 +733,17 @@ def idle_point(busy_samples, quiet_samples=3): return _idle_point(busy_samples, quiet_samples) +def simulate_cvd(rgb, kind="deuteranopia", severity=1.0): + from je_auto_control.utils.executor.action_executor import _simulate_cvd + return _simulate_cvd(rgb, kind, severity) + + +def colors_collide(left, right, kind="deuteranopia", severity=1.0, + threshold=40.0): + from je_auto_control.utils.executor.action_executor import _colors_collide + return _colors_collide(left, right, kind, severity, threshold) + + 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_cvd_simulate_batch.py b/test/unit_test/headless/test_cvd_simulate_batch.py new file mode 100644 index 00000000..0501abc7 --- /dev/null +++ b/test/unit_test/headless/test_cvd_simulate_batch.py @@ -0,0 +1,119 @@ +"""Headless tests for cvd_simulate (pure colour-vision-deficiency math).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.cvd_simulate import ( + CVD_KINDS, color_distance, colors_collide, simulate_cvd, +) + + +# --- simulate_cvd --------------------------------------------------------- + +def test_simulate_severity_zero_is_identity(): + assert simulate_cvd((120, 200, 30), "deuteranopia", severity=0.0) == \ + (120, 200, 30) + + +def test_simulate_grey_is_unchanged(): + # rows of every matrix sum to 1, so a neutral grey maps to itself + for kind in CVD_KINDS: + assert simulate_cvd((128, 128, 128), kind) == (128, 128, 128) + + +def test_simulate_shifts_red_under_protanopia(): + simulated = simulate_cvd((255, 0, 0), "protanopia") + # pure red is strongly altered for a protanope (red cones missing) + assert simulated != (255, 0, 0) + assert all(0 <= channel <= 255 for channel in simulated) + + +def test_simulate_accepts_aliases(): + assert simulate_cvd((10, 20, 30), "green") == \ + simulate_cvd((10, 20, 30), "deuteranopia") + + +def test_simulate_unknown_kind_raises(): + with pytest.raises(ValueError): + simulate_cvd((1, 2, 3), "tetrachromacy") + + +def test_simulate_severity_clamped(): + # severity above 1 behaves like 1 (full simulation) + assert simulate_cvd((200, 50, 10), "protanopia", severity=5.0) == \ + simulate_cvd((200, 50, 10), "protanopia", severity=1.0) + + +# --- color_distance ------------------------------------------------------- + +def test_color_distance_zero_for_identical(): + assert color_distance((10, 20, 30), (10, 20, 30)) == pytest.approx(0.0) + + +def test_color_distance_positive_and_symmetric(): + forward = color_distance((255, 0, 0), (0, 255, 0)) + backward = color_distance((0, 255, 0), (255, 0, 0)) + assert forward > 0 + assert forward == pytest.approx(backward) + + +# --- colors_collide ------------------------------------------------------- + +def test_cvd_reduces_red_green_distance(): + # deuteranopia pulls red and green closer together than normal vision + red, green = (220, 60, 60), (60, 200, 60) + normal = color_distance(red, green) + cvd = color_distance(simulate_cvd(red, "deuteranopia"), + simulate_cvd(green, "deuteranopia")) + assert cvd < normal + + +def test_colors_collide_close_pair(): + # two muddy shades that map to nearly the same colour for a deuteranope + result = colors_collide((150, 120, 110), (135, 130, 110), + kind="deuteranopia") + assert result["collide"] is True + assert result["kind"] == "deuteranopia" + assert len(result["simulated_left"]) == 3 + + +def test_colors_collide_black_white_never_collide(): + result = colors_collide((0, 0, 0), (255, 255, 255)) + assert result["collide"] is False + assert result["distance"] > 40.0 + + +def test_colors_collide_threshold_respected(): + red, green = (220, 60, 60), (60, 200, 60) + assert colors_collide(red, green, threshold=40.0)["collide"] is False + assert colors_collide(red, green, threshold=200.0)["collide"] is True + + +# --- wiring --------------------------------------------------------------- + +def test_executor_paths(): + from je_auto_control.utils.executor.action_executor import ( + _colors_collide, _simulate_cvd, + ) + out = _simulate_cvd("[128, 128, 128]", "protanopia", 1.0) + assert out["rgb"] == [128, 128, 128] + collide = _colors_collide([150, 120, 110], [135, 130, 110], + "deuteranopia", 1.0, 40.0) + assert collide["collide"] is True + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_simulate_cvd", "AC_colors_collide"} <= 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_simulate_cvd", "ac_colors_collide"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_simulate_cvd", "AC_colors_collide"} <= specs + + +def test_facade_exports(): + for name in ("simulate_cvd", "colors_collide", "color_distance"): + assert hasattr(ac, name) and name in ac.__all__