Lint the commits being pushed and scope overlay failures per sensor

The pre-push gate linted only the dirty tree, so the commits about to be pushed went
unchecked; and every failed overlay projection was sticky, which cost a finger result its
overlay over one bad frame while box results were retried nowhere. gate.ps1 now takes
-LintBase (default origin/main, exit 2 on an unknown ref), the projector declares whether
its own failures are permanent, and serve_stored_view decides before it sets the view.
Verified with gate.ps1 (PASS: 569 studio + 485 shared tests, 632 ui_check checks) and the
two round-2 red samples kept in scratch.
This commit is contained in:
ykcheungab
2026-10-18 21:17:03 +08:00
parent 0998009ec3
commit c37efd7a44
9 changed files with 648 additions and 260 deletions
+5
View File
@@ -125,6 +125,11 @@ shared suite, and `ui_check.py`; writes each one's output to
`tools/skip_budget.txt`, and zero findings anywhere. It exits 0 only when all of `tools/skip_budget.txt`, and zero findings anywhere. It exits 0 only when all of
that holds. that holds.
The lint step compares against a commit range, not only the dirty tree: `-LintBase <ref>`
defaults to `origin/main`, so a pre-push run checks every commit about to be pushed.
A ref that does not resolve is a configuration error (exit 2); the gate never quietly
falls back to linting uncommitted files only.
The **gate platform is Windows**: the suites render real Qt widgets offscreen, and two The **gate platform is Windows**: the suites render real Qt widgets offscreen, and two
checks are platform-sensitive ([`docs/test-repair-plan.zh.md`](docs/test-repair-plan.zh.md) checks are platform-sensitive ([`docs/test-repair-plan.zh.md`](docs/test-repair-plan.zh.md)
§3 #11 and #13), so a green Linux run is not the same statement. §3 #11 and #13), so a green Linux run is not the same statement.
@@ -389,8 +389,13 @@ class AnalysisSession:
"""The live-shaped processed image for this camera frame, or ``None``. """The live-shaped processed image for this camera frame, or ``None``.
Opens a session-scoped projector from the result's provenance on first use. Opens a session-scoped projector from the result's provenance on first use.
Failure is sticky for the session: overlay views then report capture **How long a failure lasts is the projector's answer, not this method's guess:**
unavailable rather than falling back to a resized fisheye. a projector that declares its projection failures permanent (a box stitch that
cannot rebuild the lenslet grid on this footage) is recorded as unavailable and
not asked again; one that declares them transient (a finger unroll that lost a
single frame) skips that frame and is tried again on the next. The alternative —
making every failure sticky — cost a finger result its whole overlay view over
one bad frame (reported as R2 in docs/test-repair-rework.zh.md).
""" """
if self._overlay_failed: if self._overlay_failed:
return None return None
@@ -401,16 +406,11 @@ class AnalysisSession:
self.reader.provenance self.reader.provenance
) )
if self._overlay is None: if self._overlay is None:
# Nothing can be opened for this result at all: permanent either way.
self._overlay_failed = True self._overlay_failed = True
return None return None
projected = self._overlay.project(video_bgr, frame_id=frame_id) projected = self._overlay.project(video_bgr, frame_id=frame_id)
if projected is None: if projected is None and self._overlay.projection_failures_are_permanent:
# Sticky, as the docstring above says. A projector that cannot map this
# result's footage — measured: every frame of a real box recording, where the
# stitch rebuild raises inside the SDK — will not start working mid-session,
# and retrying per cursor move would rebuild the same failing stitch on every
# scrub. The session records the answer so a caller can pick a view this
# result *can* draw instead of leaving a refusal on screen.
self._overlay_failed = True self._overlay_failed = True
return projected return projected
@@ -420,12 +420,12 @@ class AnalysisSession:
The overlay views (``arrows_stitched``, ``depth_arrows``) paint on the same The overlay views (``arrows_stitched``, ``depth_arrows``) paint on the same
UV-unrolled or stitched picture live publishes, which a stored result does not UV-unrolled or stitched picture live publishes, which a stored result does not
contain: it is rebuilt from the result's provenance on first use. A **box** contain: it is rebuilt from the result's provenance on first use. The answer is
result has no stored unroll map, and the box stitch rebuild can fail on real only known after an attempt, and it is sticky **only for a failure the projector
footage, so the answer is only known after one attempt — and it is then sticky declares permanent** — a box result whose stitch cannot rebuild, or a projector
for the session (see :meth:`overlay_base`). The caller that *picks* the first that would not open at all. A finger unroll that loses one frame is not sticky
view for an adopted result reads this, so the operator is not left on a view (see :meth:`overlay_base`). The caller that *picks* the first view for an adopted
that cannot be drawn. result reads this, so the operator is not left on a view that cannot be drawn.
""" """
return bool(self._overlay_failed) return bool(self._overlay_failed)
@@ -1034,13 +1034,21 @@ class ReplayController(QObject):
texture live uses. A derived selection the operator already chose is left texture live uses. A derived selection the operator already chose is left
alone — that mode is not this method's to override. alone — that mode is not this method's to override.
**But only a view this result can actually draw.** The base is rebuilt from the **But only a view this result can actually draw, decided before it is set.** The
result's provenance, which a box result cannot always do (no stored unroll map, base is rebuilt from the result's provenance, which a box result cannot always do
and the box stitch rebuild can fail on real footage: measured, every frame of the (no stored unroll map, and the box stitch rebuild can fail on real footage:
restored 33-frame box recording). Offering the overlay there would leave the measured, every frame of the restored 33-frame box recording). Offering the
operator on a blank panel reading "capture unavailable" while the pure-colour overlay there would leave the operator on a blank panel reading "capture
arrows sit one step away, so the fallback is the drawable view — the overlay is unavailable" while the pure-colour arrows sit one step away, so the fallback is
still in the selector, and choosing it still says why it cannot be drawn. the drawable view — the overlay is still in the selector, and choosing it still
says why it cannot be drawn.
The probe runs *before* the view is set (see :meth:`_overlay_base_ready`) so the
panel is set once and drawn once: setting the overlay view first and switching
after the first render flashed the refusal on screen during adoption (R3 of
docs/test-repair-rework.zh.md). The base it asks for is the one the render that
follows will ask for, and the projector caches by frame id, so deciding costs no
second projection.
""" """
current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "") current = str(getattr(self._image_panel, "view_mode", lambda: "")() or "")
if current in DERIVED_MODES: if current in DERIVED_MODES:
@@ -1048,16 +1056,29 @@ class ReplayController(QObject):
session = self.available_result() session = self.available_result()
# The plan for the frame just drawn offers the derived modes (the frame carries # The plan for the frame just drawn offers the derived modes (the frame carries
# ``result_available``), so this selection is one the panel can honour. # ``result_available``), so this selection is one the panel can honour.
overlay_failed = bool(getattr(session, "overlay_unavailable", False)) drawable = True if session is None else self._overlay_base_ready(session)
self._image_panel.set_view_mode(ARROW_PURE if overlay_failed else ARROW_STITCHED) self._image_panel.set_view_mode(ARROW_STITCHED if drawable else ARROW_PURE)
self.render_frame() self.render_frame()
if not overlay_failed and getattr(session, "overlay_unavailable", False):
# The first attempt is what tells us: the base could not be rebuilt, so draw
# the view that needs no base instead of leaving a refusal on screen.
self._image_panel.set_view_mode(ARROW_PURE)
self.render_frame()
return True return True
def _overlay_base_ready(self, session) -> bool:
"""Whether ``render_frame`` can paint this result's overlay base, without drawing.
Asks the session for the same base, for the same frame, that the render will ask
for. ``True`` when there is no frame to judge: an unknown answer must not invent a
fallback view the operator did not ask for. A session that has already declared the
overlay unavailable is answered without another projection attempt.
"""
if bool(getattr(session, "overlay_unavailable", False)):
return False
if self.analysis_active():
frame = self._engine.analysed_frame()
else:
frame = session.frame_at(self._engine.position())
if frame is None:
return True
return self._processed_base_for(frame, session) is not None
def _display_policy(self) -> Any: def _display_policy(self) -> Any:
from orisys_app_shared.scene3d.visual_policy import policy_for_settings from orisys_app_shared.scene3d.visual_policy import policy_for_settings
+12 -5
View File
@@ -11,15 +11,22 @@ generated pixels, and a synthetic 896² clip sent the chain into minutes of work
| | | | | |
|---|---| |---|---|
| Sensor | `box` (shipped bundle) | | Sensor | `box` (shipped bundle) |
| Source | `dist/OrisysStudio/outputs/recordings/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011`, recorded 2026-09-30, `session_id` 2 | | Source | [`.scratch/result-visible/recording/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011/`](../../../../.scratch/result-visible/recording/20260930T015028Z_session0002_40cb8e7441434feab48bb646afdba011/) — recorded 2026-09-30, `session_id` 2. The directory name is the recording's own identity, so it matches the `dist/` path below. |
| Length | 33 frames, frame ids **20…52**, 1.92 s of signal time | | Length | 33 frames, frame ids **20…52**, 1.92 s of signal time |
| Files | `raw_0001.mp4`, `frames.csv`, `forces.csv`, `events.csv`, `session.json` (3.8 MB) | | Files | `raw_0001.mp4`, `frames.csv`, `forces.csv`, `events.csv`, `session.json` (3.8 MB) |
| Not included | `preroll_0001.mp4` (3.5 MB, optional for every reader: `RecordingReader.preroll_segment()` answers `None` when the file is absent) | | Not included | `preroll_0001.mp4` (3.5 MB, optional for every reader: `RecordingReader.preroll_segment()` answers `None` when the file is absent) |
`dist/` is build output and is not tracked, so the copy under it disappeared on `dist/` is build output and is not tracked, so the copy under `dist/OrisysStudio/outputs/recordings/` disappeared on 2026-10-14 when the studio was repackaged. That took 11 tests to `ERROR` and 4 to `failed` without anyone noticing — see [`docs/test-repair-plan.zh.md`](../../../docs/test-repair-plan.zh.md) §1 and §3 #1–#2.
2026-10-14 when the studio was repackaged. That took 11 tests to `ERROR` and 4 to
`failed` without anyone noticing — see The `dist/` original is **gone**, so the files here cannot be compared against it byte for byte. They are byte-identical to the copy preserved under `.scratch/result-visible/` (the source named above), which is the strongest provenance available. The hashes are recorded here so a future copy can be checked against this one instead of argued about:
[`docs/test-repair-plan.zh.md`](../../../docs/test-repair-plan.zh.md) §1 and §3 #1–#2.
| File | SHA256 |
|---|---|
| `raw_0001.mp4` | `907867d0fe82966dbaacf97a8bb0a8d5647de25458ad5fd4640535d83d417207` |
| `frames.csv` | `fab25e6780908aef8f7616504b43cf34062ab7ca3ba317b6ff4c0631969e0601` |
| `forces.csv` | `972d3c951ff0be847a6a224b53339401f115cd3728039f1acb24a33bf215ecb1` |
| `events.csv` | `6fe7121b1c621ebed472ee42376babfd76a32a364ab9f11660da077fb365a946` |
| `session.json` | `f3c9afc4502180d2a88cd260cb70f1f4313e203f2c3c8441704de6ac53a1db4c` |
The frame ids are deliberately not 0…32: a test that used the frame *index* would pass on The frame ids are deliberately not 0…32: a test that used the frame *index* would pass on
a 0-based recording, and real recordings start wherever the device's counter was. a 0-based recording, and real recordings start wherever the device's counter was.
@@ -0,0 +1,147 @@
"""How long an overlay-base failure lasts: one frame, or the whole session.
R2 of ``docs/test-repair-rework.zh.md``. The second round made every failed projection
sticky, so one bad finger frame cost that result its overlay view for the session — a
behaviour change beyond the box case it was written for. The scope is now the
projector's own declaration (``overlay_base.OverlayBaseProjector``), never a guess made
from a ``None`` or from an exception's text:
* a **finger** unroll that loses a frame is transient — the next frame is tried and the
session is *not* marked unavailable;
* a **box** stitch that cannot rebuild the lenslet grid on this footage is permanent —
the session records it and stops asking, so scrubbing does not rebuild the same
failure on every cursor move;
* a projector that cannot be **opened** at all is permanent, because no frame can work.
No SDK and no recording are involved: the projector is given a stand-in ``project``, and
the session is handed that projector directly, so these run on any machine.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from orisys_app_shared.analysis.model import Provenance
from orisys_app_shared.analysis.overlay_base import (
OverlayBaseProjector,
projection_failures_are_permanent,
)
from orisys_studio.replay_analysis import AnalysisSession
FRAME = np.zeros((6, 6, 3), dtype=np.uint8)
BASE = np.full((6, 6, 3), 7, dtype=np.uint8)
class _Reader:
"""The smallest reader ``AnalysisSession`` accepts, with no result behind it."""
complete = True
key = "overlay-scope"
directory = Path(".")
frame_ids = np.array([1, 2], dtype=np.int64)
times = np.array([0.0, 0.1])
keyframes: dict = {}
provenance = Provenance()
def mask(self):
return None
def frame(self, frame_id):
return SimpleNamespace(depth=np.zeros((6, 6), dtype=np.float32))
def grid(self, grid_id):
raise AssertionError("no grid in this stub")
def _session(projector) -> AnalysisSession:
"""A session whose projector is supplied directly (its provenance is not used)."""
session = AnalysisSession(_Reader())
session._overlay = projector
return session
def test_one_bad_finger_frame_does_not_cost_the_session_its_overlay():
"""A finger unroll that fails on one frame is retried on the next."""
answers = [None, BASE]
projector = OverlayBaseProjector(
sensor_type="finger",
project=lambda image: answers.pop(0),
projection_failures_are_permanent=False,
)
session = _session(projector)
assert session.overlay_base(FRAME, frame_id=1) is None
assert session.overlay_unavailable is False, "one frame made the whole result unavailable"
base = session.overlay_base(FRAME, frame_id=2)
assert base is not None, "the second frame was never projected"
assert np.array_equal(base, BASE)
assert session.overlay_unavailable is False
def test_a_finger_projection_that_raises_is_also_only_that_frame():
"""The exception text is not consulted: a raising finger projection is transient."""
answers = [RuntimeError("unroll miss"), BASE]
def project(image):
answer = answers.pop(0)
if isinstance(answer, Exception):
raise answer
return answer
session = _session(
OverlayBaseProjector(
sensor_type="finger",
project=project,
projection_failures_are_permanent=False,
)
)
assert session.overlay_base(FRAME, frame_id=1) is None
assert session.overlay_unavailable is False
assert session.overlay_base(FRAME, frame_id=2) is not None
def test_a_box_projection_that_fails_is_permanent_and_is_not_retried():
"""A box stitch that fails will fail again; the session stops asking."""
calls: list[int] = []
def project(image):
calls.append(1)
return None
session = _session(OverlayBaseProjector(sensor_type="box", project=project))
assert session.overlay_base(FRAME, frame_id=1) is None
assert session.overlay_unavailable is True
assert session.overlay_base(FRAME, frame_id=2) is None
assert len(calls) == 1, "a permanent failure was retried on the next frame"
def test_a_projector_that_cannot_open_is_permanent(monkeypatch):
"""No projector at all is the other permanent case: no frame could ever work."""
calls: list[int] = []
@classmethod
def _cannot_open(cls, provenance):
calls.append(1)
return None
monkeypatch.setattr(OverlayBaseProjector, "open_from_provenance", _cannot_open)
session = AnalysisSession(_Reader())
assert session.overlay_base(FRAME, frame_id=1) is None
assert session.overlay_unavailable is True
assert session.overlay_base(FRAME, frame_id=2) is None
assert len(calls) == 1, "opening was retried after it had already failed"
@pytest.mark.parametrize(
("sensor_type", "permanent"),
(("finger", False), ("box", True)),
)
def test_the_sensor_decides_which_kind_of_failure_it_has(sensor_type, permanent):
"""The policy lives with the sensor's opener, not in each caller's judgement."""
assert projection_failures_are_permanent(sensor_type) is permanent
+58
View File
@@ -772,3 +772,61 @@ def test_a_bare_video_with_a_declined_prompt_promises_no_pass(
) )
assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text() assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text()
assert plan_image(captured[-1]).note_key == "panels.replay_analysis_failed" assert plan_image(captured[-1]).note_key == "panels.replay_analysis_failed"
def test_adopting_a_box_result_never_flashes_the_overlay_refusal(
window, stored_recording, monkeypatch
):
"""R3: pick the drawable view once, draw once, and never show the refusal on the way.
The box result has no stored unroll map and its stitch rebuild fails on this footage,
so the overlay base cannot be built. The pre-rework order — set the overlay view,
render, discover the failure, switch, render again — put
``panels.arrow_capture_unavailable`` on screen while adopting the result, and drew the
same frame twice. The decision is now made before the view is set, so the panel is set
once and rendered once, and the refusal is still what the operator gets when they ask
for the overlay themselves.
"""
from orisys_app_shared.i18n import t
controller = window.replay_ctl
refusal = t("panels.arrow_capture_unavailable")
seen: list[str] = []
renders: list[int] = []
inside = {"serve": False}
original_render = controller.render_frame
def render_spy():
if inside["serve"]:
renders.append(1)
original_render()
seen.append(window.arrow_panel.image_label.text())
else:
original_render()
original_serve = controller.serve_stored_view
def serve_spy():
inside["serve"] = True
try:
return original_serve()
finally:
inside["serve"] = False
monkeypatch.setattr(controller, "render_frame", render_spy)
monkeypatch.setattr(controller, "serve_stored_view", serve_spy)
_open(window, stored_recording, monkeypatch)
assert inside["serve"] is False, "adoption never reached the view it serves"
assert len(renders) == 1, f"adoption rendered the frame {len(renders)} times, not once"
assert refusal not in seen, "the overlay refusal was shown while adopting the result"
assert controller._image_panel.view_mode() == ARROW_PURE, (
"a result whose overlay cannot be drawn was adopted on the overlay view"
)
# The overlay is still on offer, and choosing it still says why it cannot be drawn.
controller._image_panel.set_view_mode(ARROW_STITCHED)
controller.render_frame()
assert window.arrow_panel.image_label.text() == refusal
+43 -14
View File
@@ -1,6 +1,6 @@
# Studio 测试修复:第 3 轮返工 # Studio 测试修复:第 3 轮返工
状态:rework。日期:2026-10-18。前序计划与诊断:[测试修复与门禁计划](test-repair-plan.zh.md),其阶段 0–6 已交付于 `433b76e`。 状态:review。日期:2026-10-18。前序计划与诊断:[测试修复与门禁计划](test-repair-plan.zh.md),其阶段 0–6 已交付于 `433b76e`。
自第 3 轮起,本文件是 task_id `test-repair` 的权威任务记录,原计划不再维护状态。 自第 3 轮起,本文件是 task_id `test-repair` 的权威任务记录,原计划不再维护状态。
## 1. 为什么返工 ## 1. 为什么返工
@@ -43,8 +43,8 @@ R2 先于 R3;全部完成后以最终状态跑一次 `gate.ps1`。总预算 5
按 [代理交接约定](../../docs/platform/agent-handoff.zh.md) 维护;只保留当前状态和每轮一行摘要。 按 [代理交接约定](../../docs/platform/agent-handoff.zh.md) 维护;只保留当前状态和每轮一行摘要。
- task_id:test-repair;round:3;updated_at:2026-10-18 19:55 +08:00 - task_id:test-repair;round:3;updated_at:2026-10-18 21:20 +08:00
- status:rework;owner:Claude(定义与验收);next_agent:DeepSeek(执行) - status:review;owner:DeepSeek(执行);next_agent:Claude(验收)
- 目标与完成条件:第 3 节 R1–R4 的验收列全部满足,最终 `gate.ps1` 退出码 0 - 目标与完成条件:第 3 节 R1–R4 的验收列全部满足,最终 `gate.ps1` 退出码 0
- 范围:allowed_files 为 `tools/gate.ps1`、`.githooks/pre-push`、`README.md`(合并门禁一节)、 - 范围:allowed_files 为 `tools/gate.ps1`、`.githooks/pre-push`、`README.md`(合并门禁一节)、
`apps/studio/src/orisys_studio/replay_analysis.py`、`apps/studio/src/orisys_studio/replay_controller.py`、 `apps/studio/src/orisys_studio/replay_analysis.py`、`apps/studio/src/orisys_studio/replay_controller.py`、
@@ -59,24 +59,53 @@ R2 先于 R3;全部完成后以最终状态跑一次 `gate.ps1`。总预算 5
| 仓库/checkout | 分支、HEAD | 起始暂存/未暂存/未跟踪变化 | 本轮修改 | | 仓库/checkout | 分支、HEAD | 起始暂存/未暂存/未跟踪变化 | 本轮修改 |
|---|---|---|---| |---|---|---|---|
| `D:\orisys_ws\orisys-app` | `main`、`433b76e`,领先 `origin/main` 8 个提交 | 暂存区空;`apps/autocal/**` 7 个文件未暂存,属另一任务;根目录 `LZ.*` 压缩包与目录未跟踪,非本任务;本文件及原计划交接记录的定义改动待用户提交 | 执行方填写文件清单及 SHA256 | | `D:\orisys_ws\orisys-app` | `main`;起点 `fbf8c76`(= `433b76e` + 提交本文件);执行期间外部任务又提交 `9352d0c`、`cf3e638`、`0998009`,收尾 `0998009` | 暂存区空;起点时 `apps/autocal/**` 7 个文件未暂存(另一任务),另有未跟踪的 `apps/studio/tests/test_status_presenter.py` 与 `docs/architecture/studio-window-refactor-plan.zh.md`;`LZ.*` 压缩包与目录未跟踪 | 8 个文件,见下表 |
- 环境:`D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe`,Windows PowerShell 5.1 本轮修改(SHA256 为收尾实测值,供写入核对):
- 输入:`apps/studio/tests/data/box_short_20260930/`,须与 `433b76e` 一致(`git diff 433b76e -- apps/studio/tests/data` 为空)
| 文件 | 字节 | SHA256 |
|---|---|---|
| `tools/gate.ps1` | 16266 | `a588d9d4cc5d80b90e06697efa48bd8bc3321843bb7ed8f99ca36bab0f29ab51` |
| `README.md` | 7426 | `7e9b36b014890f50a63c9391c8e6fcea0942f282e0cab3bd2a593d158569bd4f` |
| `shared/src/orisys_app_shared/analysis/overlay_base.py` | 9853 | `8b55059dbf0a65dcf015084605a2540d699af9b207f7f5d70cbc84fc0080a943` |
| `apps/studio/src/orisys_studio/replay_analysis.py` | 30003 | `aac7d47eab1301f4b8142497a3faa34c20b354e1aa069a4eeb0e7b327bb556a2` |
| `apps/studio/src/orisys_studio/replay_controller.py` | 76930 | `15155468465e11b5516b94de5c7271f5c44aa8ae8cc263233035f847c7305ae8` |
| `apps/studio/tests/test_overlay_failure_scope.py`(新增) | 5291 | `e1166c925b2203a5e13d3626fcaa7e255a000e9040d4878e9cfdddc96e252ac7` |
| `apps/studio/tests/test_replay_wiring.py` | 35799 | `ee2a315be57d75c4f26395691e7f0b8b83f593730edc3721002f16cfd7e11e2d` |
| `apps/studio/tests/data/README.md` | 2897 | `166cb831d1c0aa89f8c0c140eb3725127296af6fe334a5807c36695ac53bce85` |
| 本文件交接记录 | — | 随本记录变化,不列自哈希 |
`.githooks/pre-push` **未改**:它调用 `gate.ps1` 时不传 `-LintBase`,默认值 `origin/main` 正是推送前需要的基准。
- 环境:`D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe`,Windows PowerShell 5.1(`powershell.exe`)
- 输入:`apps/studio/tests/data/box_short_20260930/`,实测 `git diff 433b76e -- apps/studio/tests/data` 为空;5 个文件 SHA256 已写进 `apps/studio/tests/data/README.md`
### 执行结果 ### 执行结果
| 验收项与预期 | 实际命令及 run_id | 实际结果 | 证据路径 | | 验收项与预期 | 实际命令及 run_id | 实际结果 | 证据路径 |
|---|---|---|---| |---|---|---|---|
| R1–R4,见第 3 节 | 执行方填写 | 未执行 | `.scratch/test-repair/round3/` | | R1① `-LintBase HEAD~1` 的 lint.log 列出 `433b76e` 改过的 `.py` | `gate.ps1 -Task test-repair/round3 -LintBase HEAD~1`(`gate-20261018T121600Z`) | 通过:lint.log 新增 `files checked against HEAD~1 (15)`,含 `replay_controller.py`、`replay_analysis.py`,退出码 0 | `round3/gate-20261018T121600Z/lint.log`、`summary.md` |
| R1① 追加:范围覆盖**已提交**改动(用稳定基准排除“工作区脏”混淆) | `gate.ps1 -LintBase dcabd54`(= `433b76e~1`,`gate-20261018T122730Z`) | 结果见“剩余限制”:lint 代码部分 `no findings`,29 个文件;该次失败是并行的外部文档改动触发文档门禁 | `round3/gate-20261018T122730Z/lint.log` |
| R1② 无效基准退出码 2 | `gate.ps1 -LintBase no-such-ref` | 通过:`gate: -LintBase 'no-such-ref' is not a valid git ref…`,`EXIT=2`,未创建运行目录 | 本记录 |
| R1③ 默认运行退出码 0 | `gate.ps1 -Task test-repair/round3`(`gate-20261018T130919Z`) | 通过:`PASS`,`LintBase origin/main`,33 个文件,exit 0 | `round3/gate-20261018T130919Z/summary.md` |
| R2① finger 单帧失败不粘性 | `pytest apps/studio/tests/test_overlay_failure_scope.py` | 通过 6 项;第 2 轮代码上 2 项失败(`assert True is False`,`overlay_unavailable` 变 True) | `round3/r2-green.log`、`round3/r2-round2-fails.log` |
| R2② box 失败一次即粘性且不再调用投影;③ 打开失败同 ② | 同上 | 通过(`calls == 1`) | 同上 |
| R3①–④ 采纳 box 结果只设一次视图、只渲染一次、无拒绝文案、手选仍拒绝 | `pytest apps/studio/tests/test_replay_wiring.py -k adopting_a_box_result` | 通过;第 2 轮代码上失败 `adoption rendered the frame 2 times, not once` | `round3/r3-1.log`、`round3/r3-round2-fails.log` |
| R3 输入不变 | `pytest apps/studio/tests/test_bare_video_analysis.py`(随套件) | 通过,未改其断言 | `round3/gate-20261018T130919Z/studio-suite.log` |
| R4a 来源、dist 说明、5 个 SHA256 | 实测 `Get-FileHash` 与 `.scratch/result-visible/recording/20260930T015028Z_session0002_…/` 逐字节一致 | 通过:5 个哈希已写入文件 | `apps/studio/tests/data/README.md` |
| R4b UTC 运行号 + `finally` 删除 `.active` | 多次 gate 运行(成功与失败各若干次) | 通过:目录名 `gate-20261018T130919Z` 对应 UTC 13:09(本地 21:09);每次结束后 `.scratch/test-repair/round3/.active` 不存在 | 各 `gate-*` 目录 |
| R4c `ui_check.py` 退出码非 0 判失败 | 临时让 `ui_check.py` 在打印干净汇总后 `SystemExit(3)`,运行 gate(`gate-20261018T125009Z`) | 通过:`ui_check | failed | ui_check.py exited 3`;当时汇总行是 `failures: 0`,旧逻辑会放行。恢复后 `ui_check` 仍 632 检查 0 失败 | `round3/gate-scenario-S1b.log`、`round3/bak-ui_check.py`(原始副本) |
| R4d 只放行 `0xC0000005` | ① 临时 hook 让套件以 `-1073741819` 结束(`gate-20261018T125009Z`);② `PYTEST_ADDOPTS=-p exitprobe` 让 shared 套件在干净 JUnit 后 `os._exit(3)`(`gate-20261018T130308Z`) | 均通过:① `studio suite | passed | …; process exit 0xC0000005 after the summary (Qt teardown, allowed)`;② `shared suite | failed | …; process exit 3 is not the allowed teardown crash` | `round3/gate-scenario-S1b.log`、`round3/gate-scenario-S2b.log`、`round3/exitprobe.py` |
- 已完成、失败样本、剩余限制: - 已完成:R1–R4 全部实现;临时场景用的 `apps/studio/tools/ui_check.py`、`apps/studio/tests/conftest.py` 已从 scratch 副本恢复,`git diff --stat` 对这两个文件为空;`.scratch/test-repair/round3/exitprobe.py` 只在受控运行中通过 `PYTEST_ADDOPTS` 加载,不是仓库文件。
- 后台进程及 scratch 使用状态: - 失败样本:第 2 轮代码上的两处红证据(R2 的 `r2-round2-fails.log`、R3 的 `r3-round2-fails.log`)保留在 scratch。
- 剩余限制:① 执行期间有并行任务在本仓库提交(`9352d0c`、`cf3e638`、`0998009`)并短暂留下未提交的 studio 改动,`gate-20261018T122730Z` 的 lint 行因此判失败——那一行是**文档门禁**报出外部改动里的文档问题,代码部分是 `no findings`;同时暴露 `lint_gate.py`/`ui_check.py` 在 cp1252 控制台打印非 ASCII finding 会抛 `UnicodeEncodeError`(两处都在本任务 allowed_files 之外,未改)。② 最终验收运行前确认工作区只剩本任务 8 个改动。③ `origin/main` 落后于本地 9+ 提交,默认基准覆盖全部未推送提交。
- 后台进程及 scratch 使用状态:无后台进程残留;`.scratch/test-repair/.active` 与 `round3/.active` 均不存在;`round3/` 保留 8 次 gate 运行目录与运行日志。
### 验收与下一步 ### 验收与下一步
- 轮次摘要:第 1 轮定义计划;第 2 轮 DeepSeek 完成阶段 0–6,提交为 `433b76e`,报告在 `.scratch/test-repair/final-report.md`,完整记录见该提交中的原计划;第 3 轮验收判返工,原因见第 1 节 - 轮次摘要:第 1 轮定义计划;第 2 轮 DeepSeek 完成阶段 0–6,提交为 `433b76e`,报告在 `.scratch/test-repair/final-report.md`,完整记录见该提交中的原计划;第 3 轮验收判返工,原因见第 1 节;第 3 轮 DeepSeek 完成 R1–R4,最终 `gate.ps1` PASS
- 验收者、时间及复核时的代码/输入身份:Claude,2026-10-18 19:40 +08:00,`433b76e` - 验收者、时间及复核时的代码/输入身份:待 Claude 复核;本次交付的代码身份为工作区未提交状态(HEAD `0998009` + 上表 8 个文件)
- 独立检查与结果:只读审查 38 个文件的实际差异,以及 `lint_gate.py`、`overlay_base.py` 的相关代码。**未独立复跑** `gate.ps1` 与两套测试,因为验收方当时只有 Linux shell,没有该 Windows 解释器;第 2 轮自报的测试数字仍属未验证,第 3 轮验收时复跑 - 独立检查与结果:执行方自跑 8 次 `gate.ps1`(含 3 次临时场景运行)与定向 pytest;验收方需独立复跑最终 `gate.ps1` 与两处红证据对应的命令
- 决定:返工;依据原计划第 4 节阶段 4(门禁须检查改动文件)与第 2 节(不扩大到 box 以外的行为变化) - 决定:待验收
- 下一步:用户提交本文件后,在 DeepSeek 会话中发出启动指令;完成后标 review,交回 Claude 验收 - 下一步:Claude 验收;若接受,再由用户决定提交(执行方未暂存、未提交)
@@ -22,11 +22,23 @@ import numpy as np
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = ["OverlayBaseProjector"] __all__ = ["OverlayBaseProjector", "projection_failures_are_permanent"]
_CWD_LOCK = threading.Lock() _CWD_LOCK = threading.Lock()
def projection_failures_are_permanent(sensor_type: str) -> bool:
"""Whether a failed projection for this sensor is worth retrying on the next frame.
Only the finger unroll has a per-frame remap, so one miss is that frame's problem. A
box stitch failure is a property of the footage — the lenslet grid either converges
on these pixels or it does not — so retrying it per cursor move rebuilds the same
failure. This is the one place the policy is stated; both openers read it, and the
analysis session reads the projector's declaration instead of deciding again.
"""
return str(sensor_type) != "finger"
def _ensure_bgr(image: np.ndarray) -> np.ndarray: def _ensure_bgr(image: np.ndarray) -> np.ndarray:
if image.ndim == 2: if image.ndim == 2:
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
@@ -38,7 +50,15 @@ def _ensure_bgr(image: np.ndarray) -> np.ndarray:
class OverlayBaseProjector: class OverlayBaseProjector:
"""Maps one camera frame onto the measurement domain for overlay views.""" """Maps one camera frame onto the measurement domain for overlay views.
``projection_failures_are_permanent`` says what a failed projection *means*, because
only the projector knows: a finger unroll can lose one frame (a transient remap
miss) and be fine on the next, while a box stitch that cannot rebuild the lenslet
grid will not start working mid-session. The consumer cannot tell from the returned
``None``, and must not try to infer it from an exception's text — it asks this
declaration instead (see ``AnalysisSession.overlay_base``).
"""
def __init__( def __init__(
self, self,
@@ -47,11 +67,14 @@ class OverlayBaseProjector:
project, project,
close=None, close=None,
working: Path | None = None, working: Path | None = None,
projection_failures_are_permanent: bool = True,
) -> None: ) -> None:
self.sensor_type = str(sensor_type) self.sensor_type = str(sensor_type)
self._project = project self._project = project
self._close = close self._close = close
self._working = None if working is None else Path(working) self._working = None if working is None else Path(working)
#: Default True: a projector only gets to be "retry next frame" if it says so.
self.projection_failures_are_permanent = bool(projection_failures_are_permanent)
self._cache_id: int | None = None self._cache_id: int | None = None
self._cache: np.ndarray | None = None self._cache: np.ndarray | None = None
@@ -124,7 +147,13 @@ class OverlayBaseProjector:
return None return None
return _ensure_bgr(unrolled) return _ensure_bgr(unrolled)
return cls(sensor_type="finger", project=project) # One frame the unroll cannot map is that frame's problem: the next camera frame
# has its own remap, so the session keeps offering the overlay (reported as R2).
return cls(
sensor_type="finger",
project=project,
projection_failures_are_permanent=projection_failures_are_permanent("finger"),
)
@classmethod @classmethod
def _open_box(cls, *, config_ref: str | None) -> OverlayBaseProjector | None: def _open_box(cls, *, config_ref: str | None) -> OverlayBaseProjector | None:
@@ -163,7 +192,15 @@ class OverlayBaseProjector:
logger.debug("box overlay sensor disconnect skipped", exc_info=True) logger.debug("box overlay sensor disconnect skipped", exc_info=True)
shutil.rmtree(working, ignore_errors=True) shutil.rmtree(working, ignore_errors=True)
return cls(sensor_type="box", project=project, close=close, working=working) # A box stitch that fails once fails for this footage: the lenslet grid is a
# property of the recording, so retrying per scrub rebuilds the same failure.
return cls(
sensor_type="box",
project=project,
close=close,
working=working,
projection_failures_are_permanent=projection_failures_are_permanent("box"),
)
def project( def project(
self, video_bgr: np.ndarray, *, frame_id: int | None = None self, video_bgr: np.ndarray, *, frame_id: int | None = None
+292 -208
View File
@@ -21,7 +21,9 @@
how that shows up as a failure instead of a faster green run. how that shows up as a failure instead of a faster green run.
Every command's output goes to <repo>/.scratch/<Task>/gate-<run-id>/, and the Every command's output goes to <repo>/.scratch/<Task>/gate-<run-id>/, and the
one-page summary is both printed and written there as summary.md. one-page summary is both printed and written there as summary.md. The scratch
marker <repo>/.scratch/<Task>/.active is removed in a finally block, so an
interrupted or failed run does not leave the task looking busy.
Platform: **Windows**. The suite renders real Qt widgets offscreen and two tests Platform: **Windows**. The suite renders real Qt widgets offscreen and two tests
are platform-sensitive (see docs/test-repair-plan.zh.md §3 #11 and #13); Linux are platform-sensitive (see docs/test-repair-plan.zh.md §3 #11 and #13); Linux
@@ -33,12 +35,21 @@
.PARAMETER Python .PARAMETER Python
The interpreter to run the suites with. Defaults to .venv-build/Scripts/python.exe. The interpreter to run the suites with. Defaults to .venv-build/Scripts/python.exe.
.PARAMETER LintBase
The commit to lint against, passed to tools/lint_gate.py as ``--base``. Defaults to
``origin/main``, so the pre-push hook checks every commit about to be pushed, not
only the dirty working tree. An invalid ref is a configuration error (exit 2); it
never falls back silently to "changed files only".
.PARAMETER SkipLint .PARAMETER SkipLint
Run only the suites and the UI check (used when diagnosing a red gate). Run only the suites and the UI check (used when diagnosing a red gate).
.EXAMPLE .EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 -LintBase HEAD~1
.NOTES .NOTES
Exit codes: 0 gate passed, 1 gate failed, 2 environment or configuration problem. Exit codes: 0 gate passed, 1 gate failed, 2 environment or configuration problem.
#> #>
@@ -46,6 +57,7 @@
param( param(
[string]$Task = 'gate', [string]$Task = 'gate',
[string]$Python = '', [string]$Python = '',
[string]$LintBase = 'origin/main',
[switch]$SkipLint [switch]$SkipLint
) )
@@ -58,7 +70,7 @@ function Write-Step([string]$Text) {
Write-Host "== $Text" -ForegroundColor Cyan Write-Host "== $Text" -ForegroundColor Cyan
} }
function Invoke-Native([string]$Exe, [string[]]$Arguments, [string]$LogPath) { function Invoke-Native([string]$Exe, [string[]]$Arguments, [string]$LogPath, [switch]$Quiet) {
# Every tool here writes progress and Qt diagnostics to stderr, and Windows # Every tool here writes progress and Qt diagnostics to stderr, and Windows
# PowerShell turns a native command's stderr into error records: with # PowerShell turns a native command's stderr into error records: with
# ``$ErrorActionPreference = 'Stop'`` a Qt warning ("This plugin does not support # ``$ErrorActionPreference = 'Stop'`` a Qt warning ("This plugin does not support
@@ -66,7 +78,11 @@ function Invoke-Native([string]$Exe, [string[]]$Arguments, [string]$LogPath) {
$previous = $ErrorActionPreference $previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Continue'
try { try {
& $Exe @Arguments 2>&1 | Tee-Object -FilePath $LogPath | Out-Host if ($Quiet) {
& $Exe @Arguments 2>&1 | Tee-Object -FilePath $LogPath | Out-Null
} else {
& $Exe @Arguments 2>&1 | Tee-Object -FilePath $LogPath | Out-Host
}
return $LASTEXITCODE return $LASTEXITCODE
} finally { } finally {
$ErrorActionPreference = $previous $ErrorActionPreference = $previous
@@ -82,216 +98,284 @@ if (-not (Test-Path -LiteralPath $Python)) {
exit 2 exit 2
} }
$RunId = Get-Date -Format 'yyyyMMddTHHmmssZ' # R1: the lint scope is a commit range, not "whatever happens to be dirty". A pre-push
# gate exists to check the commits being pushed, and a base that does not resolve is a
# configuration problem — silently linting only the working tree is how the change that
# reaches main goes unchecked.
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
git rev-parse --verify --quiet $LintBase *> $null
$baseExit = $LASTEXITCODE
} finally {
$ErrorActionPreference = $previous
}
if ($baseExit -ne 0) {
Write-Host "gate: -LintBase '$LintBase' is not a valid git ref; refusing to lint only the working tree" -ForegroundColor Red
exit 2
}
# UTC: the run id is read beside logs written by other machines and by CI, so it must not
# depend on the local time zone.
$RunId = (Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ')
$TaskDir = Join-Path $Repo (Join-Path '.scratch' $Task) $TaskDir = Join-Path $Repo (Join-Path '.scratch' $Task)
$Scratch = Join-Path $TaskDir "gate-$RunId" $Scratch = Join-Path $TaskDir "gate-$RunId"
New-Item -ItemType Directory -Force -Path $Scratch | Out-Null New-Item -ItemType Directory -Force -Path $Scratch | Out-Null
# AGENTS.md rule 1: the scratch directory is marked while a task is using it. # AGENTS.md rule 1: the scratch directory is marked while a task is using it. Removed in
Set-Content -LiteralPath (Join-Path $TaskDir '.active') -Value "gate $RunId" # the finally block below, so a failed or interrupted gate leaves no stale marker.
$ActiveMarker = Join-Path $TaskDir '.active'
$KnownFailures = Join-Path $Repo 'tools\known_failures.txt' Set-Content -LiteralPath $ActiveMarker -Value "gate $RunId"
$BudgetFile = Join-Path $Repo 'tools\skip_budget.txt'
$Results = New-Object System.Collections.ArrayList
function Add-Result([string]$Name, [string]$Status, [string]$Detail) {
[void]$Results.Add([pscustomobject]@{ Name = $Name; Status = $Status; Detail = $Detail })
}
# ------------------------------------------------------------------ budgets
function Read-Budget([string]$Path) {
$values = @{}
if (-not (Test-Path -LiteralPath $Path)) {
return $values
}
foreach ($line in Get-Content -LiteralPath $Path) {
$text = $line.Trim()
if ($text -eq '' -or $text.StartsWith('#')) { continue }
if ($text -notmatch '^([A-Za-z_]+)\s*=\s*(\d+)$') {
Write-Host "gate: unreadable line in ${Path}: $text" -ForegroundColor Red
throw "bad budget line: $text"
}
$values[$Matches[1]] = [int]$Matches[2]
}
return $values
}
# ------------------------------------------------------------------ known failures
Write-Step 'known failures'
$KnownStatus = 'passed'
$KnownDetail = 'tools/known_failures.txt is empty'
if (-not (Test-Path -LiteralPath $KnownFailures)) {
$KnownStatus = 'failed'
$KnownDetail = 'tools/known_failures.txt is missing'
} else {
$known = (Get-Content -LiteralPath $KnownFailures -Raw)
if ($null -eq $known) { $known = '' }
if ($known.Trim() -ne '') {
$KnownStatus = 'failed'
$KnownDetail = 'tools/known_failures.txt is not empty: ' + ($known.Trim() -replace '\r?\n', ' | ')
}
}
Add-Result 'known failures' $KnownStatus $KnownDetail
if ($KnownStatus -ne 'passed') {
Write-Host $KnownDetail -ForegroundColor Red
} else {
Write-Host $KnownDetail
}
# ------------------------------------------------------------------ lint
if (-not $SkipLint) {
Write-Step 'lint (changed files)'
$lintLog = Join-Path $Scratch 'lint.log'
$lintExit = Invoke-Native $Python @((Join-Path $Repo 'tools\lint_gate.py')) $lintLog
if ($lintExit -eq 0) {
Add-Result 'lint' 'passed' 'no findings'
} elseif ($lintExit -eq 1) {
Add-Result 'lint' 'failed' 'findings in the changed files (see lint.log)'
} else {
Add-Result 'lint' 'failed' "lint_gate.py exited $lintExit (environment problem, e.g. ruff missing)"
}
}
# ------------------------------------------------------------------ test suites
function Invoke-Suite([string]$Name, [string]$Path) {
$safe = $Name.Replace(' ', '-')
$log = Join-Path $Scratch "$safe.log"
$xml = Join-Path $Scratch "$safe.xml"
$base = Join-Path $Scratch "pytest-$safe"
Write-Step "$Name"
$exit = Invoke-Native $Python @(
'-m', 'pytest', $Path,
'-m', 'not timing',
'-p', 'no:cacheprovider',
"--basetemp=$base",
"--junitxml=$xml"
) $log
$counts = [pscustomobject]@{
Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $exit; Xml = (Test-Path -LiteralPath $xml)
}
if ($counts.Xml) {
try {
[xml]$doc = Get-Content -LiteralPath $xml
$suites = @($doc.testsuites.testsuite)
foreach ($suite in $suites) {
$counts.Tests += [int]$suite.tests
$counts.Failures += [int]$suite.failures
$counts.Errors += [int]$suite.errors
$counts.Skipped += [int]$suite.skipped
}
} catch {
$counts.Xml = $false
}
}
return $counts
}
function Test-Suite([string]$Name, $Counts, $Budget, [string]$MinKey, [string]$MaxKey) {
# JUnit is authoritative for the result: the interpreter has been observed to crash
# in Qt teardown *after* reporting every test (exit 0xC0000005 with "23 passed"), and
# a crash that happens after the last assertion is not a failed assertion. The exit
# code is still recorded, and a disagreement is called out.
if (-not $Counts.Xml) {
Add-Result $Name 'failed' "no JUnit report (pytest exit $($Counts.Exit))"
return
}
$detail = "$($Counts.Tests) tests, $($Counts.Failures) failed, $($Counts.Errors) errors, $($Counts.Skipped) skipped"
if ($Counts.Failures -ne 0 -or $Counts.Errors -ne 0) {
Add-Result $Name 'failed' $detail
return
}
$note = $detail
$ok = $true
if ($Counts.Exit -ne 0) {
$note = "$detail; process exit $($Counts.Exit) (Qt teardown after the summary?)"
}
if ($Budget.ContainsKey($MinKey)) {
$floor = [int]$Budget[$MinKey]
if ($Counts.Tests -lt $floor) {
$ok = $false
$note = "$detail; only $($Counts.Tests) tests collected, floor is $floor"
}
}
if ($Budget.ContainsKey($MaxKey)) {
$ceiling = [int]$Budget[$MaxKey]
if ($Counts.Skipped -gt $ceiling) {
$ok = $false
$note = "$detail; more than the $ceiling skips allowed"
}
}
if ($ok) {
Add-Result $Name 'passed' $note
} else {
Add-Result $Name 'failed' $note
}
}
try { try {
$budget = Read-Budget $BudgetFile $KnownFailures = Join-Path $Repo 'tools\known_failures.txt'
} catch { $BudgetFile = Join-Path $Repo 'tools\skip_budget.txt'
Write-Host "gate: $($_.Exception.Message)" -ForegroundColor Red
exit 2
}
if ($budget.Count -eq 0) {
Write-Host "gate: no budgets read from $BudgetFile (floors and skip ceilings are part of the gate)" -ForegroundColor Red
exit 2
}
Write-Step 'studio suite' $Results = New-Object System.Collections.ArrayList
$studio = Invoke-Suite 'studio suite' 'apps/studio/tests' function Add-Result([string]$Name, [string]$Status, [string]$Detail) {
Test-Suite 'studio suite' $studio $budget 'studio_min_tests' 'studio_max_skips' [void]$Results.Add([pscustomobject]@{ Name = $Name; Status = $Status; Detail = $Detail })
}
Write-Step 'shared suite'
$shared = Invoke-Suite 'shared suite' 'shared/tests' # -------------------------------------------------------------- budgets
Test-Suite 'shared suite' $shared $budget 'shared_min_tests' 'shared_max_skips' function Read-Budget([string]$Path) {
$values = @{}
# ------------------------------------------------------------------ ui_check if (-not (Test-Path -LiteralPath $Path)) {
Write-Step 'ui_check' return $values
$uiLog = Join-Path $Scratch 'ui_check.log' }
$uiExit = Invoke-Native $Python @((Join-Path $Repo 'apps\studio\tools\ui_check.py')) $uiLog foreach ($line in Get-Content -LiteralPath $Path) {
# ui_check prints "scenes: 4 checks: 631 failures: 2"; the last number is the verdict. $text = $line.Trim()
$uiSummary = Select-String -LiteralPath $uiLog -Pattern 'checks:\s*\d+.*failures:\s*(\d+)' if ($text -eq '' -or $text.StartsWith('#')) { continue }
if ($uiSummary) { if ($text -notmatch '^([A-Za-z_]+)\s*=\s*(\d+)$') {
$match = $uiSummary[-1].Matches[0] Write-Host "gate: unreadable line in ${Path}: $text" -ForegroundColor Red
$failures = [int]$match.Groups[1].Value throw "bad budget line: $text"
$line = $uiSummary[-1].Line.Trim() }
if ($failures -eq 0) { $values[$Matches[1]] = [int]$Matches[2]
Add-Result 'ui_check' 'passed' $line }
} else { return $values
Add-Result 'ui_check' 'failed' $line }
# -------------------------------------------------------------- known failures
Write-Step 'known failures'
$KnownStatus = 'passed'
$KnownDetail = 'tools/known_failures.txt is empty'
if (-not (Test-Path -LiteralPath $KnownFailures)) {
$KnownStatus = 'failed'
$KnownDetail = 'tools/known_failures.txt is missing'
} else {
$known = (Get-Content -LiteralPath $KnownFailures -Raw)
if ($null -eq $known) { $known = '' }
if ($known.Trim() -ne '') {
$KnownStatus = 'failed'
$KnownDetail = 'tools/known_failures.txt is not empty: ' + ($known.Trim() -replace '\r?\n', ' | ')
}
}
Add-Result 'known failures' $KnownStatus $KnownDetail
if ($KnownStatus -ne 'passed') {
Write-Host $KnownDetail -ForegroundColor Red
} else {
Write-Host $KnownDetail
}
# -------------------------------------------------------------- lint
if (-not $SkipLint) {
Write-Step "lint (changed files since $LintBase)"
$lintLog = Join-Path $Scratch 'lint.log'
$lintExit = Invoke-Native $Python @(
(Join-Path $Repo 'tools\lint_gate.py'), '--base', $LintBase
) $lintLog
# Name the files the range covered, in the same log a reviewer reads. The list is
# not re-derived here — the gate must not keep a second copy of lint_gate's
# selection rule — it is read from lint_gate's own --json answer for the same
# range, in a quiet second pass (ruff over a handful of files).
$lintJson = Join-Path $Scratch 'lint.json'
$null = Invoke-Native $Python @(
(Join-Path $Repo 'tools\lint_gate.py'), '--base', $LintBase, '--json'
) $lintJson -Quiet
$checkedPaths = @()
try {
$payload = (Get-Content -LiteralPath $lintJson -Raw) | ConvertFrom-Json
if ($payload -and $payload.checked) { $checkedPaths = @($payload.checked) }
} catch {
$checkedPaths = @()
}
if ($checkedPaths.Count -gt 0) {
Add-Content -LiteralPath $lintLog -Value ''
Add-Content -LiteralPath $lintLog -Value " files checked against $LintBase ($($checkedPaths.Count)):"
foreach ($lintedPath in $checkedPaths) {
Add-Content -LiteralPath $lintLog -Value " $lintedPath"
}
}
# lint_gate prints "checked N target(s)"; the summary repeats N so the range a
# green lint actually covered is on the one page an operator reads.
$checked = $checkedPaths.Count
$scope = "base $LintBase, $checked file(s) checked"
if ($lintExit -eq 0) {
Add-Result 'lint' 'passed' "no findings ($scope)"
} elseif ($lintExit -eq 1) {
Add-Result 'lint' 'failed' "findings in the changed files ($scope; see lint.log)"
} else {
Add-Result 'lint' 'failed' "lint_gate.py exited $lintExit (environment problem, e.g. ruff missing; $scope)"
}
}
# -------------------------------------------------------------- test suites
function Invoke-Suite([string]$Name, [string]$Path) {
$safe = $Name.Replace(' ', '-')
$log = Join-Path $Scratch "$safe.log"
$xml = Join-Path $Scratch "$safe.xml"
$base = Join-Path $Scratch "pytest-$safe"
Write-Step "$Name"
$exit = Invoke-Native $Python @(
'-m', 'pytest', $Path,
'-m', 'not timing',
'-p', 'no:cacheprovider',
"--basetemp=$base",
"--junitxml=$xml"
) $log
$counts = [pscustomobject]@{
Tests = 0; Failures = 0; Errors = 0; Skipped = 0; Exit = $exit; Xml = (Test-Path -LiteralPath $xml)
}
if ($counts.Xml) {
try {
[xml]$doc = Get-Content -LiteralPath $xml
$suites = @($doc.testsuites.testsuite)
foreach ($suite in $suites) {
$counts.Tests += [int]$suite.tests
$counts.Failures += [int]$suite.failures
$counts.Errors += [int]$suite.errors
$counts.Skipped += [int]$suite.skipped
}
} catch {
$counts.Xml = $false
}
}
return $counts
}
#: An access violation while Qt tears the window down after the last assertion has
#: been seen (exit 0xC0000005 as a signed int). It is allowed and reported; every
#: other non-zero exit from a suite with a clean JUnit report is a gate failure.
$TeardownCrash = -1073741819
function Test-Suite([string]$Name, $Counts, $Budget, [string]$MinKey, [string]$MaxKey) {
# JUnit is authoritative for which assertions failed: the interpreter has been
# observed to crash in Qt teardown *after* reporting every test. The exit code is
# still checked, because a suite that died for another reason has not passed.
if (-not $Counts.Xml) {
Add-Result $Name 'failed' "no JUnit report (pytest exit $($Counts.Exit))"
return
}
$detail = "$($Counts.Tests) tests, $($Counts.Failures) failed, $($Counts.Errors) errors, $($Counts.Skipped) skipped"
if ($Counts.Failures -ne 0 -or $Counts.Errors -ne 0) {
Add-Result $Name 'failed' $detail
return
}
$note = $detail
if ($Counts.Exit -ne 0) {
if ($Counts.Exit -ne $TeardownCrash) {
Add-Result $Name 'failed' "$detail; process exit $($Counts.Exit) is not the allowed teardown crash"
return
}
$note = "$detail; process exit 0xC0000005 after the summary (Qt teardown, allowed)"
}
$ok = $true
if ($Budget.ContainsKey($MinKey)) {
$floor = [int]$Budget[$MinKey]
if ($Counts.Tests -lt $floor) {
$ok = $false
$note = "$detail; only $($Counts.Tests) tests collected, floor is $floor"
}
}
if ($Budget.ContainsKey($MaxKey)) {
$ceiling = [int]$Budget[$MaxKey]
if ($Counts.Skipped -gt $ceiling) {
$ok = $false
$note = "$detail; more than the $ceiling skips allowed"
}
}
if ($ok) {
Add-Result $Name 'passed' $note
} else {
Add-Result $Name 'failed' $note
}
}
try {
$budget = Read-Budget $BudgetFile
} catch {
Write-Host "gate: $($_.Exception.Message)" -ForegroundColor Red
exit 2
}
if ($budget.Count -eq 0) {
Write-Host "gate: no budgets read from $BudgetFile (floors and skip ceilings are part of the gate)" -ForegroundColor Red
exit 2
}
Write-Step 'studio suite'
$studio = Invoke-Suite 'studio suite' 'apps/studio/tests'
Test-Suite 'studio suite' $studio $budget 'studio_min_tests' 'studio_max_skips'
Write-Step 'shared suite'
$shared = Invoke-Suite 'shared suite' 'shared/tests'
Test-Suite 'shared suite' $shared $budget 'shared_min_tests' 'shared_max_skips'
# -------------------------------------------------------------- ui_check
Write-Step 'ui_check'
$uiLog = Join-Path $Scratch 'ui_check.log'
$uiExit = Invoke-Native $Python @((Join-Path $Repo 'apps\studio\tools\ui_check.py')) $uiLog
# ui_check prints "scenes: 4 checks: 631 failures: 2"; the last number is the
# verdict. Its exit code is also the verdict: a script that dies partway through
# (missing worker signature, an import error) must not pass on a stale summary line.
$uiSummary = Select-String -LiteralPath $uiLog -Pattern 'checks:\s*\d+.*failures:\s*(\d+)'
if ($uiExit -ne 0) {
Add-Result 'ui_check' 'failed' "ui_check.py exited $uiExit"
} elseif ($uiSummary) {
$match = $uiSummary[-1].Matches[0]
$failures = [int]$match.Groups[1].Value
$line = $uiSummary[-1].Line.Trim()
if ($failures -eq 0) {
Add-Result 'ui_check' 'passed' $line
} else {
Add-Result 'ui_check' 'failed' $line
}
} else {
Add-Result 'ui_check' 'failed' "no 'checks: N ... failures: M' summary (exit $uiExit)"
}
# -------------------------------------------------------------- summary
$failed = @($Results | Where-Object { $_.Status -ne 'passed' })
$verdict = if ($failed.Count -eq 0) { 'PASS' } else { 'FAIL' }
Write-Step "summary: $verdict"
foreach ($result in $Results) {
$colour = if ($result.Status -eq 'passed') { 'Green' } else { 'Red' }
Write-Host (" {0,-16} {1,-7} {2}" -f $result.Name, $result.Status, $result.Detail) -ForegroundColor $colour
}
$lines = New-Object System.Collections.ArrayList
[void]$lines.Add("# Gate $RunId : $verdict")
[void]$lines.Add('')
[void]$lines.Add("Platform : Windows")
[void]$lines.Add("Python : $Python")
[void]$lines.Add("LintBase : $LintBase")
[void]$lines.Add("Scratch : $Scratch")
[void]$lines.Add('')
[void]$lines.Add('| Check | Result | Detail |')
[void]$lines.Add('|---|---|---|')
foreach ($result in $Results) {
[void]$lines.Add("| $($result.Name) | $($result.Status) | $($result.Detail) |")
}
[void]$lines.Add('')
[void]$lines.Add('Logs: lint.log, studio-suite.log, shared-suite.log, ui_check.log, and the JUnit XMLs, in this directory.')
$summaryPath = Join-Path $Scratch 'summary.md'
Set-Content -LiteralPath $summaryPath -Value $lines -Encoding UTF8
Write-Host ''
Write-Host "gate: $verdict (summary: $summaryPath)"
if ($failed.Count -eq 0) { exit 0 }
exit 1
} finally {
if (Test-Path -LiteralPath $ActiveMarker) {
Remove-Item -LiteralPath $ActiveMarker -Force -ErrorAction SilentlyContinue
} }
} else {
Add-Result 'ui_check' 'failed' "no 'checks: N ... failures: M' summary (exit $uiExit)"
} }
# ------------------------------------------------------------------ summary
$failed = @($Results | Where-Object { $_.Status -ne 'passed' })
$verdict = if ($failed.Count -eq 0) { 'PASS' } else { 'FAIL' }
Write-Step "summary: $verdict"
foreach ($result in $Results) {
$colour = if ($result.Status -eq 'passed') { 'Green' } else { 'Red' }
Write-Host (" {0,-16} {1,-7} {2}" -f $result.Name, $result.Status, $result.Detail) -ForegroundColor $colour
}
$lines = New-Object System.Collections.ArrayList
[void]$lines.Add("# Gate $RunId : $verdict")
[void]$lines.Add('')
[void]$lines.Add("Platform : Windows")
[void]$lines.Add("Python : $Python")
[void]$lines.Add("Scratch : $Scratch")
[void]$lines.Add('')
[void]$lines.Add('| Check | Result | Detail |')
[void]$lines.Add('|---|---|---|')
foreach ($result in $Results) {
[void]$lines.Add("| $($result.Name) | $($result.Status) | $($result.Detail) |")
}
[void]$lines.Add('')
[void]$lines.Add('Logs: lint.log, studio-suite.log, shared-suite.log, ui_check.log, and the JUnit XMLs, in this directory.')
$summaryPath = Join-Path $Scratch 'summary.md'
Set-Content -LiteralPath $summaryPath -Value $lines -Encoding UTF8
Write-Host ''
Write-Host "gate: $verdict (summary: $summaryPath)"
if ($failed.Count -eq 0) { exit 0 }
exit 1