migrate pyqt5 to pyside6

This commit is contained in:
ykcheungab
2026-09-21 09:34:56 +08:00
parent e276b5de0f
commit ccedb9ddd9
66 changed files with 2184 additions and 395 deletions
+6 -3
View File
@@ -29,7 +29,8 @@ Replaces the vendor example program it was ported from:
## Run it
PySide6 only — Qt's LGPLv3 binding, so an internal closed-source tool needs no
licence purchase. (`PyQt5`, which the studio app uses, is GPLv3-or-commercial.)
licence purchase. (PyQt5 is GPLv3-or-commercial; the studio app moved to PySide6
for the same reason and no longer uses it.)
```powershell
# with the environment that already has PySide6 (Anaconda py310, for example)
@@ -130,8 +131,10 @@ The suite is written for **PySide6**. Where PySide6 is missing, the test modules
are skipped rather than failed: a broken environment should not look like a
broken application. `tests/test_binding_purity.py` enforces the binding policy
statically — no module may import PyQt5/PySide2/PyQt6, no module may use an
unscoped Qt enumerator (removed in PySide6), `shared/` may import no binding at
all, and the controller interface must stay importable headless.
unscoped Qt enumerator (removed in PySide6), `shared/` may import PySide6 and no
other binding, and the controller interface must stay importable headless. The
studio app enforces the same rules for itself in
`apps/studio/tests/test_binding_purity.py`.
## Do not ship this app
+17 -2
View File
@@ -196,6 +196,8 @@
"validation_title": "Checks",
"points_title": "Calibration points",
"field_mode": "Surface mode",
"field_name": "Sequence name",
"field_note": "Note",
"field_pitch": "Pitch (mm)",
"field_margin": "Margin (mm)",
"field_radius": "Radius (mm)",
@@ -221,8 +223,11 @@
"btn_generate": "Generate points",
"btn_simulate": "Simulate",
"btn_dry_run": "Dry run (no contact)",
"btn_save": "Save sequence…",
"btn_load": "Load sequence…",
"btn_export": "Export sequence…",
"btn_import": "Import sequence…",
"import_export_tip": "Saved as a JSON document (.oryseq), reusable across machines; the format version is checked on import.",
"export_failed": "Export failed: {error}",
"import_failed": "Import failed: {error}",
"pending_tooltip": "This belongs to phase {phase} and is not implemented yet.",
"col_index": "#",
"col_x": "X (mm)",
@@ -234,6 +239,16 @@
"col_actions": "Actions",
"not_generated": "—",
"status_pending": "Pending",
"status_skipped": "Skipped",
"summary": "{count} points ({enabled} enabled) · {spans} · about {duration} (estimate)",
"summary_empty": "No points yet: generate or import to see the count and travel span.",
"dialog_export": "Export sequence",
"dialog_import": "Import sequence",
"filter_sequence": "Calibration sequence (*.oryseq *.json)",
"exported": "Sequence exported: {name}",
"imported": "Sequence imported: {name} ({count} points)",
"import_warnings": "Import reported {count} warning(s)",
"confirm_discard": "The current sequence has not been exported. Importing replaces it. Continue?",
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked, and one wrong number ruins a run."
},
"action": {
+17 -2
View File
@@ -196,6 +196,8 @@
"validation_title": "校验",
"points_title": "标定点列表",
"field_mode": "曲面模式",
"field_name": "序列名称",
"field_note": "备注",
"field_pitch": "点距 (mm)",
"field_margin": "边界余量 (mm)",
"field_radius": "曲面半径 (mm)",
@@ -221,8 +223,11 @@
"btn_generate": "生成标定点",
"btn_simulate": "仿真",
"btn_dry_run": "干跑(不接触)",
"btn_save": "保存序列…",
"btn_load": "载入序列…",
"btn_export": "导出序列…",
"btn_import": "导入序列…",
"import_export_tip": "序列以 JSON 文档保存(.oryseq),可跨机器复用;导入前会校验格式版本。",
"export_failed": "导出失败: {error}",
"import_failed": "导入失败: {error}",
"pending_tooltip": "该功能属于 {phase} 阶段,尚未实现。",
"col_index": "#",
"col_x": "X (mm)",
@@ -234,6 +239,16 @@
"col_actions": "操作",
"not_generated": "—",
"status_pending": "待测",
"status_skipped": "已跳过",
"summary": "{count} 点({enabled} 启用) · {spans} · 预计 {duration}(估计值)",
"summary_empty": "尚无标定点:生成或导入后显示点数与行程范围。",
"dialog_export": "导出序列",
"dialog_import": "导入序列",
"filter_sequence": "标定序列 (*.oryseq *.json)",
"exported": "已导出序列: {name}",
"imported": "已导入序列: {name}({count} 点)",
"import_warnings": "导入有 {count} 项提示",
"confirm_discard": "当前序列尚未导出,导入将替换它。是否继续?",
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。"
},
"action": {
+167
View File
@@ -0,0 +1,167 @@
# Calibration sequence format (`.oryseq`)
A calibration sequence is a small JSON document: the **profile** that generated
the points, plus the **points** themselves. It is written by
`导出序列…` in the Sequence tab and read by `导入序列…`.
Why JSON and not a binary container: a sequence is at most a few hundred points
of text. It is read by a human, diffed in review, and emailed to whoever runs the
bench. Binary would buy nothing and cost all three. (The *acquired data* of a run
is a different problem — per-point time series are large and will need a sidecar;
that decision belongs to workflow P8, not here.)
## Conventions
The format follows this repository's existing documents rather than inventing a
convention. Where it matches `session.json` and `force_calibration.json`, that is
deliberate:
| Convention | Value |
|---|---|
| Version field | `"format_version": "1.0"` — a dotted **string**, compared as a tuple |
| Identity | `"kind": "autocal.sequence"` — checked on import |
| Extension | `.oryseq` by convention; import accepts any `.json` |
| Encoding | UTF-8, `indent=2`, `ensure_ascii=False`, trailing newline |
| Write | temp file then `Path.replace` — a half-written sequence that still parses looks like a design |
## The document
```json
{
"format_version": "1.0",
"kind": "autocal.sequence",
"created_at": "2026-09-20T10:31:00+00:00",
"name": "5 点灵敏度扫描",
"note": "",
"versions": { "app": "0.1.0", "shared": "0.1.0", "shared_api": "0.1" },
"device": {
"sensor_type": "box",
"config_ref": "…\\config\\sensors\\box\\sensor.json",
"geometry_id": "finger-1.0.0",
"probe_tip_set": true
},
"frame": { "targets": "platform", "length_unit": "mm", "units_per_mm": 10.0 },
"profile": {
"mode": "flat", "pitch_mm": 2.0, "margin_mm": 1.0, "radius_mm": 0.0,
"clear_height_mm": 5.0, "approach_speed_mm_s": 2.0, "press_speed_mm_s": 0.5,
"press_depth_mm": 0.2, "dwell_s": 0.5, "settle_window_s": 0.5,
"repeats": 3, "order": "serpentine"
},
"points": [
{
"id": 1,
"target_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
"sensor_mm": { "x": 0.0, "y": 0.0, "z": 0.0 },
"clearance_mm": 5.0,
"enabled": true,
"note": ""
}
],
"summary": {
"count": 12, "enabled": 12,
"min_mm": { "x": 0.0, "y": 0.0, "z": 12.4 },
"max_mm": { "x": 10.0, "y": 4.0, "z": 12.4 },
"span_mm": { "x": 10.0, "y": 4.0, "z": 0.0 },
"pitch_mm": 2.0,
"estimated_duration_s": 380.0
}
}
```
## Fields
### `frame` — required
| Field | Meaning |
|---|---|
| `targets` | the frame `target_mm` is expressed in; today always `"platform"` |
| `length_unit` | `"mm"`; anything else is refused |
| `units_per_mm` | the pulse equivalent the points were built with |
A sequence whose numbers do not say whether they are millimetres or user units is
a sequence that will eventually be read wrong. `units_per_mm` is recorded so a
file from a differently-configured axis is detectable rather than silently
believed.
### `profile` — required
The twelve fields the Sequence tab edits, and nothing else. A field absent from
the file is **kept at its current value and reported**, never silently defaulted
to another bench's number.
### `points` — required, may be empty
| Field | Meaning |
|---|---|
| `id` | 1-based, stable within the file; used for run progress and resume |
| `target_mm` | where the axes go — required, with all three of `x`, `y`, `z` |
| `sensor_mm` | where that point lands on the sensor; optional but written |
| `clearance_mm` | the standoff for this point |
| `enabled` | false = designed but deliberately skipped |
| `note` | free text |
Both positions are stored on purpose. The platform target is what the run uses;
the sensor position is what makes the point checkable against the surface.
Deriving one from the other on every load would silently change what the points
mean the moment a sensor is re-fixtured — and a re-fixture is exactly when a
stored transform should be *noticed*, not recomputed from.
### `summary` — derived, not authoritative
Present so a human reading the file sees the point count and travel span without
counting rows, and so a diff is legible. It is **recomputed on load**; a mismatch
is reported as a warning and the recomputed values win. That is how a hand-edited
document announces itself instead of quietly disagreeing with its own contents.
`estimated_duration_s` assumes a constant traverse rate
(`sequence_io.ASSUMED_TRAVERSE_MM_S`) and ignores acceleration. It is a planning
figure and is labelled an estimate wherever it is shown.
### `device` — a reference, not a requirement
Records which sensor the sequence was designed for. Importing a sequence for
another sensor is **allowed and warned about**, because re-fixturing
deliberately is a normal thing to do.
## Import rules
Every rejection names the specific problem — `invalid file` tells an operator
nothing.
| Condition | Behaviour |
|---|---|
| not JSON | refused, with line and column |
| top level not an object | refused |
| `kind` missing or different | refused, naming what was found |
| `format_version` missing or not a string | refused |
| `format_version` newer than this build | refused, naming both versions |
| `frame` missing, or `length_unit` not `mm` | refused |
| `profile` not an object | refused |
| a profile key absent | imported; the current value is kept and the key is reported |
| `points` absent | imported as profile-only, and reported |
| a point without `target_mm.x/y/z` | refused, naming the point and the axis |
| `summary` disagreeing with `points` | imported; recomputed, and reported |
| current design is unsaved | the operator is asked before it is replaced |
Version strings are compared as tuples, never as strings: `"1.10"` is newer than
`"1.9"`, and a string comparison would make a newer file look older and load it
as if it were supported.
## Compatibility rules
- **Adding an optional key** does not change `format_version`.
- **Adding a required key, or changing a field's meaning or unit**, does.
- A reader must refuse a document whose version it does not support rather than
interpreting the parts it recognises — a sequence half-applied is worse than
one rejected.
- `1.x` readers are expected to accept `1.y` for `y <= x` on the same major
version and report anything they did not use.
## Where the pieces live
| Concern | Module |
|---|---|
| The document, validation, the derived summary | `apps/autocal/src/orisys_autocal/sequence_io.py` |
| The design screen and its controls | `apps/autocal/src/orisys_autocal/ui/sequence_panel.py` |
| The file dialogs and the unsaved-work guard | `apps/autocal/src/orisys_autocal/ui/window.py` |
| Why a sequence is an artifact (P8) | `apps/autocal/docs/workflow.md` |
@@ -0,0 +1,476 @@
"""Import and export a calibration sequence (workflow P5/P8).
A sequence is a small, human-readable document — a profile plus the points it
generated — so it can be reviewed, diffed, emailed, and reproduced on another
bench. The format follows this repository's other documents rather than
inventing a convention: a dotted ``format_version`` string, sorted sections,
``indent=2 / ensure_ascii=False`` JSON, and an atomic temp-then-replace write
(cf. ``record/experiment.py``'s ``session.json`` and ``force_calibration.json``).
The full field-by-field spec is ``apps/autocal/docs/sequence-format.md``.
Two properties matter more than the field names:
* **``summary`` is derived.** It exists so a human reading the file sees the
point count and the travel span without counting rows. It is *recomputed* on
load, and a mismatch against the file is reported — that is how a hand-edited
document announces itself instead of quietly disagreeing with its own contents.
* **The file states its frame.** A sequence whose numbers do not say whether they
are millimetres or user units is a sequence that will eventually be read wrong,
so ``frame`` records both the unit and the pulse equivalent it was built with.
Nothing here imports Qt, so the format can be exercised from a test process and
a future batch tool.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping
#: The version this build writes, and the highest it will read.
FORMAT_VERSION = "1.0"
#: What a sequence document says it is. Checked on import: a force-calibration
#: file and a sequence file are both JSON, and applying one as the other must
#: fail loudly rather than half-work.
DOCUMENT_KIND = "autocal.sequence"
#: File extension by convention. Import accepts any ``.json``.
FILE_SUFFIX = ".oryseq"
#: The frame targets are expressed in.
TARGET_FRAME = "platform"
LENGTH_UNIT = "mm"
#: Platform traverse rate assumed when estimating a run's duration, in mm/s.
#: A first-pass planning figure, not a measurement: the axes' own speed setting
#: is in user units and the real rate depends on acceleration and the path.
#: Clearly labelled as an estimate wherever it is shown.
ASSUMED_TRAVERSE_MM_S = 20.0
#: The profile keys a document must carry, matching the panel's fields.
PROFILE_KEYS: tuple[str, ...] = (
"mode",
"pitch_mm",
"margin_mm",
"radius_mm",
"clear_height_mm",
"approach_speed_mm_s",
"press_speed_mm_s",
"press_depth_mm",
"dwell_s",
"settle_window_s",
"repeats",
"order",
)
class SequenceFormatError(ValueError):
"""A sequence document could not be read.
The message is what the operator sees, so it always names the specific
problem (which key, which version, which point) rather than "invalid file".
"""
@dataclass
class SequencePoint:
"""One generated calibration point.
``target_mm`` is where the axes go; ``sensor_mm`` is where that lands on the
sensor. Both are stored: the run needs the first, and the second is what makes
the point checkable against the surface. Re-deriving one from the other on
every load would silently change what the points mean when a sensor is
re-fixtured.
"""
id: int
target_mm: dict[str, float]
sensor_mm: dict[str, float] | None = None
clearance_mm: float = 0.0
enabled: bool = True
note: str = ""
def as_dict(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"id": int(self.id),
"target_mm": {axis: float(self.target_mm.get(axis, 0.0)) for axis in "xyz"},
"clearance_mm": float(self.clearance_mm),
"enabled": bool(self.enabled),
"note": str(self.note),
}
if self.sensor_mm is not None:
payload["sensor_mm"] = {axis: float(self.sensor_mm.get(axis, 0.0)) for axis in "xyz"}
return payload
@dataclass
class SequenceDocument:
"""A sequence as it travels between machines."""
profile: dict[str, Any] = field(default_factory=dict)
points: list[SequencePoint] = field(default_factory=list)
name: str = ""
note: str = ""
device: dict[str, Any] = field(default_factory=dict)
units_per_mm: float = 0.0
created_at: str = ""
summary: dict[str, Any] = field(default_factory=dict)
#: Problems found while reading that did not stop the import. Shown to the
#: operator: "it loaded" and "it loaded as written" are different claims.
warnings: list[str] = field(default_factory=list)
@property
def point_count(self) -> int:
return len(self.points)
@property
def enabled_count(self) -> int:
return sum(1 for point in self.points if point.enabled)
# --------------------------------------------------------------------- derive
def summarise(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> dict[str, Any]:
"""The derived block: count, travel span, and an estimated duration.
Recomputed everywhere rather than trusted from a file, so a document cannot
claim a range its points do not have.
"""
ordered = list(points)
span: dict[str, float] = {}
minimum: dict[str, float] = {}
maximum: dict[str, float] = {}
for axis in "xyz":
values = [float(point.target_mm.get(axis, 0.0)) for point in ordered]
if values:
minimum[axis] = min(values)
maximum[axis] = max(values)
span[axis] = maximum[axis] - minimum[axis]
return {
"count": len(ordered),
"enabled": sum(1 for point in ordered if point.enabled),
"min_mm": minimum,
"max_mm": maximum,
"span_mm": span,
"pitch_mm": _number(profile.get("pitch_mm"), 0.0),
"estimated_duration_s": estimate_duration(ordered, profile),
}
def estimate_duration(
points: Iterable[SequencePoint], profile: Mapping[str, Any]
) -> float:
"""A planning estimate for one full run, in seconds.
Per point: the descent and press, the dwell, and the settle window, repeated
``repeats`` times; plus a traverse between points at
:data:`ASSUMED_TRAVERSE_MM_S`. An estimate, labelled as one wherever it is
shown — it assumes a constant traverse rate and ignores acceleration.
"""
ordered = list(points)
if not ordered:
return 0.0
repeats = max(1, int(_number(profile.get("repeats"), 1)))
clear_height = max(0.0, _number(profile.get("clear_height_mm"), 0.0))
approach = max(1e-6, _number(profile.get("approach_speed_mm_s"), 1.0))
press_speed = max(1e-6, _number(profile.get("press_speed_mm_s"), 1.0))
press_depth = max(0.0, _number(profile.get("press_depth_mm"), 0.0))
dwell = max(0.0, _number(profile.get("dwell_s"), 0.0))
settle = max(0.0, _number(profile.get("settle_window_s"), 0.0))
descent = clear_height / approach
press = press_depth / press_speed
per_point = (descent + press + dwell + settle) * repeats
total = per_point * len(ordered)
pitch = max(0.0, _number(profile.get("pitch_mm"), 0.0))
if pitch > 0:
total += (len(ordered) - 1) * (pitch / ASSUMED_TRAVERSE_MM_S) * repeats
return float(total)
def format_span(low: float | None, high: float | None, *, places: int = 3) -> str:
"""``min–max (span)``, or a single number when the travel is nil.
A zero span means every point shares that coordinate; printing
``12.400–12.400 (0.000)`` would be noise in a line whose job is to be read at
a glance.
"""
if low is None or high is None:
return "—"
if abs(high - low) < 10 ** (-places) / 2:
return f"{low:.{places}f}"
return f"{low:.{places}f}–{high:.{places}f} ({high - low:.{places}f})"
def format_duration(seconds: float | None) -> str:
"""``m:ss``, or a dash when no estimate can be made."""
if seconds is None or not math.isfinite(float(seconds)) or seconds <= 0:
return "—"
total = int(round(float(seconds)))
minutes, rest = divmod(total, 60)
if minutes >= 60:
hours, minutes = divmod(minutes, 60)
return f"{hours:d}:{minutes:02d}:{rest:02d}"
return f"{minutes:d}:{rest:02d}"
# ----------------------------------------------------------------- serialize
def to_document(
*,
profile: Mapping[str, Any],
points: Iterable[SequencePoint] = (),
name: str = "",
note: str = "",
device: Mapping[str, Any] | None = None,
units_per_mm: float = 0.0,
app_version: str = "",
shared_version: str = "",
shared_api: str = "",
) -> dict[str, Any]:
"""Build the sequence document."""
ordered = list(points)
return {
"format_version": FORMAT_VERSION,
"kind": DOCUMENT_KIND,
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"name": str(name),
"note": str(note),
"versions": {
"app": str(app_version),
"shared": str(shared_version),
"shared_api": str(shared_api),
},
"device": dict(device or {}),
"frame": {
"targets": TARGET_FRAME,
"length_unit": LENGTH_UNIT,
"units_per_mm": float(units_per_mm),
},
"profile": {
key: profile.get(key)
for key in PROFILE_KEYS
},
"points": [point.as_dict() for point in ordered],
"summary": summarise(ordered, profile),
}
def save(document: Mapping[str, Any], path: str | Path) -> Path:
"""Write ``document`` atomically, as UTF-8 JSON.
Temp-then-replace: a half-written sequence file that still parses is worse
than no file, because it looks like a design.
"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(target.suffix + ".tmp")
temporary.write_text(
json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
temporary.replace(target)
return target
# ------------------------------------------------------------------- parse
def load(path: str | Path, *, current_profile: Mapping[str, Any] | None = None) -> SequenceDocument:
"""Read a sequence document, validating it.
Args:
path: the file to read.
current_profile: the profile in force now. Missing profile keys are
taken from it and reported, rather than silently defaulting to
another bench's numbers.
Raises:
SequenceFormatError: with a message naming the specific problem.
"""
target = Path(path)
try:
text = target.read_text(encoding="utf-8")
except OSError as exc:
raise SequenceFormatError(f"cannot read {target.name}: {exc}") from exc
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise SequenceFormatError(
f"{target.name} is not valid JSON: line {exc.lineno}, column {exc.colno}"
) from exc
if not isinstance(data, dict):
raise SequenceFormatError(
f"{target.name}: the top level must be an object, found {type(data).__name__}"
)
return from_document(data, current_profile=current_profile, source=target.name)
def from_document(
data: Mapping[str, Any],
*,
current_profile: Mapping[str, Any] | None = None,
source: str = "document",
) -> SequenceDocument:
"""Validate and interpret an already-parsed sequence document."""
warnings: list[str] = []
kind = data.get("kind")
if kind != DOCUMENT_KIND:
raise SequenceFormatError(
f"{source}: not a sequence document "
f"(kind is {kind!r}, expected {DOCUMENT_KIND!r})"
)
raw_version = data.get("format_version")
if not isinstance(raw_version, str) or not raw_version.strip():
raise SequenceFormatError(f"{source}: missing or invalid format_version")
if _version_tuple(raw_version) > _version_tuple(FORMAT_VERSION):
raise SequenceFormatError(
f"{source}: written by a newer AutoCal "
f"(format {raw_version}, this build reads up to {FORMAT_VERSION})"
)
frame = data.get("frame")
if not isinstance(frame, dict):
raise SequenceFormatError(f"{source}: missing frame block")
if str(frame.get("length_unit") or "") != LENGTH_UNIT:
raise SequenceFormatError(
f"{source}: length_unit is {frame.get('length_unit')!r}, expected {LENGTH_UNIT!r}"
)
profile_in = data.get("profile")
if not isinstance(profile_in, dict):
raise SequenceFormatError(f"{source}: missing or invalid profile block")
profile: dict[str, Any] = dict(current_profile or {})
missing: list[str] = []
for key in PROFILE_KEYS:
if key in profile_in:
profile[key] = profile_in[key]
else:
missing.append(key)
if missing:
warnings.append(
"profile keys absent, kept the current value: " + ", ".join(sorted(missing))
)
points = _parse_points(data.get("points"), source=source, warnings=warnings)
stored_summary = data.get("summary")
recomputed = summarise(points, profile)
if isinstance(stored_summary, dict) and points:
differences = _summary_differences(stored_summary, recomputed)
if differences:
warnings.append(
"the stored summary disagrees with the points and was recomputed: "
+ "; ".join(differences)
)
device = data.get("device") if isinstance(data.get("device"), dict) else {}
return SequenceDocument(
profile=profile,
points=points,
name=str(data.get("name") or ""),
note=str(data.get("note") or ""),
device=dict(device),
units_per_mm=_number(frame.get("units_per_mm"), 0.0),
created_at=str(data.get("created_at") or ""),
summary=recomputed,
warnings=warnings,
)
def _parse_points(
raw: Any, *, source: str, warnings: list[str]
) -> list[SequencePoint]:
if raw is None:
warnings.append("no points in the file; only the profile was imported")
return []
if not isinstance(raw, list):
raise SequenceFormatError(
f"{source}: points must be a list, found {type(raw).__name__}"
)
points: list[SequencePoint] = []
for index, entry in enumerate(raw, start=1):
where = f"{source}: point {index}"
if not isinstance(entry, dict):
raise SequenceFormatError(f"{where} is not an object")
target = entry.get("target_mm")
if not isinstance(target, dict):
raise SequenceFormatError(f"{where} has no target_mm object")
for axis in "xyz":
if axis not in target:
raise SequenceFormatError(f"{where}: target_mm is missing '{axis}'")
sensor = entry.get("sensor_mm")
points.append(
SequencePoint(
id=int(_number(entry.get("id"), index)),
target_mm={axis: _number(target.get(axis), 0.0) for axis in "xyz"},
sensor_mm=(
{axis: _number(sensor.get(axis), 0.0) for axis in "xyz"}
if isinstance(sensor, dict)
else None
),
clearance_mm=_number(entry.get("clearance_mm"), 0.0),
enabled=bool(entry.get("enabled", True)),
note=str(entry.get("note") or ""),
)
)
return points
def _summary_differences(stored: Mapping[str, Any], recomputed: Mapping[str, Any]) -> list[str]:
"""Where the file's own summary disagrees with its points."""
differences: list[str] = []
if int(_number(stored.get("count"), -1)) != recomputed["count"]:
differences.append(
f"count {stored.get('count')} != {recomputed['count']}"
)
stored_span = stored.get("span_mm")
if isinstance(stored_span, dict):
for axis in "xyz":
if axis in recomputed["span_mm"]:
if not _close(_number(stored_span.get(axis), 0.0), recomputed["span_mm"][axis]):
differences.append(
f"span {axis} {stored_span.get(axis)} != {recomputed['span_mm'][axis]:.3f}"
)
return differences
def _close(first: float, second: float, tolerance: float = 1e-6) -> bool:
return abs(float(first) - float(second)) <= tolerance
def _number(value: Any, fallback: float) -> float:
"""A finite float, or the fallback. Never NaN, never a string."""
try:
number = float(value)
except (TypeError, ValueError):
return float(fallback)
if not math.isfinite(number):
return float(fallback)
return number
def _version_tuple(text: str) -> tuple[int, ...]:
"""``"1.10"`` as ``(1, 10)``.
Compared as tuples rather than strings: ``"1.10" < "1.9"`` is a real trap that
would make a newer file look older and load it as if it were supported.
"""
parts: list[int] = []
for chunk in str(text).split("."):
digits = "".join(character for character in chunk if character.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts)
@@ -47,6 +47,12 @@ from PySide6.QtWidgets import (
from orisys_app_shared.i18n import t
from ..preferences import POINT_ORDERS, SURFACE_MODES
from ..sequence_io import (
SequencePoint,
format_duration,
format_span,
summarise,
)
#: Profile fields, in form order: ``(key, locale_key, kind)``.
#: ``kind`` is ``choice`` (a combo box) or ``number`` (a line edit).
@@ -69,6 +75,10 @@ PROFILE_FIELDS: tuple[tuple[str, str, str], ...] = (
PROFILE_ROWS = (len(PROFILE_FIELDS) + 1) // 2
#: Point-table columns: ``(key, locale_key)``.
#:
#: No actions column. It was rendered empty on every row, and an empty action
#: cell reads as a broken control — the per-point actions (re-measure, skip)
#: belong to the run, not to the design screen.
POINT_COLUMNS: tuple[tuple[str, str], ...] = (
("index", "sequence.col_index"),
("x", "sequence.col_x"),
@@ -77,7 +87,6 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
("status", "sequence.col_status"),
("sensitivity", "sequence.col_sensitivity"),
("samples", "sequence.col_samples"),
("actions", "sequence.col_actions"),
)
#: Editability of the point table. Positions are *generated*, not typed: a typed
@@ -85,20 +94,22 @@ POINT_COLUMNS: tuple[tuple[str, str], ...] = (
_EDITABLE_POINT_COLUMNS: tuple[str, ...] = ()
#: Column widths of the point table, by column index.
_POINT_COLUMN_WIDTHS = (50, 130, 130, 130, 120, 160, 90, 200)
_POINT_COLUMN_WIDTHS = (60, 150, 150, 150, 140, 190, 110)
#: Reserved controls and the phase that will fill each one.
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run", "save", "load")
PENDING_CONTROLS: tuple[str, ...] = ("generate", "simulate", "dry_run")
#: Which phase each pending control belongs to (see docs/workflow.md).
PENDING_PHASES: dict[str, str] = {
"generate": "P5",
"simulate": "P6",
"dry_run": "P6",
"save": "P8",
"load": "P8",
}
#: Controls that work now: a sequence is a document, and P8 says it is a
#: versioned artifact. Import and export are the first half of that.
LIVE_CONTROLS: tuple[str, ...] = ("export", "import")
#: The validation checks, with the locale key naming what each one proves.
VALIDATION_CHECKS: tuple[tuple[str, str], ...] = (
("on_surface", "sequence.check_surface"),
@@ -140,8 +151,8 @@ class SequencePanel(QGroupBox):
generateRequested = Signal()
simulateRequested = Signal()
dryRunRequested = Signal()
saveRequested = Signal()
loadRequested = Signal()
exportRequested = Signal()
importRequested = Signal()
def __init__(self, profile: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
super().__init__(parent)
@@ -151,6 +162,8 @@ class SequencePanel(QGroupBox):
self._choices: dict[str, QComboBox] = {}
self._pending_buttons: dict[str, QPushButton] = {}
self._check_rows: dict[str, QLabel] = {}
self._summary: dict[str, Any] = {}
self._dirty = False
outer = QVBoxLayout(self)
outer.setContentsMargins(10, 8, 10, 10)
@@ -169,16 +182,48 @@ class SequencePanel(QGroupBox):
self.set_profile(profile or {})
self.retranslate()
# A profile edit changes the estimate, so the line follows the form.
for widget in self._profile_fields.values():
if isinstance(widget, QComboBox):
widget.currentIndexChanged.connect(self._mark_dirty)
else:
widget.textChanged.connect(self._mark_dirty)
self.refresh_summary()
# ------------------------------------------------------------------ profile
def _build_profile(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
grid = QGridLayout(box)
grid.setContentsMargins(10, 8, 10, 8)
outer = QVBoxLayout(box)
outer.setContentsMargins(10, 8, 10, 8)
outer.setSpacing(4)
# The name is what the document is called on disk and in the export, so
# it lives at the top of the block that defines the sequence rather than
# in a dialog.
name_row = QHBoxLayout()
name_row.setSpacing(8)
self._name_label = QLabel(box)
self._name_label.setObjectName("hintLabel")
self.name_edit = QLineEdit(box)
self.name_edit.setFixedHeight(28)
name_row.addWidget(self._name_label)
name_row.addWidget(self.name_edit, 1)
outer.addLayout(name_row)
self._note_label = QLabel(box)
self._note_label.setObjectName("hintLabel")
self.note_edit = QLineEdit(box)
self.note_edit.setFixedHeight(28)
outer.addWidget(self._note_label)
outer.addWidget(self.note_edit)
grid = QGridLayout()
grid.setContentsMargins(0, 0, 0, 0)
grid.setHorizontalSpacing(10)
grid.setVerticalSpacing(4)
outer.addLayout(grid)
# Two fields per row: label, field, label, field. Seeing the whole profile
# at once is what lets an operator sanity-check a pitch against a depth.
@@ -258,13 +303,21 @@ class SequencePanel(QGroupBox):
column.addWidget(button)
self._pending_buttons[key] = button
# Import/export work today: a sequence is a portable document (P8).
self._live_buttons: dict[str, QPushButton] = {}
for key in LIVE_CONTROLS:
button = QPushButton(box)
button.setProperty("rail", True)
button.setFixedHeight(28)
column.addWidget(button)
self._live_buttons[key] = button
self._pending_buttons["generate"].clicked.connect(self.generateRequested)
self._pending_buttons["simulate"].clicked.connect(self.simulateRequested)
self._pending_buttons["dry_run"].clicked.connect(self.dryRunRequested)
self._pending_buttons["save"].clicked.connect(self.saveRequested)
self._pending_buttons["load"].clicked.connect(self.loadRequested)
# None of these can run: generation, simulation, the runner and the
# dataset writer belong to later phases (see docs/workflow.md).
self._live_buttons["export"].clicked.connect(self.exportRequested)
self._live_buttons["import"].clicked.connect(self.importRequested)
# Generation, simulation and the runner belong to later phases (P5-P7).
for button in self._pending_buttons.values():
button.setEnabled(False)
@@ -273,6 +326,10 @@ class SequencePanel(QGroupBox):
# -------------------------------------------------------------- point table
def _mark_dirty(self, *_args: Any) -> None:
self._dirty = True
self.refresh_summary()
def _build_point_table(self) -> QWidget:
box = QGroupBox(self)
box.setObjectName("groupBox")
@@ -303,7 +360,6 @@ class SequencePanel(QGroupBox):
4: t("sequence.status_pending"),
5: "—",
6: "—",
7: "",
}
for column_index, text in values.items():
item = QTableWidgetItem(text)
@@ -311,7 +367,15 @@ class SequencePanel(QGroupBox):
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column_index, item)
# The count and span line: the two numbers an operator reasons with, and
# the only place a wrong pitch or margin shows up before a seven-minute
# run does it for them.
self.summary_label = QLabel(box)
self.summary_label.setObjectName("statusValue")
self.summary_label.setWordWrap(True)
column.addWidget(self.table, 1)
column.addWidget(self.summary_label)
self.point_hint = QLabel(box)
self.point_hint.setObjectName("hintLabel")
@@ -400,7 +464,6 @@ class SequencePanel(QGroupBox):
4: status or t("sequence.status_pending"),
5: "—",
6: "—",
7: "",
}
for column, text in values.items():
item = QTableWidgetItem(text)
@@ -409,6 +472,77 @@ class SequencePanel(QGroupBox):
self.table.setItem(row, column, item)
return row
# ----------------------------------------------------------- points & state
def points(self) -> list[SequencePoint]:
"""The list as it stands, for export and for a future runner."""
points: list[SequencePoint] = []
for row in range(self.table.rowCount()):
cells = self.point_row(row)
try:
target = {axis: float(cells[axis]) for axis in "xyz"}
except (TypeError, ValueError):
continue # a placeholder row that was never generated
points.append(
SequencePoint(id=row + 1, target_mm=target, enabled=cells["status"] != t("sequence.status_skipped"))
)
return points
def set_points(self, points: list[SequencePoint]) -> None:
"""Replace the list. Used by import and by the future generator."""
self.table.setRowCount(0)
for point in points:
row = self.table.rowCount()
self.table.insertRow(row)
values = {
0: str(point.id),
1: f"{point.target_mm.get('x', 0.0):.3f}",
2: f"{point.target_mm.get('y', 0.0):.3f}",
3: f"{point.target_mm.get('z', 0.0):.3f}",
4: t("sequence.status_pending") if point.enabled else t("sequence.status_skipped"),
5: "—",
6: "—",
}
for column, text in values.items():
item = QTableWidgetItem(text)
if column >= 4:
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.setItem(row, column, item)
self._dirty = True
self.refresh_summary()
@property
def dirty(self) -> bool:
"""Whether the design has changed since it was last saved or loaded.
Import replaces the design outright, and there is no autosave; discarding
a designed sequence without asking would be worse than one extra prompt.
"""
return self._dirty
def mark_clean(self) -> None:
self._dirty = False
def name(self) -> str:
return self.name_edit.text().strip()
def set_name(self, value: str) -> None:
self.name_edit.setText(str(value or ""))
def note(self) -> str:
return self.note_edit.text().strip()
def set_note(self, value: str) -> None:
self.note_edit.setText(str(value or ""))
def refresh_summary(self) -> None:
"""Recompute the count, span and duration line from what is on screen."""
profile = self.profile()
points = self.points()
summary = summarise(points, profile)
self._summary = summary
self.summary_label.setText(_summary_text(summary, profile))
def planned_steps(self) -> list[SequenceStep]:
"""The steps the table describes, for the future runner.
@@ -432,6 +566,10 @@ class SequencePanel(QGroupBox):
for key, combo in self._choices.items():
_fill_choice(combo, key)
self._name_label.setText(t("sequence.field_name"))
self.name_edit.setPlaceholderText(t("sequence.name_hint"))
self._note_label.setText(t("sequence.field_note"))
self.preview_label.setText(t("sequence.preview_placeholder"))
self.preview_hint.setText(t("sequence.preview_hint"))
for key, _locale_key in VALIDATION_CHECKS:
@@ -442,6 +580,9 @@ class SequencePanel(QGroupBox):
self._pending_buttons[key].setToolTip(
t("sequence.pending_tooltip", phase=PENDING_PHASES[key])
)
for key in LIVE_CONTROLS:
self._live_buttons[key].setText(t(f"sequence.btn_{key}"))
self._live_buttons[key].setToolTip(t("sequence.import_export_tip"))
self.table.setHorizontalHeaderLabels([t(locale_key) for _key, locale_key in POINT_COLUMNS])
for row in range(self.table.rowCount()):
@@ -449,6 +590,33 @@ class SequencePanel(QGroupBox):
if item is not None and item.text() in ("", t("sequence.status_pending")):
item.setText(t("sequence.status_pending"))
self.point_hint.setText(t("sequence.points_hint"))
self.refresh_summary()
def _summary_text(summary: dict[str, Any], profile: dict[str, Any]) -> str:
"""The count / span / duration line under the point list.
Spans are ``min–max (span)`` with a nil span printed once; the duration is
labelled an estimate because it is one.
"""
count = int(summary.get("count") or 0)
enabled = int(summary.get("enabled") or count)
if not count:
return t("sequence.summary_empty")
spans = " · ".join(
f"{axis.upper()} {format_span(summary['min_mm'].get(axis), summary['max_mm'].get(axis))}"
for axis in "xyz"
if axis in summary.get("min_mm", {})
)
duration = format_duration(summary.get("estimated_duration_s"))
return t(
"sequence.summary",
count=count,
enabled=enabled,
spans=spans,
duration=duration,
)
def _fill_choice(combo: QComboBox, key: str) -> None:
@@ -314,6 +314,11 @@ class RunBar(QFrame):
self.step_label.setMinimumWidth(140)
layout.addWidget(self.step_label)
# Shown only while idle, in place of the step/progress/timing trio.
self._idle_hint = QLabel(self)
self._idle_hint.setObjectName("hintLabel")
layout.addWidget(self._idle_hint)
self.progress = QProgressBar(self)
self.progress.setTextVisible(False)
self.progress.setFixedHeight(12)
@@ -395,6 +400,14 @@ class RunBar(QFrame):
self.abort_button.setEnabled(self._has_run)
self.pause_button.setEnabled(self._has_run and busy)
self.run_button.setEnabled(not busy)
# With no run, "point 0/0" and "elapsed 0:00" are noise on a strip whose
# job is to be read at a glance. Idle shows the phase and why instead.
live = self._has_run or busy
self.step_label.setVisible(live)
self.progress.setVisible(live)
self.time_label.setVisible(live)
if not live:
self._idle_hint.setText(t("run.no_steps"))
@property
def phase(self) -> str:
@@ -415,6 +428,7 @@ class RunBar(QFrame):
else:
self.step_label.setText(t("run.no_steps"))
self.time_label.setText(t("run.elapsed_only", elapsed=_duration(0.0)))
self._refresh_buttons(self._phase in ("running", "paused"))
def _duration(seconds: float) -> str:
@@ -38,6 +38,14 @@ from PySide6.QtWidgets import (
from .. import __version__ as app_version
from ..motion import AxisParams, ConnectionSpec, MotionPoller, MotionService, Snapshot
from ..preferences import load_preferences, save_preferences
from ..sequence_io import (
FILE_SUFFIX,
SequenceDocument,
SequenceFormatError,
load,
save,
to_document,
)
from .axis_panel import AxisPanel
from .axis_readout import AxisReadoutPanel
from .brand_bar import BrandBar
@@ -242,6 +250,8 @@ class AutoCalMainWindow(QMainWindow):
self.run_bar.runRequested.connect(self._on_run_requested)
self.run_bar.pauseRequested.connect(self._on_run_pause)
self.run_bar.abortRequested.connect(self._on_run_abort)
self.sequence_panel.exportRequested.connect(self._on_export_sequence)
self.sequence_panel.importRequested.connect(self._on_import_sequence)
self.axis_readout.stopAllRequested.connect(self._on_stop_all)
self.axis_readout.clearAllRequested.connect(self._on_clear_all_alarms)
self.axis_readout.enableAllRequested.connect(self._on_enable_all)
@@ -272,6 +282,106 @@ class AutoCalMainWindow(QMainWindow):
self.statusBar().showMessage(text)
self.status_strip.set_action_result(text, ok)
# ------------------------------------------------------- sequence import/export
def _default_sequence_path(self) -> str:
"""Start the file dialog somewhere useful and named after the profile."""
stem = self.sequence_panel.name() or "sequence"
return str(Path(outputs_dir()) / "sequences" / f"{stem}{FILE_SUFFIX}")
def _sequence_device_ref(self) -> dict:
setup = self.prefs.get("sensor_setup") or {}
return {
"sensor_type": str(setup.get("sensor_type") or ""),
"config_ref": str(setup.get("config_ref") or ""),
"geometry_id": str(setup.get("geometry_id") or ""),
"probe_tip_set": bool(setup.get("probe_tip_set")),
}
def export_sequence(self, path: str) -> str:
"""Write the current design. Returns the path written."""
document = to_document(
profile=self.sequence_panel.profile(),
points=self.sequence_panel.points(),
name=self.sequence_panel.name(),
note=self.sequence_panel.note(),
device=self._sequence_device_ref(),
units_per_mm=float(self.prefs.get("units_per_mm") or 0.0),
app_version=app_version,
shared_version=shared_version,
shared_api=API_VERSION,
)
written = save(document, path)
self.sequence_panel.mark_clean()
return str(written)
def import_sequence(self, path: str) -> SequenceDocument:
"""Read a design and apply it. Raises SequenceFormatError on a bad file."""
document = load(path, current_profile=self.sequence_panel.profile())
self.sequence_panel.set_profile(document.profile)
self.sequence_panel.set_points(document.points)
self.sequence_panel.set_name(document.name)
self.sequence_panel.set_note(document.note)
self.sequence_panel.mark_clean()
return document
def _on_export_sequence(self) -> None:
path, _selected = QFileDialog.getSaveFileName(
self,
t("sequence.dialog_export"),
self._default_sequence_path(),
t("sequence.filter_sequence"),
)
if not path:
return
try:
written = self.export_sequence(path)
except (OSError, ValueError) as exc:
self._report_sequence(t("sequence.export_failed", error=str(exc)), ok=False)
return
self._report_sequence(
t("sequence.exported", name=Path(written).name), ok=True
)
def _on_import_sequence(self) -> None:
# Import replaces the design and there is no autosave, so an unsaved
# sequence is confirmed before it is discarded.
if self.sequence_panel.dirty:
answer = QMessageBox.question(
self,
t("sequence.dialog_import"),
t("sequence.confirm_discard"),
)
if answer != QMessageBox.StandardButton.Yes:
return
path, _selected = QFileDialog.getOpenFileName(
self,
t("sequence.dialog_import"),
str(Path(outputs_dir()) / "sequences"),
t("sequence.filter_sequence"),
)
if not path:
return
try:
document = self.import_sequence(path)
except SequenceFormatError as exc:
self._report_sequence(t("sequence.import_failed", error=str(exc)), ok=False)
return
self._report_sequence(
t("sequence.imported", name=Path(path).name, count=document.point_count), ok=True
)
if document.warnings:
self.statusBar().showMessage(
t("sequence.import_warnings", count=len(document.warnings))
)
for warning in document.warnings:
logger.warning("Sequence import: %s", warning)
def _report_sequence(self, text: str, *, ok: bool) -> None:
self.statusBar().showMessage(text)
self.status_strip.set_action_result(text, ok)
def _apply_chrome_colours(self) -> None:
"""Style the status bar from the theme rather than Qt's default.
+15
View File
@@ -66,6 +66,21 @@ def _scratch_dir() -> Path:
return path
@pytest.fixture
def tmp_dir():
"""A workspace-local scratch directory, cleared per test.
Deliberately not ``tmp_path``: this suite has to run inside a sandboxed
shell, and ``tmp_path`` depends on a writable process temp root that such an
environment may not provide.
"""
import shutil
path = _scratch_dir()
yield path
shutil.rmtree(path, ignore_errors=True)
@pytest.fixture
def make_window(qt_app, monkeypatch):
"""Build a window whose preference writes stay inside a scratch directory.
+47 -16
View File
@@ -1,18 +1,23 @@
"""Binding policy for AutoCal.
"""Binding policy for the Orisys apps.
AutoCal is **PySide6 only**, and this file is the guard for that:
Both shipped apps are **PySide6 only**, and this file is AutoCal's guard for that:
* no module may import PyQt5, PySide2 or PyQt6 — the app's environment has
PySide6 and nothing else, so such an import is a hard start-up failure;
* no module may use an unscoped Qt enumerator (``Qt.AlignLeft``), which PySide6
removed in favour of ``Qt.AlignmentFlag.AlignLeft``;
* ``shared/`` — which serves both this app and the PyQt5-based studio — must
import no Qt binding at all;
* ``shared/`` may import PySide6 and nothing else: it is used by both apps, so it
must not pull in a second binding, and the studio's own guard
(``apps/studio/tests/test_binding_purity.py``) enforces the same rule from its
side. (Until the studio moved to PySide6 this file forbade *any* binding in
``shared/``; that rule existed for a two-binding workspace, which no longer
exists. ``shared/theme/qss.py`` paints its chevron icons with PySide6 now, and
under the old rule those imports were failing silently instead.)
* the controller layer must stay importable from a headless process.
The reason the binding matters at all is licensing, not taste: PyQt5's free
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so an internal
closed-source tool needs no licence purchase. See ``README.md``.
option is GPLv3-or-commercial, while PySide6 is LGPLv3, so a closed-source tool
needs no licence purchase. See ``README.md``.
"""
from __future__ import annotations
@@ -23,13 +28,19 @@ from pathlib import Path
APP_SRC = Path(__file__).resolve().parents[1] / "src"
SHARED_SRC = Path(__file__).resolve().parents[3] / "shared" / "src"
#: Bindings the app must never import.
#: Bindings no module may import.
FORBIDDEN_BINDINGS = {"PyQt5", "PyQt6", "PySide2"}
#: The one binding allowed, and where it may be named.
ALLOWED_BINDING = "PySide6"
#: Scoped enum holders we care about, with the value names actually used.
#:
#: Hand-maintained, and therefore incomplete by construction: the PySide6 migration
#: found ``QPainter.RenderHint.Antialiasing`` and ``QDialogButtonBox.StandardButton.Ok`` slipping through a list
#: like this one. The studio's guard asks PySide6 instead (``getattr`` + enum check),
#: which is the better rule; this map is kept because AutoCal cannot import the binding
#: in a headless test run.
_SCOPED_ENUMS = {
"Qt": (
"AlignmentFlag",
@@ -41,6 +52,12 @@ _SCOPED_ENUMS = {
"ToolButtonStyle",
"FocusPolicy",
"ScrollBarPolicy",
"CursorShape",
"MouseButton",
"Key",
"PenStyle",
"GlobalColor",
"ItemFlag",
),
"QTableWidget": ("SelectionMode", "EditTrigger"),
"QSizePolicy": ("Policy",),
@@ -48,6 +65,11 @@ _SCOPED_ENUMS = {
"QMessageBox": ("StandardButton", "Icon"),
"QDialog": ("DialogCode",),
"QAbstractItemView": ("SelectionMode", "EditTrigger"),
"QDialogButtonBox": ("StandardButton",),
"QPainter": ("RenderHint", "CompositionMode"),
"QImage": ("Format",),
"QSurfaceFormat": ("RenderableType", "OpenGLContextProfile", "SwapBehavior"),
"QFrame": ("Shape",),
}
@@ -72,11 +94,13 @@ def _unscoped_enum_references(path: Path) -> list[str]:
"""Attribute reads like ``Qt.AlignLeft`` — the spelling PySide6 rejects.
Parsed rather than pattern-matched for two reasons: a regex also hits prose
in a comment or docstring (which is exactly where the scoped spelling gets
*explained*), and ``QMessageBox.information(...)`` looks like an enumerator
but is a static method. The parent map separates the two, and
``Qt.AlignmentFlag.AlignLeft`` is an inner attribute on an allow-listed enum
class rather than a plain name, so it is never a candidate.
in a comment or docstring (which is exactly where the *unscoped* spelling gets
explained, so a rewrite would destroy the explanation — this file quotes it
three times, which is why the migration scripts skip it), and
``QMessageBox.information(...)`` looks like an enumerator but is a static
method. The parent map separates the two, and ``Qt.AlignmentFlag.AlignLeft`` is
an inner attribute on an allow-listed enum class rather than a plain name, so it
is never a candidate.
"""
tree = ast.parse(path.read_text(encoding="utf-8"))
called = {
@@ -152,15 +176,22 @@ def test_the_controller_interface_is_qt_free():
assert not offenders, offenders
def test_shared_imports_no_qt_binding():
"""Shared code serves both products, so it must not pick a side."""
def test_shared_imports_no_forbidden_binding():
"""Shared code serves both products, so it must not pull in a second binding.
PySide6 is allowed and expected (``shared/theme/qss.py`` paints the chevron icons with
it); what must never appear here is PyQt5/PyQt6/PySide2, which would either fail to
import in a PySide6-only environment or — worse — work in one app's environment and not
the other's. Before the studio moved to PySide6 this test forbade PySide6 itself, and
the PyQt5 import in ``qss.py`` was failing silently as a result.
"""
offenders = []
for path in _python_files(SHARED_SRC):
qt_imports = _imported_packages(path) & {"PySide2", "PySide6"}
qt_imports = _imported_packages(path) & FORBIDDEN_BINDINGS
if qt_imports:
offenders.append(f"{path.name}: {sorted(qt_imports)}")
assert not offenders, (
"shared code must not import PySide2/PySide6 (studio is PyQt5): "
"shared code may import PySide6 only; these files import another binding: "
f"{offenders}"
)
+124 -7
View File
@@ -17,6 +17,7 @@ import pytest
from conftest import pump
from orisys_autocal.sequence_io import SequenceFormatError, SequencePoint
from orisys_autocal.ui.workspace import TABS
@@ -187,13 +188,7 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
"""A disabled control must say why, or it reads as a broken button."""
window = _show(make_window())
panel = window.sequence_panel
assert panel.pending_controls() == [
"generate",
"simulate",
"dry_run",
"save",
"load",
]
assert panel.pending_controls() == ["generate", "simulate", "dry_run"]
for name, phase in panel.pending_phases().items():
button = panel._pending_buttons[name]
assert not button.isEnabled(), f"{name} is usable but not implemented"
@@ -201,6 +196,24 @@ def test_every_pending_control_names_the_phase_that_owns_it(make_window):
assert button.text(), f"{name} has no label"
def test_import_and_export_are_live_controls(make_window):
"""A sequence is a document, and P8 says it is a versioned artifact."""
window = _show(make_window())
panel = window.sequence_panel
for name in ("export", "import"):
button = panel._live_buttons[name]
assert button.isEnabled(), f"{name} is not usable"
assert button.text(), f"{name} has no label"
assert button.toolTip()
def test_the_point_list_has_no_empty_action_column(make_window):
"""An empty action cell reads as a broken control."""
window = _show(make_window())
panel = window.sequence_panel
assert "actions" not in panel.column_keys()
def test_the_sequence_tab_hands_out_no_runnable_steps(make_window):
"""A runner must not be able to start from points nothing has validated."""
window = _show(make_window())
@@ -456,6 +469,110 @@ def test_the_status_bar_uses_its_own_theme_token(make_window):
assert "QStatusBar" in window.statusBar().styleSheet()
def test_the_sequence_summary_reports_count_and_span(make_window):
"""The two numbers an operator reasons with, before anything runs."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 10.0, "y": 0.0, "z": 12.4}),
]
)
text = panel.summary_label.text()
assert "2" in text
# A nil Z span prints once, and the real X span prints both ends.
assert "0.000–10.000" in text
assert "12.400" in text
def test_the_sequence_summary_updates_with_the_profile(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 1.0})])
before = panel.summary_label.text()
panel.field("repeats").setText("10")
assert panel.summary_label.text() != before
def test_an_empty_sequence_says_so_rather_than_showing_zeros(make_window):
window = _show(make_window())
panel = window.sequence_panel
panel.set_points([])
assert panel.summary_label.text()
assert "0" not in panel.summary_label.text()[:2]
def test_the_design_is_dirty_until_it_is_saved_or_loaded(make_window):
"""Import replaces the design, and there is no autosave."""
window = _show(make_window())
panel = window.sequence_panel
panel.mark_clean()
assert not panel.dirty
panel.field("pitch_mm").setText("9")
assert panel.dirty, "a profile edit did not mark the design dirty"
def test_export_then_import_restores_the_design(make_window, tmp_dir):
"""The round trip through the real panel, not just the format module."""
window = _show(make_window())
panel = window.sequence_panel
panel.set_name("bench sweep")
panel.field("pitch_mm").setText("3.5")
panel.set_points(
[
SequencePoint(id=1, target_mm={"x": 0.0, "y": 0.0, "z": 12.4}),
SequencePoint(id=2, target_mm={"x": 3.5, "y": 0.0, "z": 12.4}),
]
)
path = tmp_dir / "sweep.oryseq"
window.export_sequence(str(path))
assert path.is_file()
assert not panel.dirty, "export did not clear the dirty flag"
# Change everything, then import it back.
panel.set_name("")
panel.field("pitch_mm").setText("1")
panel.set_points([])
document = window.import_sequence(str(path))
assert document.point_count == 2
assert panel.name() == "bench sweep"
assert panel.profile()["pitch_mm"] == 3.5
assert panel.point_row_count() == 2
assert not panel.dirty
def test_importing_a_bad_file_reports_and_changes_nothing(make_window, tmp_dir):
window = _show(make_window())
panel = window.sequence_panel
panel.field("pitch_mm").setText("4")
path = tmp_dir / "bad.oryseq"
path.write_text("not json at all", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
window.import_sequence(str(path))
assert "not valid JSON" in str(excinfo.value)
# The design is untouched: a refused import is not a partial import.
assert panel.profile()["pitch_mm"] == 4
def test_an_exported_sequence_names_the_sensor_it_was_designed_for(make_window, tmp_dir):
import json
window = _show(make_window())
window.prefs["sensor_setup"] = {
"sensor_type": "finger",
"geometry_id": "finger-1.0.0",
"probe_tip_set": True,
}
path = tmp_dir / "named.oryseq"
window.export_sequence(str(path))
document = json.loads(path.read_text(encoding="utf-8"))
assert document["device"]["sensor_type"] == "finger"
assert document["device"]["geometry_id"] == "finger-1.0.0"
assert document["versions"]["app"]
def test_language_switch_relabels_the_new_shell(make_window):
from orisys_app_shared.i18n import t
+283
View File
@@ -0,0 +1,283 @@
"""The calibration sequence document: round trip, refusals, derived summary.
The format is a contract with another machine's bench, so most of these tests
are about what happens when the file is *wrong* — a sequence half-applied is
worse than one rejected.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from orisys_autocal.sequence_io import (
DOCUMENT_KIND,
FORMAT_VERSION,
SequenceFormatError,
SequencePoint,
_version_tuple,
format_duration,
format_span,
from_document,
load,
save,
summarise,
to_document,
)
#: Where scratch files go. Deliberately not ``tmp_dir``: this suite has to run
#: in a sandboxed shell, and ``tmp_dir`` needs a writable process temp root that
#: such an environment may not provide (the same reason ``conftest.make_window``
#: avoids it).
_SCRATCH = Path(__file__).resolve().parents[1] / "outputs" / "_test_sequence_io"
@pytest.fixture
def tmp_dir():
shutil.rmtree(_SCRATCH, ignore_errors=True)
_SCRATCH.mkdir(parents=True, exist_ok=True)
yield _SCRATCH
shutil.rmtree(_SCRATCH, ignore_errors=True)
PROFILE = {
"mode": "flat",
"pitch_mm": 2.0,
"margin_mm": 1.0,
"radius_mm": 0.0,
"clear_height_mm": 5.0,
"approach_speed_mm_s": 2.0,
"press_speed_mm_s": 0.5,
"press_depth_mm": 0.2,
"dwell_s": 0.5,
"settle_window_s": 0.5,
"repeats": 3,
"order": "serpentine",
}
def _points(count: int = 6) -> list[SequencePoint]:
return [
SequencePoint(
id=index + 1,
target_mm={"x": 2.0 * index, "y": 0.0, "z": 12.4},
sensor_mm={"x": 2.0 * index, "y": 0.0, "z": 0.0},
clearance_mm=5.0,
)
for index in range(count)
]
def _document(**overrides) -> dict:
payload = to_document(
profile=PROFILE,
points=_points(),
name="sweep",
units_per_mm=10.0,
app_version="0.1.0",
shared_version="0.1.0",
shared_api="0.1",
)
payload.update(overrides)
return payload
# ------------------------------------------------------------------ round trip
def test_a_document_round_trips_through_a_file(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
back = load(path, current_profile=PROFILE)
assert back.point_count == 6
assert back.name == "sweep"
assert back.profile["pitch_mm"] == 2.0
assert back.units_per_mm == 10.0
assert not back.warnings
# Both frames survive: the platform target and the sensor position.
assert back.points[0].target_mm["z"] == pytest.approx(12.4)
assert back.points[0].sensor_mm is not None
def test_the_written_file_is_readable_json_with_the_house_style(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
text = path.read_text(encoding="utf-8")
assert text.endswith("\n")
assert "\n " in text, "the document is not indented"
assert "\\u" not in text, "non-ASCII was escaped"
payload = json.loads(text)
assert payload["kind"] == DOCUMENT_KIND
assert payload["format_version"] == FORMAT_VERSION
def test_saving_leaves_no_temporary_file_behind(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
assert [entry.name for entry in tmp_dir.iterdir()] == ["sweep.oryseq"]
def test_saving_over_an_existing_file_replaces_it(tmp_dir):
path = tmp_dir / "sweep.oryseq"
save(_document(), path)
save(_document(name="second"), path)
assert json.loads(path.read_text(encoding="utf-8"))["name"] == "second"
# -------------------------------------------------------------------- refusals
@pytest.mark.parametrize(
("case", "mutate", "expected"),
[
("wrong kind", lambda d: d.update(kind="something.else"), "not a sequence document"),
("missing version", lambda d: d.pop("format_version"), "format_version"),
("version not a string", lambda d: d.update(format_version=1.0), "format_version"),
("newer version", lambda d: d.update(format_version="2.0"), "newer AutoCal"),
("missing frame", lambda d: d.pop("frame"), "frame"),
("wrong unit", lambda d: d.update(frame={"length_unit": "um"}), "length_unit"),
("missing profile", lambda d: d.pop("profile"), "profile"),
("points not a list", lambda d: d.update(points={}), "must be a list"),
("point not an object", lambda d: d.update(points=["nope"]), "not an object"),
("point missing axis", lambda d: d.update(points=[{"target_mm": {"x": 1, "y": 2}}]), "missing 'z'"),
("point without target", lambda d: d.update(points=[{}]), "no target_mm"),
],
)
def test_a_bad_document_is_refused_with_a_specific_message(case, mutate, expected):
document = _document()
mutate(document)
with pytest.raises(SequenceFormatError) as excinfo:
from_document(document, current_profile=PROFILE)
assert expected in str(excinfo.value), case
def test_a_non_json_file_is_refused_with_its_position(tmp_dir):
path = tmp_dir / "broken.oryseq"
path.write_text("this is not json", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
load(path, current_profile=PROFILE)
assert "not valid JSON" in str(excinfo.value)
assert "line 1" in str(excinfo.value)
def test_a_missing_file_is_refused_not_crashed(tmp_dir):
with pytest.raises(SequenceFormatError):
load(tmp_dir / "absent.oryseq", current_profile=PROFILE)
def test_a_json_array_is_refused(tmp_dir):
path = tmp_dir / "array.oryseq"
path.write_text("[1, 2, 3]", encoding="utf-8")
with pytest.raises(SequenceFormatError) as excinfo:
load(path, current_profile=PROFILE)
assert "top level" in str(excinfo.value)
# ------------------------------------------------------------------- summary
def test_the_summary_reports_count_and_travel_span():
summary = summarise(_points(6), PROFILE)
assert summary["count"] == 6
assert summary["min_mm"]["x"] == 0.0
assert summary["max_mm"]["x"] == 10.0
assert summary["span_mm"]["x"] == pytest.approx(10.0)
# A constant coordinate has a nil span, and says so.
assert summary["span_mm"]["z"] == pytest.approx(0.0)
def test_the_summary_is_recomputed_and_wins_over_the_file():
"""A hand-edited summary must not be believed."""
document = _document()
document["summary"]["count"] = 999
document["summary"]["span_mm"]["x"] = 999.0
back = from_document(document, current_profile=PROFILE)
assert back.summary["count"] == 6
assert back.summary["span_mm"]["x"] == pytest.approx(10.0)
assert back.warnings and "disagrees" in back.warnings[0]
def test_an_agreeing_summary_produces_no_warning():
back = from_document(_document(), current_profile=PROFILE)
assert not any("disagrees" in warning for warning in back.warnings)
def test_no_points_imports_the_profile_and_says_so():
document = _document()
document.pop("points")
back = from_document(document, current_profile=PROFILE)
assert back.point_count == 0
assert any("no points" in warning for warning in back.warnings)
def test_a_missing_profile_key_keeps_the_current_value():
document = _document()
document["profile"].pop("pitch_mm")
back = from_document(document, current_profile=PROFILE)
assert back.profile["pitch_mm"] == 2.0
assert any("pitch_mm" in warning for warning in back.warnings)
def test_a_disabled_point_is_counted_separately():
points = _points(4)
points[1] = SequencePoint(id=2, target_mm=points[1].target_mm, enabled=False)
summary = summarise(points, PROFILE)
assert summary["count"] == 4
assert summary["enabled"] == 3
# ------------------------------------------------------------------- duration
def test_the_duration_estimate_scales_with_repeats_and_points():
one = summarise(_points(1), {**PROFILE, "repeats": 1})["estimated_duration_s"]
three = summarise(_points(1), {**PROFILE, "repeats": 3})["estimated_duration_s"]
assert three > one
six = summarise(_points(6), {**PROFILE, "repeats": 3})["estimated_duration_s"]
assert six > three
def test_no_points_means_no_estimate():
summary = summarise([], PROFILE)
assert summary["estimated_duration_s"] == 0.0
assert format_duration(0.0) == "—"
def test_the_duration_is_formatted_as_a_bench_timer():
assert format_duration(72.0) == "1:12"
assert format_duration(3600.0) == "1:00:00"
assert format_duration(None) == "—"
assert format_duration(float("nan")) == "—"
# ---------------------------------------------------------------------- spans
def test_a_zero_span_prints_once_not_twice():
assert format_span(12.4, 12.4) == "12.400"
assert format_span(0.0, 0.0) == "0.000"
def test_a_real_span_shows_both_ends_and_the_length():
assert format_span(0.0, 10.0) == "0.000–10.000 (10.000)"
def test_a_missing_span_is_a_dash():
assert format_span(None, None) == "—"
# --------------------------------------------------------------------- version
def test_versions_compare_as_tuples_not_strings():
"""``"1.10" < "1.9"`` as strings would load a newer file as if supported."""
assert _version_tuple("1.10") > _version_tuple("1.9")
assert _version_tuple("1.0") == (1, 0)
assert _version_tuple("1") == (1,)
# A same-major newer minor is still refused, but is *recognised* as newer.
document = _document()
document["format_version"] = "1.10"
with pytest.raises(SequenceFormatError):
from_document(document, current_profile=PROFILE)
+39 -26
View File
@@ -77,8 +77,8 @@ STRINGS = {
"stop_all": "全部停止",
"run_title": "运行",
"run_phase": "空闲",
"run_step": "第 0/0 点",
"run_time": "已用 0:00 · 预计剩余 —",
"run_idle_hint": "尚未生成标定点",
"run_run": "运行",
"run_pause": "暂停",
"run_abort": "中止",
@@ -129,6 +129,9 @@ STRINGS = {
"seq_preview": "预览",
"seq_validation": "校验",
"seq_points": "标定点列表",
"seq_name": "序列名称",
"seq_note": "备注",
"seq_summary": "6 点(6 启用) · X 0.000–10.000 (10.000) · Y 0.000 · Z 12.400–13.400 (1.000) · 预计 2:24(估计值)",
"seq_fields": [
("曲面模式", "平面"), ("点距 (mm)", "2.000"), ("边界余量 (mm)", "1.000"),
("曲面半径 (mm)", "—"), ("安全高度 (mm)", "5.000"), ("进给速度 (mm/s)", "2.000"),
@@ -140,12 +143,12 @@ STRINGS = {
"seq_preview_hint": "生成后可在此检查点是否落在曲面上、是否在行程内、接触能否被确认。",
"seq_checks": ["在曲面范围内", "在行程内", "接触可确认"],
"seq_checks_pending": "尚未生成标定点。",
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("保存序列…", True), ("载入序列…", True)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本", "操作"],
"seq_buttons": [("生成标定点", True), ("仿真", True), ("干跑(不接触)", True), ("导出序列…", False), ("导入序列…", False)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "状态", "灵敏度(上次)", "样本"],
"point_rows": [
["1", "0.000", "0.000", "12.400", "待测", "—", "—", ""],
["2", "2.000", "0.000", "12.400", "待测", "—", "—", ""],
["3", "4.000", "0.000", "12.400", "待测", "—", "—", ""],
["1", "0.000", "0.000", "12.400", "待测", "—", "—"],
["2", "2.000", "0.000", "12.400", "待测", "—", "—"],
["3", "4.000", "0.000", "12.400", "待测", "—", "—"],
],
"points_hint": "坐标由网格生成并逐点校验,不接受手工输入:手工输入的坐标没有任何检查,一个错数字会毁掉整条序列。",
"sensor_note": "设备与信号源、实时图像、力曲线",
@@ -169,8 +172,8 @@ STRINGS = {
"stop_all": "Stop all",
"run_title": "Run",
"run_phase": "Idle",
"run_step": "Point 0/0",
"run_time": "Elapsed 0:00 · about — left",
"run_idle_hint": "No points generated",
"run_run": "Run",
"run_pause": "Pause",
"run_abort": "Abort",
@@ -221,6 +224,9 @@ STRINGS = {
"seq_preview": "Preview",
"seq_validation": "Checks",
"seq_points": "Calibration points",
"seq_name": "Sequence name",
"seq_note": "Note",
"seq_summary": "6 points (6 enabled) · X 0.000-10.000 (10.000) · Y 0.000 · Z 12.400-13.400 (1.000) · about 2:24 (estimate)",
"seq_fields": [
("Surface mode", "Flat"), ("Pitch (mm)", "2.000"), ("Margin (mm)", "1.000"),
("Radius (mm)", "—"), ("Clear height (mm)", "5.000"), ("Approach (mm/s)", "2.000"),
@@ -232,12 +238,12 @@ STRINGS = {
"seq_preview_hint": "After generation, check each point is on the surface, inside travel and confirmable by contact.",
"seq_checks": ["On the surface", "Within travel", "Contact confirmable"],
"seq_checks_pending": "No points generated yet.",
"seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Save sequence…", True), ("Load sequence…", True)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples", "Actions"],
"seq_buttons": [("Generate points", True), ("Simulate", True), ("Dry run (no contact)", True), ("Export sequence…", False), ("Import sequence…", False)],
"point_cols": ["#", "X (mm)", "Y (mm)", "Z (mm)", "Status", "Sensitivity (last)", "Samples"],
"point_rows": [
["1", "0.000", "0.000", "12.400", "Pending", "—", "—", ""],
["2", "2.000", "0.000", "12.400", "Pending", "—", "—", ""],
["3", "4.000", "0.000", "12.400", "Pending", "—", "—", ""],
["1", "0.000", "0.000", "12.400", "Pending", "—", "—"],
["2", "2.000", "0.000", "12.400", "Pending", "—", "—"],
["3", "4.000", "0.000", "12.400", "Pending", "—", "—"],
],
"points_hint": "Coordinates are generated and checked per point; they are not typed. A typed coordinate is one nothing has checked.",
"sensor_note": "Device and source, live image, force curves",
@@ -644,13 +650,22 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
profile = QRect(inner.x(), inner.y(), profile_w, top_h)
p_inner = _panel(painter, profile, palette)
_box_title(painter, profile, s["seq_profile"], palette)
# the name and note the document carries
y = p_inner.y() + 4
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_name"], palette, size=9, muted=True)
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "5 点灵敏度扫描", palette)
y += 26
_text(painter, QRect(p_inner.x(), y, 70, 22), s["seq_note"], palette, size=9, muted=True)
_field(painter, QRect(p_inner.x() + 72, y, p_inner.width() - 78, 22), "", palette)
y += 28
column_w = p_inner.width() // 2
for index, (label, value) in enumerate(s["seq_fields"]):
row, side = divmod(index, 2)
x = p_inner.x() + side * column_w
y = p_inner.y() + 20 + row * 26
_text(painter, QRect(x, y, 130, 22), label, palette, size=9, muted=True)
_field(painter, QRect(x + 128, y, column_w - 140, 22), value, palette)
row_y = y + row * 26
_text(painter, QRect(x, row_y, 130, 22), label, palette, size=9, muted=True)
_field(painter, QRect(x + 128, row_y, column_w - 140, 22), value, palette)
# --- preview
preview = QRect(inner.x() + profile_w + gap, inner.y(), preview_w, top_h)
@@ -699,7 +714,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
points = QRect(inner.x(), inner.y() + top_h + gap, inner.width(), inner.height() - top_h - gap)
t_inner = _panel(painter, points, palette)
_box_title(painter, points, s["seq_points"], palette)
widths = (50, 150, 150, 150, 130, 190, 100)
# Seven columns: no empty actions column, and the summary line carries the
# count and span the operator reasons with.
widths = (60, 150, 150, 150, 140, 190, 110)
widths = widths + (t_inner.width() - sum(widths),)
header_h = 28
header = QRect(t_inner.x(), t_inner.y() + 4, t_inner.width(), header_h)
@@ -719,7 +736,9 @@ def draw_sequence_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect)
for value, width in zip(row, widths):
_text(painter, QRect(x, row_y, width, row_h), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter)
x += width
y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 10
y = header.bottom() + 1 + len(s["point_rows"]) * row_h + 8
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 22), s["seq_summary"], palette, size=10)
y += 24
_text(painter, QRect(t_inner.x(), y, t_inner.width(), 34), s["points_hint"], palette, size=9, muted=True)
@@ -917,13 +936,7 @@ def draw_run_bar(painter: QPainter, palette: Palette, s: dict, top: int) -> None
x += painter.fontMetrics().horizontalAdvance(s["run_title"]) + 20
_text(painter, QRect(x, top, 90, RUNBAR_H), s["run_phase"], palette, size=10, muted=True)
x += 96
_text(painter, QRect(x, top, 150, RUNBAR_H), s["run_step"], palette, size=10)
x += 158
bar = QRect(x, top + 14, 300, 12)
painter.fillRect(bar, palette.border)
painter.fillRect(QRect(bar.x(), bar.y(), 3 * bar.width() // 4, bar.height()), palette.accent)
_text(painter, QRect(bar.right() + 12, top, 260, RUNBAR_H), s["run_time"], palette, size=10, muted=True)
_text(painter, QRect(x, top, 420, RUNBAR_H), s["run_idle_hint"], palette, size=9, muted=True)
_button(painter, QRect(PAGE_W - 330, top + 7, 96, 26), s["run_run"], palette, disabled=True)
_button(painter, QRect(PAGE_W - 228, top + 7, 96, 26), s["run_pause"], palette, disabled=True)
+4 -4
View File
@@ -8,8 +8,8 @@ inside parents, controls gated by connection and bus state, locales complete.
python apps/autocal/tools/panel_check.py
No controller, no DLL and no display are needed. Mirrors
``apps/studio/tools/ui_check.py``, but cannot import it: that module imports
PyQt5, and AutoCal's environment has none.
``apps/studio/tools/ui_check.py``, but cannot import it: that module pulls in
``orisys_studio`` (the acquisition stack), which AutoCal's environment has none of.
"""
from __future__ import annotations
@@ -218,8 +218,8 @@ def main() -> int:
"the sequence point list is empty",
)
checker.that(
sequence.table.columnCount() == 8,
f"the point list has {sequence.table.columnCount()} columns, expected 8",
sequence.table.columnCount() == len(sequence.column_keys()),
f"the point list has {sequence.table.columnCount()} columns, expected {len(sequence.column_keys())}",
)
# The shell: one rail, a run bar, and a health chip.