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:
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -389,8 +389,13 @@ class AnalysisSession:
|
||||
"""The live-shaped processed image for this camera frame, or ``None``.
|
||||
|
||||
Opens a session-scoped projector from the result's provenance on first use.
|
||||
Failure is sticky for the session: overlay views then report capture
|
||||
unavailable rather than falling back to a resized fisheye.
|
||||
**How long a failure lasts is the projector's answer, not this method's guess:**
|
||||
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:
|
||||
return None
|
||||
@@ -401,16 +406,11 @@ class AnalysisSession:
|
||||
self.reader.provenance
|
||||
)
|
||||
if self._overlay is None:
|
||||
# Nothing can be opened for this result at all: permanent either way.
|
||||
self._overlay_failed = True
|
||||
return None
|
||||
projected = self._overlay.project(video_bgr, frame_id=frame_id)
|
||||
if projected is None:
|
||||
# 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.
|
||||
if projected is None and self._overlay.projection_failures_are_permanent:
|
||||
self._overlay_failed = True
|
||||
return projected
|
||||
|
||||
@@ -420,12 +420,12 @@ class AnalysisSession:
|
||||
|
||||
The overlay views (``arrows_stitched``, ``depth_arrows``) paint on the same
|
||||
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**
|
||||
result has no stored unroll map, and the box stitch rebuild can fail on real
|
||||
footage, so the answer is only known after one attempt — and it is then sticky
|
||||
for the session (see :meth:`overlay_base`). The caller that *picks* the first
|
||||
view for an adopted result reads this, so the operator is not left on a view
|
||||
that cannot be drawn.
|
||||
contain: it is rebuilt from the result's provenance on first use. The answer is
|
||||
only known after an attempt, and it is sticky **only for a failure the projector
|
||||
declares permanent** — a box result whose stitch cannot rebuild, or a projector
|
||||
that would not open at all. A finger unroll that loses one frame is not sticky
|
||||
(see :meth:`overlay_base`). The caller that *picks* the first view for an adopted
|
||||
result reads this, so the operator is not left on a view that cannot be drawn.
|
||||
"""
|
||||
return bool(self._overlay_failed)
|
||||
|
||||
|
||||
@@ -1034,13 +1034,21 @@ class ReplayController(QObject):
|
||||
texture live uses. A derived selection the operator already chose is left
|
||||
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
|
||||
result's provenance, which a box result cannot always do (no stored unroll map,
|
||||
and the box stitch rebuild can fail on real footage: measured, every frame of the
|
||||
restored 33-frame box recording). Offering the overlay there would leave the
|
||||
operator on a blank panel reading "capture unavailable" while the pure-colour
|
||||
arrows sit one step away, so the fallback is the drawable view — the overlay is
|
||||
still in the selector, and choosing it still says why it cannot be drawn.
|
||||
**But only a view this result can actually draw, decided before it is set.** The
|
||||
base is rebuilt from the result's provenance, which a box result cannot always do
|
||||
(no stored unroll map, and the box stitch rebuild can fail on real footage:
|
||||
measured, every frame of the restored 33-frame box recording). Offering the
|
||||
overlay there would leave the operator on a blank panel reading "capture
|
||||
unavailable" while the pure-colour arrows sit one step away, so the fallback is
|
||||
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 "")
|
||||
if current in DERIVED_MODES:
|
||||
@@ -1048,16 +1056,29 @@ class ReplayController(QObject):
|
||||
session = self.available_result()
|
||||
# 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.
|
||||
overlay_failed = bool(getattr(session, "overlay_unavailable", False))
|
||||
self._image_panel.set_view_mode(ARROW_PURE if overlay_failed else ARROW_STITCHED)
|
||||
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)
|
||||
drawable = True if session is None else self._overlay_base_ready(session)
|
||||
self._image_panel.set_view_mode(ARROW_STITCHED if drawable else ARROW_PURE)
|
||||
self.render_frame()
|
||||
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:
|
||||
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
|
||||
|
||||
|
||||
@@ -11,15 +11,22 @@ generated pixels, and a synthetic 896² clip sent the chain into minutes of work
|
||||
| | |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| 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) |
|
||||
|
||||
`dist/` is build output and is not tracked, so the copy under it 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.
|
||||
`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.
|
||||
|
||||
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:
|
||||
|
||||
| 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
|
||||
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
|
||||
@@ -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 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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` 的权威任务记录,原计划不再维护状态。
|
||||
|
||||
## 1. 为什么返工
|
||||
@@ -43,8 +43,8 @@ R2 先于 R3;全部完成后以最终状态跑一次 `gate.ps1`。总预算 5
|
||||
|
||||
按 [代理交接约定](../../docs/platform/agent-handoff.zh.md) 维护;只保留当前状态和每轮一行摘要。
|
||||
|
||||
- task_id:test-repair;round:3;updated_at:2026-10-18 19:55 +08:00
|
||||
- status:rework;owner:Claude(定义与验收);next_agent:DeepSeek(执行)
|
||||
- task_id:test-repair;round:3;updated_at:2026-10-18 21:20 +08:00
|
||||
- status:review;owner:DeepSeek(执行);next_agent:Claude(验收)
|
||||
- 目标与完成条件:第 3 节 R1–R4 的验收列全部满足,最终 `gate.ps1` 退出码 0
|
||||
- 范围: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`、
|
||||
@@ -59,24 +59,53 @@ R2 先于 R3;全部完成后以最终状态跑一次 `gate.ps1`。总预算 5
|
||||
|
||||
| 仓库/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
|
||||
- 输入:`apps/studio/tests/data/box_short_20260930/`,须与 `433b76e` 一致(`git diff 433b76e -- apps/studio/tests/data` 为空)
|
||||
本轮修改(SHA256 为收尾实测值,供写入核对):
|
||||
|
||||
| 文件 | 字节 | 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 | 实际结果 | 证据路径 |
|
||||
|---|---|---|---|
|
||||
| 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` |
|
||||
|
||||
- 已完成、失败样本、剩余限制:
|
||||
- 后台进程及 scratch 使用状态:
|
||||
- 已完成: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` 加载,不是仓库文件。
|
||||
- 失败样本:第 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 节
|
||||
- 验收者、时间及复核时的代码/输入身份:Claude,2026-10-18 19:40 +08:00,`433b76e`
|
||||
- 独立检查与结果:只读审查 38 个文件的实际差异,以及 `lint_gate.py`、`overlay_base.py` 的相关代码。**未独立复跑** `gate.ps1` 与两套测试,因为验收方当时只有 Linux shell,没有该 Windows 解释器;第 2 轮自报的测试数字仍属未验证,第 3 轮验收时复跑
|
||||
- 决定:返工;依据原计划第 4 节阶段 4(门禁须检查改动文件)与第 2 节(不扩大到 box 以外的行为变化)
|
||||
- 下一步:用户提交本文件后,在 DeepSeek 会话中发出启动指令;完成后标 review,交回 Claude 验收
|
||||
- 轮次摘要:第 1 轮定义计划;第 2 轮 DeepSeek 完成阶段 0–6,提交为 `433b76e`,报告在 `.scratch/test-repair/final-report.md`,完整记录见该提交中的原计划;第 3 轮验收判返工,原因见第 1 节;第 3 轮 DeepSeek 完成 R1–R4,最终 `gate.ps1` PASS
|
||||
- 验收者、时间及复核时的代码/输入身份:待 Claude 复核;本次交付的代码身份为工作区未提交状态(HEAD `0998009` + 上表 8 个文件)
|
||||
- 独立检查与结果:执行方自跑 8 次 `gate.ps1`(含 3 次临时场景运行)与定向 pytest;验收方需独立复跑最终 `gate.ps1` 与两处红证据对应的命令
|
||||
- 决定:待验收
|
||||
- 下一步:Claude 验收;若接受,再由用户决定提交(执行方未暂存、未提交)
|
||||
|
||||
@@ -22,11 +22,23 @@ import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["OverlayBaseProjector"]
|
||||
__all__ = ["OverlayBaseProjector", "projection_failures_are_permanent"]
|
||||
|
||||
_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:
|
||||
if image.ndim == 2:
|
||||
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
@@ -38,7 +50,15 @@ def _ensure_bgr(image: np.ndarray) -> np.ndarray:
|
||||
|
||||
|
||||
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__(
|
||||
self,
|
||||
@@ -47,11 +67,14 @@ class OverlayBaseProjector:
|
||||
project,
|
||||
close=None,
|
||||
working: Path | None = None,
|
||||
projection_failures_are_permanent: bool = True,
|
||||
) -> None:
|
||||
self.sensor_type = str(sensor_type)
|
||||
self._project = project
|
||||
self._close = close
|
||||
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: np.ndarray | None = None
|
||||
|
||||
@@ -124,7 +147,13 @@ class OverlayBaseProjector:
|
||||
return None
|
||||
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
|
||||
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)
|
||||
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(
|
||||
self, video_bgr: np.ndarray, *, frame_id: int | None = None
|
||||
|
||||
+179
-95
@@ -21,7 +21,9 @@
|
||||
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
|
||||
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
|
||||
are platform-sensitive (see docs/test-repair-plan.zh.md §3 #11 and #13); Linux
|
||||
@@ -33,12 +35,21 @@
|
||||
.PARAMETER Python
|
||||
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
|
||||
Run only the suites and the UI check (used when diagnosing a red gate).
|
||||
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1
|
||||
|
||||
.EXAMPLE
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 -LintBase HEAD~1
|
||||
|
||||
.NOTES
|
||||
Exit codes: 0 gate passed, 1 gate failed, 2 environment or configuration problem.
|
||||
#>
|
||||
@@ -46,6 +57,7 @@
|
||||
param(
|
||||
[string]$Task = 'gate',
|
||||
[string]$Python = '',
|
||||
[string]$LintBase = 'origin/main',
|
||||
[switch]$SkipLint
|
||||
)
|
||||
|
||||
@@ -58,7 +70,7 @@ function Write-Step([string]$Text) {
|
||||
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
|
||||
# PowerShell turns a native command's stderr into error records: with
|
||||
# ``$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
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
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
|
||||
} finally {
|
||||
$ErrorActionPreference = $previous
|
||||
@@ -82,23 +98,45 @@ if (-not (Test-Path -LiteralPath $Python)) {
|
||||
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)
|
||||
$Scratch = Join-Path $TaskDir "gate-$RunId"
|
||||
New-Item -ItemType Directory -Force -Path $Scratch | Out-Null
|
||||
# AGENTS.md rule 1: the scratch directory is marked while a task is using it.
|
||||
Set-Content -LiteralPath (Join-Path $TaskDir '.active') -Value "gate $RunId"
|
||||
# AGENTS.md rule 1: the scratch directory is marked while a task is using it. Removed in
|
||||
# the finally block below, so a failed or interrupted gate leaves no stale marker.
|
||||
$ActiveMarker = Join-Path $TaskDir '.active'
|
||||
Set-Content -LiteralPath $ActiveMarker -Value "gate $RunId"
|
||||
|
||||
$KnownFailures = Join-Path $Repo 'tools\known_failures.txt'
|
||||
$BudgetFile = Join-Path $Repo 'tools\skip_budget.txt'
|
||||
try {
|
||||
$KnownFailures = Join-Path $Repo 'tools\known_failures.txt'
|
||||
$BudgetFile = Join-Path $Repo 'tools\skip_budget.txt'
|
||||
|
||||
$Results = New-Object System.Collections.ArrayList
|
||||
function Add-Result([string]$Name, [string]$Status, [string]$Detail) {
|
||||
$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) {
|
||||
# -------------------------------------------------------------- budgets
|
||||
function Read-Budget([string]$Path) {
|
||||
$values = @{}
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
return $values
|
||||
@@ -113,46 +151,74 @@ function Read-Budget([string]$Path) {
|
||||
$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)) {
|
||||
# -------------------------------------------------------------- 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 {
|
||||
} 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)"
|
||||
}
|
||||
}
|
||||
Add-Result 'known failures' $KnownStatus $KnownDetail
|
||||
if ($KnownStatus -ne 'passed') {
|
||||
Write-Host $KnownDetail -ForegroundColor Red
|
||||
} else {
|
||||
Write-Host $KnownDetail
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ test suites
|
||||
function Invoke-Suite([string]$Name, [string]$Path) {
|
||||
# -------------------------------------------------------------- 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"
|
||||
@@ -184,13 +250,17 @@ function Invoke-Suite([string]$Name, [string]$Path) {
|
||||
}
|
||||
}
|
||||
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.
|
||||
#: 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
|
||||
@@ -201,10 +271,14 @@ function Test-Suite([string]$Name, $Counts, $Budget, [string]$MinKey, [string]$M
|
||||
return
|
||||
}
|
||||
$note = $detail
|
||||
$ok = $true
|
||||
if ($Counts.Exit -ne 0) {
|
||||
$note = "$detail; process exit $($Counts.Exit) (Qt teardown after the summary?)"
|
||||
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) {
|
||||
@@ -224,34 +298,38 @@ function Test-Suite([string]$Name, $Counts, $Budget, [string]$MinKey, [string]$M
|
||||
} else {
|
||||
Add-Result $Name 'failed' $note
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
$budget = Read-Budget $BudgetFile
|
||||
} catch {
|
||||
} catch {
|
||||
Write-Host "gate: $($_.Exception.Message)" -ForegroundColor Red
|
||||
exit 2
|
||||
}
|
||||
if ($budget.Count -eq 0) {
|
||||
}
|
||||
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 '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'
|
||||
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.
|
||||
$uiSummary = Select-String -LiteralPath $uiLog -Pattern 'checks:\s*\d+.*failures:\s*(\d+)'
|
||||
if ($uiSummary) {
|
||||
# -------------------------------------------------------------- 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()
|
||||
@@ -260,38 +338,44 @@ if ($uiSummary) {
|
||||
} else {
|
||||
Add-Result 'ui_check' 'failed' $line
|
||||
}
|
||||
} else {
|
||||
} 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' }
|
||||
# -------------------------------------------------------------- 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) {
|
||||
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) {
|
||||
$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)"
|
||||
}
|
||||
[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
|
||||
if ($failed.Count -eq 0) { exit 0 }
|
||||
exit 1
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $ActiveMarker) {
|
||||
Remove-Item -LiteralPath $ActiveMarker -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user