docs(mosaic): preserve rotated partial popup clipping evidence
This commit is contained in:
parent
f7a94eb5fb
commit
89567ba246
17 changed files with 1162 additions and 0 deletions
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
### STEP20C experimental evidence
|
||||
|
||||
- Local static-R90 partial-popup probe: a legal nested B popup crossed the Mosaic viewport by 72 logical pixels; 46,455 B pixels and a real button survived on H1, while H2 was pixel-identical to its reference and delivered no B button. Both standard controls showed 35,360 B pixels and a B button on H2. A crossed visually, but its forbidden-side input was not independently clicked. PARTIALLY_FEASIBLE for the tested matrix; no production change or phase-B commit/push.
|
||||
|
||||
- Preserved rotated-output probe: actual 90° output keeps an 800×600 logical viewport while the physical mode becomes 750×1000 and `grim` remains logical-oriented 1000×750. Private standard shows 15,860 B-popup pixels on H2; private Mosaic shows zero, but its B popup ends before the boundary, so partial clipping is unchallenged. Fully hidden owner, pointer, measured physical damage and R0/standard returns pass sampled checks. Pristine R90 capture stopped on a fixture IPC-direction assertion; no replay. PARTIALLY_FEASIBLE, architectural conclusion UNDECIDED; no production change.
|
||||
|
||||
- Preserved dynamic-output ownership probe: real 800→960→800 logical resize and H1→H2→H1 workspace migration preserve IDs and sampled applied clips. Exact vacated-output backgrounds and measured physical damage are compatible; two standard-control final calibrations fail through reference occlusion. PARTIALLY_FEASIBLE; maximum clamp, migrated clipped-popup input, opaque spill and effects remain open. Six reconfigurations/six migrations, zero extra camera pairs; no production change. The original temporary evidence manifest was verified before an offline analyzer rerun replaced it; the report records the provenance limit.
|
||||
|
|
|
|||
|
|
@ -270,3 +270,12 @@ The `grim` PNG is logical-oriented at 1000×750 while the R90 physical mode is
|
|||
and the Mosaic B popup never reached the boundary, so partial popup clipping
|
||||
under rotation remains **UNDECIDED**. No applied-geometry generation contract
|
||||
or production behavior is accepted by this evidence.
|
||||
|
||||
The separate [static R90 partial-popup probe](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md)
|
||||
measured a real Mosaic B box crossing x=0 by 72 logical pixels, with B
|
||||
visible/clickable inside H1 and absent visually and from client delivery on
|
||||
H2. Its applied root clip and nested inheritance matched the H1 owner
|
||||
viewport in the sampled state; pristine and private-standard controls both
|
||||
drew and handled the crossed portion. Result **PARTIALLY_FEASIBLE** because
|
||||
the clipped-away part of A was not independently clicked and no atomic
|
||||
render/input generation was proved. The architecture remains experimental.
|
||||
|
|
|
|||
|
|
@ -209,3 +209,13 @@ result is **PARTIALLY_FEASIBLE**, architectural partial-clipping conclusion
|
|||
Mosaic popup stayed wholly inside the viewport despite standard control spill.
|
||||
Scored border hits, rotated shadow/blur/cache and raw buffer readback are
|
||||
**NOT TESTED**. No production support claim follows.
|
||||
|
||||
The subsequent [static R90 partial-popup probe](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md)
|
||||
closed the missing B geometry challenge for one legal XDG placement:
|
||||
Mosaic B crossed by 72 logical pixels; sampled H1 visual/button input
|
||||
survived and H2 matched its prior reference with no B delivery. Its
|
||||
overall status is **PARTIALLY_FEASIBLE**. The A root popup also crossed,
|
||||
but independent input on its forbidden portion remains **NOT TESTED**.
|
||||
Saved buffers, optimized blur caches, inner shadows, intermediate frames,
|
||||
atomic generation and SceneFX/wlroots ABI/package remain unqualified.
|
||||
Multi-output stays **NOT YET SUPPORTED**.
|
||||
|
|
|
|||
|
|
@ -120,3 +120,11 @@ sampled damage and return. Pristine R90 capture and a Mosaic popup portion
|
|||
actually crossing the boundary were not exercised; architectural clipping
|
||||
conclusion UNDECIDED. STEP20C remains NEXT; production multi-output remains
|
||||
NOT YET SUPPORTED.
|
||||
|
||||
[Static R90 partial-popup probe](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md)
|
||||
and [fixture](../tooling/probes/step20c-rotated-partial-popup/README.md):
|
||||
EXPERIMENTAL / PARTIALLY_FEASIBLE. Mosaic B crossed x=0 by 72 logical pixels;
|
||||
its H1 pixels and real button remained, while H2 matched its reference and
|
||||
delivered no B button. Both standard controls drew and handled the overflow.
|
||||
Independent clipped-away A input and saved buffers remain open. STEP20C is
|
||||
NEXT; production multi-output is NOT YET SUPPORTED.
|
||||
|
|
|
|||
|
|
@ -212,3 +212,12 @@ is **UNDECIDED** because the Mosaic popup never crossed the R90 boundary and
|
|||
the pristine R90 capture was lost to a fixture assertion. The next distinct
|
||||
geometric gate should force a legal grabbed Mosaic popup portion across that
|
||||
boundary with a completed pristine control. STEP20C remains **NEXT**.
|
||||
|
||||
The [static R90 partial-popup gate](design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md)
|
||||
then measured exactly that B crossing and completed the pristine control.
|
||||
The bounded result is **PARTIALLY_FEASIBLE**: B's sampled pixels/input obeyed
|
||||
the owner clip, while the independent clipped-away A input point and broader
|
||||
temporal/effect cases remain open. Next prioritize saved transaction buffers
|
||||
against the same owner-derived viewport before optimized blur cache,
|
||||
interior shadow and ABI/package maintenance. STEP20C remains **NEXT**;
|
||||
production multi-output remains **NOT YET SUPPORTED**.
|
||||
|
|
|
|||
207
docs/design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md
Normal file
207
docs/design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# STEP20C static R90 partial popup clipping probe
|
||||
|
||||
**EXPERIMENTAL protocol, before execution.** STEP20C is NEXT; production
|
||||
multi-output Mosaic is NOT YET SUPPORTED. The preceding
|
||||
[rotation probe](STEP20C_ROTATED_OUTPUT_PROBE.md) was PARTIALLY_FEASIBLE:
|
||||
private standard displayed 15,860 B pixels on H2, but Mosaic B ended at
|
||||
PNG x=903, short of the x=1000 boundary. Its pristine R90 fixture stopped
|
||||
after applying transform `90` because it incorrectly expected IPC `270`.
|
||||
This independent campaign tests only a static R90 startup, with no dynamic
|
||||
transform, resize, migration, camera hide/return, effects or algorithm change.
|
||||
|
||||
## Fixed geometry and model
|
||||
|
||||
H1 starts at global (-800,0), physical 750×1000, IPC transform `90`, scale
|
||||
1.25, logical 800×600. H2 starts at (0,0), physical/logical 800×600, normal
|
||||
at scale 1. Read `get_outputs` before scoring. The expected `grim -s 1.25`
|
||||
H1 PNG is logically oriented 1000×750 according to the earlier probe; its
|
||||
actual dimensions and five distinct markers must be measured anew. The
|
||||
internal Wayland transform enum `270`, physical buffer and logical PNG are
|
||||
different coordinate systems. Pointer injection uses the measured logical
|
||||
union (-800..800, 0..600), with sent x = global x + 800.
|
||||
|
||||
Use one fixed XDG placement `rot_cross`: TOPLEVEL 420×320; A 480×170 with
|
||||
anchor rect (360,80,1,1); B 300×130 with anchor rect (210,60,1,1).
|
||||
Both anchors fit their parent dimensions. Positioners use TOP_LEFT anchor,
|
||||
BOTTOM_RIGHT gravity and no constraint adjustment requested by the client.
|
||||
Actual `xdg_popup.configure` x/y/w/h, ack, map and scene placement must be
|
||||
recorded; compositor unconstrain may change them. No alternate placement is
|
||||
authorized in this campaign.
|
||||
|
||||
The prior applied Mosaic owner plus camera 640 placed B's right edge near
|
||||
global -78 (PNG x=903). Moving A's anchor +160 and B's -10 predicts B's
|
||||
right edge near +72, enough for a discriminating H2 portion. A's width
|
||||
adds 180, predicting an A right edge near +42. These are **predictions**, not
|
||||
observations. The original `(390,80)` candidate would shift B by +180: the
|
||||
earlier standard B left edge near -178 would move to +2, removing its H1
|
||||
portion. This single pre-run variant shifts B by +150 and predicts standard
|
||||
B left near -28, retaining 28 logical pixels on H1. The owner, workspace,
|
||||
applied camera and popup root scene
|
||||
coordinates must be measured separately per mode. Compute each global popup
|
||||
box from the applied owner/scene origin and configured XDG offsets, then
|
||||
intersect with H1 [-800,0)×[0,600) and H2 [0,800)×[0,600).
|
||||
Partial clipping is exercised only when left < 0 < right and both intersections
|
||||
are nonempty. A crossing less than 16 logical pixels is reported with its
|
||||
actual width before qualification. A box wholly within H1 is NOT EXERCISED,
|
||||
even when H2 contains zero popup pixels.
|
||||
|
||||
## Runs, observations and decisions
|
||||
|
||||
One run each: C0 pristine pinned SwayFX `splith`, C1 private `splith`, C2
|
||||
same private compositor `mosaic`. Static output config is applied at startup.
|
||||
Each run uses one 0700 runtime and explicit private IPC socket, at most two
|
||||
distinct A/B grab chains, 16 captures and 120 seconds. The entire campaign
|
||||
allows at most 3 runs and 50 captures; failed reservations count. A persistent
|
||||
exclusive ledger is tested offline and reserved before each launch. A single
|
||||
initial Mosaic scroll to 640 is permitted before popup creation, logged once.
|
||||
|
||||
Capture a five-marker H1 background and stable H2 reference before popup
|
||||
creation. Record capture monotonic windows, SHA-256, mode/scale/transform,
|
||||
logical rect, PNG dimensions, popup boxes and clips. Compare H2's entire
|
||||
forbidden intersection to its predeclared reference as well as exact A/B
|
||||
colors, without post-hoc ROI exclusions. At least one standard control must
|
||||
show B pixels on both outputs and a real B button on H2. Mosaic must show
|
||||
its own geometric B crossing, B pixels and a delivered button inside H1,
|
||||
zero B contribution and no B delivery on H2. Derive boundary y from the
|
||||
applied popup box and inspect x=-3..3. Keep hover probes in one chain; use
|
||||
a second chain for an exterior click that may dismiss the grab. A is evaluated
|
||||
separately at points outside B when its own box crosses.
|
||||
|
||||
The client must log a real button serial on TOPLEVEL before A's grab and on
|
||||
A before B's grab, each configure/ack/map and protocol error. The runner
|
||||
records applied tiled/root/child clips, owner output, damage in buffer-local
|
||||
physical coordinates, commits/presents and actual client delivery. Damage
|
||||
from the inherited `TEMPORAL_DAMAGE` path is already physical: do not apply
|
||||
scale twice. Settled PNG differences do not prove every intermediate frame
|
||||
or an atomic render/input generation.
|
||||
|
||||
FEASIBLE requires valid crossing B in Mosaic, standard overflow controls,
|
||||
retained H1 pixels/input, excluded H2 pixels/input, correct owner clips and
|
||||
completed pristine R90. PARTIALLY_FEASIBLE preserves a crossing with missing
|
||||
secondary proof. INCONCLUSIVE covers absent crossing, invalid fixture or
|
||||
incomparable controls. BLOCKED requires a reproducible forbidden pixel/hit,
|
||||
wrong owner clip, crash or standard regression. A confirmed leak stops the
|
||||
affected campaign; no clipping algorithm is changed to obtain a pass.
|
||||
R270, saved buffers, optimized blur cache, inner shadow, raw framebuffer,
|
||||
all intermediate frames and ABI/package maintenance remain NOT TESTED.
|
||||
|
||||
The [source fixture](../../tooling/probes/step20c-rotated-partial-popup/README.md)
|
||||
retains only reviewed scripts and the client placement patch. Generated
|
||||
builds, binaries, captures, sockets, logs and structured results stay in a
|
||||
private LAB.
|
||||
|
||||
## Measured static-R90 campaign — 2026-10-12
|
||||
|
||||
**EXPERIMENTAL / PARTIALLY_FEASIBLE.** LAB:
|
||||
`/tmp/swayfx-mosaic-nested-grab-PioJ5X43`. The new preparer rebuilt the pinned
|
||||
pristine SwayFX, private Mosaic/SceneFX and strict C17 clients from the prior
|
||||
published source chain, then applied only the dedicated `rot_cross` client
|
||||
patch. The clipping algorithm and installed libraries were untouched. One
|
||||
main run each of pristine `splith`, private `splith` and private `mosaic`
|
||||
completed normally, with 14 captures and about 14.6 seconds per run. The
|
||||
new locked ledger consumed **3/3** runs; dynamic transitions, migrations,
|
||||
resize, camera hide/return and R270 each consumed **0**. Mosaic made one
|
||||
pre-popup static camera placement to scroll 640, recorded in the raw events.
|
||||
|
||||
All three `get_outputs` states reported H1 physical 750×1000, IPC transform
|
||||
`90`, scale 1.25 and logical (-800,0,800,600). H2 remained 800×600,
|
||||
normal/scale 1 at (0,0). Every H1 `grim -s 1.25` PNG was 1000×750 and the
|
||||
five marker centers established the logical PNG orientation. The control
|
||||
pristine therefore completed the R90 capture and input matrix missed by the
|
||||
historical fixture; no dynamic R90→R0 action occurred. Its two grabs used
|
||||
real button serials 121/126, configured, acked, mapped, and exited without
|
||||
protocol error. Private standard used 123/128; Mosaic used 105/110. The
|
||||
configured XDG offsets were A (360,80,480,170) and B (210,60,300,130)
|
||||
in all modes, matching the fixed legal 1×1 anchors. The actual source scene
|
||||
and owner positions produced these global half-open boxes:
|
||||
|
||||
| Mode | Applied owner x / camera | A global | A∩H1 / A∩H2 widths | B global | B∩H1 / B∩H2 widths |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Pristine `splith` | -600 / 0 | [-238,242)×[105,275) | 238 / 242 | [-28,272)×[165,295) | 28 / 272 |
|
||||
| Private `splith` | -600 / 0 | [-238,242)×[105,275) | 238 / 242 | [-28,272)×[165,295) | 28 / 272 |
|
||||
| Private Mosaic | -160 / 640 | [-438,42)×[105,275) | 438 / 42 | [-228,72)×[165,295) | 228 / 72 |
|
||||
|
||||
The Mosaic owner current rect was x=-160 with width 640, while its applied
|
||||
scene origin was x=-800 after scroll 640. Its content/window x offset was 2,
|
||||
giving root-popup global (-798,25). A starts there plus configured (360,80),
|
||||
B starts at A plus configured (210,60). The sampled `DO_STATE` reported
|
||||
internal transform 3 (`WL_OUTPUT_TRANSFORM_270`), physical 750×1000 and
|
||||
logical 800×600. `PI_SCENE` held tile global -1440 and local clip
|
||||
(640,0,800,600), hence viewport [-800,0)×[0,600). `DO_POPUP` identified
|
||||
owner 8 on H1 and local root clip (-2,-25,800,600), which yields the same
|
||||
global viewport. B remained a scene descendant of A and inherited that
|
||||
root clip. The scene and configured XDG offsets agree at the sampled applied
|
||||
state; this does not establish an atomic generation over all frames.
|
||||
|
||||
The predefined reference was each mode's `right-reference.png`, captured
|
||||
before A/B creation. At the mapped-B capture, pristine and private standard
|
||||
each had **5,705 exact B pixels on H1** and **35,360 on H2**; their H2
|
||||
full-image difference from reference contained **49,880 pixels**, with box
|
||||
[0,105,272,295). Mosaic retained **46,455 B pixels on H1**, including
|
||||
163/489/815 pixels in its last 1/3/5 PNG columns, and **zero B pixels on
|
||||
H2**. Its H2 whole PNG was **pixel-identical** to the
|
||||
predeclared reference: zero differing pixels, not merely zero exact SHM
|
||||
colors. A itself also crossed: Mosaic showed 77,181 A pixels on H1 and
|
||||
zero on H2; the standards showed 58,431 on H1 and 14,520 on H2. The A
|
||||
count includes occlusion by B where their rectangles overlap; it is not a
|
||||
full theoretical A-area count. The measured B geometry, unlike the old
|
||||
probe, genuinely challenged the clip by 72 logical pixels on H2.
|
||||
|
||||
For B's y=230 boundary grid, the pristine and private-standard clients
|
||||
reported B local motions x=25..31 for global x=-3..3 and delivered right
|
||||
buttons on B at (-14,230) and (30,230). The Mosaic client reported B local
|
||||
motions x=225,226,227 at global x=-3,-2,-1, then a B leave at x=0; no
|
||||
B enter/motion followed for x=0..3. A right button at (-30,230) was delivered
|
||||
to B with serial 119. At forbidden (30,230), B received **no button**;
|
||||
the valid grab produced `popup_done` for B and A. The button on visible A
|
||||
that created B was genuinely delivered (serial 110). An independent click
|
||||
on the clipped-away portion of **A** was not made; P2 root-popup input is
|
||||
therefore PARTIAL even though A's geometry and pixels cross. No universal
|
||||
click-through contract is inferred from the dismissal.
|
||||
|
||||
Available `TEMPORAL_DAMAGE` extents for the settled reference→mapped-B
|
||||
Mosaic H1 pair covered all **123,636 changed logical-PNG pixels** after
|
||||
mapping `(x,y)` to physical `(749-y,x)`: 10 physical extents, zero uncovered.
|
||||
H2 changed zero pixels. The private-standard pair likewise had zero
|
||||
uncovered changed pixels on both outputs (10 H1 and 6 H2 extents). The
|
||||
pristine control had no passive damage trace; its nonzero screenshot diff
|
||||
cannot be scored against absent extents. These broad damage observations
|
||||
cover only the settled pair, not minimal invalidation, all intermediate
|
||||
frames, saved buffers or render/input generation identity. Private Mosaic
|
||||
logged 126 native commits and 124 presents during the whole run; private
|
||||
standard logged 119/117. Startup, pointer and teardown activity are included.
|
||||
|
||||
`LAB/results/` retains geometry, positions/intersections, output transform,
|
||||
grab, applied clip, input, pixels, damage, matrix and summary JSON. Raw
|
||||
client/protocol, compositor, pointer, `get_outputs`/tree, PNG and capture
|
||||
windows remain under each `LAB/runs/` directory. The final source and
|
||||
evidence manifest entry counts and SHA-256 values are recorded below after
|
||||
documentation synchronization: **910 source entries**,
|
||||
`3e6930c46dd9abf50aa2720357ccbdd47ac3fdd0f1106a47734383cb281b41a5`;
|
||||
**146 evidence entries**,
|
||||
`e3c13e4fc02f6b66b59b87407ef7b91f5b7cd67430cc1142252098ef3885a949`.
|
||||
The dedicated placement patch SHA-256 is
|
||||
`0f45d48625c062ca05f36849068aedecd02e4480a98f3e0bafe98c1f1c529089`,
|
||||
based on nested client SHA-256
|
||||
`2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d`.
|
||||
Both manifest files passed entry-by-entry verification after the fixture
|
||||
README was synchronized and the CLI limits were made non-overridable above
|
||||
120 seconds / 16 captures / 30 MB. Each run retains its executed
|
||||
`fixture-source.py`; that exact source precedes this post-run limit hardening.
|
||||
The hardening was tested offline with an over-limit argument and caused no
|
||||
fourth run or ledger change. The ledger is separate
|
||||
from the historical 5/6 rotation and 1/1 camera budget, which were not
|
||||
reopened. Every run used a private 0700 runtime and explicit private IPC;
|
||||
exit status and cleanup were retained. No production, package or UX change
|
||||
was made.
|
||||
|
||||
**Verdict:** `SWAYFX_MOSAIC_ROTATED_PARTIAL_POPUP_CLIP_PROBE=PARTIALLY_FEASIBLE`.
|
||||
The central B crossing/visible/clipped/input distinction passed this one
|
||||
static-R90 matrix, including a complete pristine control. The scored
|
||||
clipped-away A input point, all-frame damage and applied generation are
|
||||
still open. Architectural conclusion:
|
||||
`ROTATED_PARTIAL_POPUP_CLIP_FEASIBLE_FOR_TESTED_MATRIX`, without production
|
||||
STEP20C qualification. Saved transaction buffers are the next priority:
|
||||
they can reintroduce pixels from an earlier scene at the same viewport even
|
||||
when the live popup tree clips correctly. Optimized blur cache, interior
|
||||
shadow and SceneFX/wlroots ABI/package maintenance remain separate gates.
|
||||
45
tooling/probes/step20c-rotated-partial-popup/README.md
Normal file
45
tooling/probes/step20c-rotated-partial-popup/README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Static R90 partial popup fixture
|
||||
|
||||
**EXPERIMENTAL / PARTIALLY_FEASIBLE.** See the
|
||||
[scientific report](../../../docs/design/STEP20C_ROTATED_PARTIAL_POPUP_CLIP_PROBE.md).
|
||||
The fixed placement is `rot_cross`: A (360,80), 480×170; B (210,60),
|
||||
300×130; toplevel 420×320. It was fixed before the three runs.
|
||||
|
||||
Use a new private LAB and a locked ledger; the previous rotation laboratory
|
||||
and its consumed budget are read-only. The runner must use startup R90 config,
|
||||
one run per mode, explicit private sockets, at most 16 captures per run and
|
||||
120 seconds. No production source, clipping algorithm, installed library or
|
||||
daily session changes are permitted. Generated files remain outside Git.
|
||||
|
||||
The completed LAB is `/tmp/swayfx-mosaic-nested-grab-PioJ5X43`; its ledger
|
||||
has consumed all three runs. **Do not run those modes again or reset the
|
||||
ledger.** Source-only preparation in another authorized campaign uses:
|
||||
|
||||
```sh
|
||||
sh tooling/probes/step20c-rotated-partial-popup/prepare.sh
|
||||
```
|
||||
|
||||
This rebuilds pinned private sources and applies `rot-cross-client.patch`
|
||||
to the [nested client](../step20c-nested-grab/nested-client.c), whose base
|
||||
SHA-256 is `2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d`.
|
||||
The patch preserves earlier placements and adds only `rot_cross`.
|
||||
`prepare.sh` does not launch a compositor.
|
||||
|
||||
The completed campaign used these one-run commands, in order:
|
||||
|
||||
```sh
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/test-budget.py
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/run.py "$LAB" control
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/run.py "$LAB" private_standard
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/run.py "$LAB" private_mosaic
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 tooling/probes/step20c-rotated-partial-popup/analyze.py "$LAB"
|
||||
```
|
||||
|
||||
`LAB/results/` contains structured analysis; `LAB/runs/` contains raw
|
||||
captures, client/protocol events, applied traces and per-run exit codes.
|
||||
The source manifest covers 910 files and the evidence manifest 146 files;
|
||||
their final SHA-256 values are recorded in the scientific report.
|
||||
Mosaic B crossed by 72 logical pixels, remained visible/clickable on H1
|
||||
and was absent from H2 pixels/client delivery. Both standards visibly
|
||||
crossed. Independent forbidden-side A input was not tested, so the verdict
|
||||
stays PARTIALLY_FEASIBLE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
213
tooling/probes/step20c-rotated-partial-popup/analyze.py
Normal file
213
tooling/probes/step20c-rotated-partial-popup/analyze.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""Offline, single-seal analysis of the static R90 runs and their raw evidence."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
COLORS = {'A': (224, 192, 32), 'B': (32, 208, 160)}
|
||||
TRACE = re.compile(r'\b(DO_STATE|DO_POPUP|PI_SCENE|PI_OWNER|PI_OUTPUT_COMMIT|PI_OUTPUT_PRESENT)\b')
|
||||
DAMAGE = re.compile(r'TEMPORAL_DAMAGE ordinal=\d+ ns=(\d+) output=(\S+) '
|
||||
r'extent=(-?\d+),(-?\d+)\.\.(-?\d+),(-?\d+)')
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def write(path, obj):
|
||||
path.write_text(json.dumps(obj, indent=2, sort_keys=True) + '\n')
|
||||
|
||||
|
||||
def fields(line):
|
||||
return dict(re.findall(r'(\w+)=([^\s]+)', line))
|
||||
|
||||
|
||||
def bbox(mask):
|
||||
yy, xx = np.where(mask)
|
||||
return [int(xx.min()), int(yy.min()), int(xx.max()) + 1, int(yy.max()) + 1] if len(xx) else None
|
||||
|
||||
|
||||
def latest(traces, kind, before, **constraints):
|
||||
found = [x for x in traces if x['kind'] == kind and x['ns'] <= before and
|
||||
all(x.get(k) == v for k, v in constraints.items())]
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
def image(path):
|
||||
return np.asarray(Image.open(path).convert('RGB'))
|
||||
|
||||
|
||||
def colors(path):
|
||||
im = image(path)
|
||||
return {name: {'pixels': int(mask.sum()), 'box': bbox(mask)}
|
||||
for name, color in COLORS.items()
|
||||
for mask in [np.all(im == color, axis=2)]}
|
||||
|
||||
|
||||
def damage_coverage(run, a, b, output, damages):
|
||||
before, after = image(run / a['path']), image(run / b['path'])
|
||||
changed = np.any(before != after, axis=2)
|
||||
yy, xx = np.where(changed)
|
||||
width, height = (750, 1000) if output == 'HEADLESS-1' else (800, 600)
|
||||
mask = np.zeros((height, width), dtype=bool)
|
||||
extents = [d for d in damages if d[1] == output and
|
||||
a['after_ns'] < d[0] <= b['after_ns']]
|
||||
for _, _, x1, y1, x2, y2 in extents:
|
||||
mask[max(0, y1):min(height, y2), max(0, x1):min(width, x2)] = True
|
||||
# WHY: grim H1 is logical-oriented; inherited damage extents already use
|
||||
# physical buffer-local coordinates after SceneFX inverse rotation.
|
||||
# CONTRACT: measured static R90 maps PNG (x,y) to buffer (749-y,x).
|
||||
px, py = (width - 1 - yy, xx) if output == 'HEADLESS-1' else (xx, yy)
|
||||
return {'from': a['tag'], 'to': b['tag'], 'output': output,
|
||||
'changed_png_pixels': int(len(xx)), 'changed_box_png': bbox(changed),
|
||||
'physical_extents_count': len(extents),
|
||||
'uncovered_changed_pixels': int(np.count_nonzero(~mask[py, px])),
|
||||
'physical_extents': [list(d[2:]) for d in extents],
|
||||
'scope': 'settled PNG pair only; no intermediate-frame claim'}
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('lab', type=Path)
|
||||
args = parser.parse_args()
|
||||
lab = args.lab.resolve()
|
||||
out = lab / 'results'
|
||||
if out.exists():
|
||||
raise SystemExit('sealed results exist; refusing overwrite')
|
||||
runs = sorted((lab / 'runs').glob('*-static-r90-*'))
|
||||
if len(runs) != 3:
|
||||
raise SystemExit('exactly three retained runs required')
|
||||
budget = json.loads((lab / 'partial-campaign-budget.json').read_text())
|
||||
if sorted(budget['runs']) != ['control', 'private_mosaic', 'private_standard']:
|
||||
raise SystemExit('campaign ledger incomplete')
|
||||
|
||||
outputs, geometry, intersections, grabs, clips = {}, {}, {}, {}, {}
|
||||
hits, delivery, pixels, damages_out, matrix, summary_runs = {}, {}, {}, {}, {}, {}
|
||||
source_paths = []
|
||||
for tree in (lab / 'mosaic', lab / 'scenefx', lab / 'standard'):
|
||||
source_paths.extend(p for p in tree.rglob('*') if p.is_file())
|
||||
source_paths.extend(p for p in lab.glob('*.c'))
|
||||
source_paths.extend(p for p in lab.glob('*.h'))
|
||||
source_paths.extend(p for p in Path(__file__).parent.glob('*') if p.is_file())
|
||||
|
||||
for run in runs:
|
||||
mode = run.name.split('-static-r90-', 1)[0]
|
||||
events = [json.loads(line) for line in (run / 'events.jsonl').read_text().splitlines()]
|
||||
by_kind = lambda kind: [e for e in events if e['kind'] == kind]
|
||||
geom = by_kind('applied_popup_geometry')[-1]
|
||||
gate = by_kind('geometry_gate')[-1]
|
||||
client = (run / 'client.log').read_text(errors='replace')
|
||||
log = (run / 'compositor.log').read_text(errors='replace')
|
||||
traces = []
|
||||
for line in log.splitlines():
|
||||
m = TRACE.search(line)
|
||||
if m:
|
||||
item = {'kind': m[1], **fields(line)}
|
||||
if item.get('ns', '').isdigit():
|
||||
item['ns'] = int(item['ns'])
|
||||
traces.append(item)
|
||||
damage = [(int(ns), output, *map(int, (x1, y1, x2, y2)))
|
||||
for ns, output, x1, y1, x2, y2 in DAMAGE.findall(log)]
|
||||
captures = {(e['tag'], e['output']): e for e in by_kind('capture')}
|
||||
capture_geometry = {(e['tag'], e['output']): e for e in by_kind('capture_geometry')}
|
||||
outputs[mode] = {'states': by_kind('state'), 'capture_geometry': by_kind('capture_geometry'),
|
||||
'markers': by_kind('marker_calibration'),
|
||||
'pointer_calibration': by_kind('pointer_calibration')}
|
||||
geometry[mode] = geom
|
||||
intersections[mode] = {k: geom[k] for k in ('a_h1', 'a_h2', 'b_h1', 'b_h2')}
|
||||
parsed_grabs = [{'popup': p, 'target': t, 'serial': int(s), 'used': int(u),
|
||||
'real_button': bool(re.search(rf'POINTER_BUTTON target={t} serial={s} button=272 state=1', client))}
|
||||
for p, t, s, u in re.findall(
|
||||
r'(POPUP_[AB]) GRAB_REQUEST origin=wl_pointer.button target=(\S+) serial=(\d+) used=(\d+)', client)]
|
||||
grabs[mode] = {'requests': parsed_grabs,
|
||||
'role_configures': re.findall(r'(POPUP_[AB]) ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)', client),
|
||||
'xdg_configure_acks': re.findall(r'(POPUP_[AB]) XDG_CONFIGURE serial=(\d+) ack', client),
|
||||
'maps': re.findall(r'(POPUP_[AB]) MAP wl=(\d+) xdg=(\d+)', client),
|
||||
'protocol_errors': [line for line in client.splitlines() if 'protocol error' in line.lower()]}
|
||||
capture = captures[('rot-cross-S2-B', 'HEADLESS-1')]
|
||||
before = capture['before_ns']
|
||||
clips[mode] = {'output_state': latest(traces, 'DO_STATE', before, observer='HEADLESS-1'),
|
||||
'tiled': latest(traces, 'PI_SCENE', before, output='HEADLESS-1'),
|
||||
'root_popup': latest(traces, 'DO_POPUP', before, owner_id=str(geom['owner_id'])),
|
||||
'owner': latest(traces, 'PI_OWNER', before, id=str(geom['owner_id'])),
|
||||
'native_commits': sum(t['kind'] == 'PI_OUTPUT_COMMIT' for t in traces),
|
||||
'native_presents': sum(t['kind'] == 'PI_OUTPUT_PRESENT' for t in traces)}
|
||||
hits[mode] = {'border_hovers': by_kind('border_hover'),
|
||||
'visible_b_button': by_kind('visible_b_button'),
|
||||
'forbidden_b_button': by_kind('forbidden_b_button')}
|
||||
delivery[mode] = {'client_grabs': grabs[mode],
|
||||
'visible_b_events': by_kind('visible_b_button')[-1]['client_events'],
|
||||
'forbidden_b_events': by_kind('forbidden_b_button')[-1]['client_events'],
|
||||
'popup_done_A': client.count('POPUP_A POPUP_DONE'),
|
||||
'popup_done_B': client.count('POPUP_B POPUP_DONE'),
|
||||
'client_log': str(run / 'client.log')}
|
||||
pair = {}
|
||||
for output, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')):
|
||||
a, b = captures[('reference', output)], captures[('rot-cross-S2-B', output)]
|
||||
before_im, popup_im = image(run / a['path']), image(run / b['path'])
|
||||
diff = np.any(before_im != popup_im, axis=2)
|
||||
pair[output] = {'reference': {'path': a['path'], 'sha256': digest(run / a['path']),
|
||||
'window_ns': [a['before_ns'], a['after_ns']]},
|
||||
'popup': {'path': b['path'], 'sha256': digest(run / b['path']),
|
||||
'window_ns': [b['before_ns'], b['after_ns']]},
|
||||
'png_dimensions': list(popup_im.shape[1::-1]),
|
||||
'output': capture_geometry[('rot-cross-S2-B', output)],
|
||||
'reference_diff_pixels': int(diff.sum()), 'reference_diff_box': bbox(diff),
|
||||
'colors': colors(run / b['path'])}
|
||||
pixels[mode] = pair
|
||||
damages_out[mode] = [damage_coverage(run, captures[('reference', output)],
|
||||
captures[('rot-cross-S2-B', output)], output, damage)
|
||||
for output in ('HEADLESS-1', 'HEADLESS-2')]
|
||||
status = json.loads((run / 'exit-status.json').read_text())
|
||||
matrix[mode] = {'run_failure': status['failure'], 'captures': status['captures'],
|
||||
'duration_seconds': status['duration_seconds'],
|
||||
'geometry_gate': gate['result'], 'B_box': geom['b_box'],
|
||||
'A_box': geom['a_box'], 'grab_count': len(parsed_grabs),
|
||||
'H1_B_pixels': pair['HEADLESS-1']['colors']['B']['pixels'],
|
||||
'H2_B_pixels': pair['HEADLESS-2']['colors']['B']['pixels'],
|
||||
'H2_full_reference_diff': pair['HEADLESS-2']['reference_diff_pixels']}
|
||||
summary_runs[mode] = {'run': str(run), 'exit': status}
|
||||
|
||||
if not all(matrix[m]['geometry_gate'] == 'CROSSING' for m in matrix):
|
||||
verdict = 'INCONCLUSIVE'
|
||||
elif matrix['private_mosaic']['H2_full_reference_diff'] or matrix['private_mosaic']['H2_B_pixels']:
|
||||
verdict = 'BLOCKED'
|
||||
elif any(matrix[m]['run_failure'] for m in matrix):
|
||||
verdict = 'INCONCLUSIVE'
|
||||
else:
|
||||
# P2 root-popup input was not independently clicked in the one-chain run.
|
||||
verdict = 'PARTIALLY_FEASIBLE'
|
||||
|
||||
out.mkdir()
|
||||
write(out / 'geometry.json', geometry)
|
||||
write(out / 'popup-positions.json', {m: {'A': x['a_box'], 'B': x['b_box']} for m, x in geometry.items()})
|
||||
write(out / 'popup-intersections.json', intersections)
|
||||
write(out / 'output-transforms.json', outputs)
|
||||
write(out / 'client-grabs.json', grabs)
|
||||
write(out / 'scene-clips.json', clips)
|
||||
write(out / 'pointer-hits.json', hits)
|
||||
write(out / 'client-delivery.json', delivery)
|
||||
write(out / 'pixel-differences.json', pixels)
|
||||
write(out / 'damage.json', damages_out)
|
||||
write(out / 'budget.json', budget)
|
||||
write(out / 'matrix.json', matrix)
|
||||
write(out / 'summary.json', {'result': verdict, 'architectural_conclusion':
|
||||
'ROTATED_PARTIAL_POPUP_CLIP_FEASIBLE_FOR_TESTED_MATRIX'
|
||||
if verdict in ('FEASIBLE', 'PARTIALLY_FEASIBLE') else 'UNDECIDED',
|
||||
'runs': summary_runs, 'unexercised': ['separate A input and exterior hit',
|
||||
'raw framebuffer', 'intermediate frames', 'saved buffers',
|
||||
'optimized blur cache', 'inner shadow', 'ABI/package']})
|
||||
write(out / 'source-provenance.json', {'pinned_swayfx': 'fd71a6bdc061bd633b488ae7b83e8a6981d22f86',
|
||||
'mosaic_private_base': 'cd550382b311efdbfe3ed892f4e1d281d852e754',
|
||||
'scenefx': '37ccd723bef49e6891156ffafce8f549f01446cc',
|
||||
'client_base_sha256': '2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d',
|
||||
'client_patch_sha256': digest(Path(__file__).parent / 'rot-cross-client.patch')})
|
||||
(out / 'source-sha256.txt').write_text(''.join(f'{digest(p)} {p.resolve()}\n' for p in sorted(set(source_paths))))
|
||||
evidence = [p for p in sorted(run_file for run in runs for run_file in run.rglob('*') if run_file.is_file())]
|
||||
evidence += [lab / 'partial-campaign-budget.json', lab / 'partial-build-log.txt']
|
||||
evidence += [p for p in out.iterdir() if p.is_file() and p.name != 'evidence-sha256.txt']
|
||||
(out / 'evidence-sha256.txt').write_text(''.join(f'{digest(p)} {p.resolve()}\n' for p in sorted(set(evidence))))
|
||||
print(f'RESULT={verdict} LAB={lab} SOURCES={len(set(source_paths))} EVIDENCE={len(set(evidence))}')
|
||||
45
tooling/probes/step20c-rotated-partial-popup/budget.py
Normal file
45
tooling/probes/step20c-rotated-partial-popup/budget.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Persistent one-run-per-mode budget for the independent static-R90 campaign."""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
MODES = ('control', 'private_standard', 'private_mosaic')
|
||||
|
||||
|
||||
class Budget:
|
||||
def __init__(self, lab):
|
||||
self.lab = Path(lab)
|
||||
self.lock = (self.lab / 'partial-campaign.lock').open('a')
|
||||
fcntl.flock(self.lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
self.path = self.lab / 'partial-campaign-budget.json'
|
||||
|
||||
def read(self):
|
||||
if not self.path.exists():
|
||||
if (self.lab / 'runs').exists() and any((self.lab / 'runs').iterdir()):
|
||||
raise ValueError('missing ledger with retained run')
|
||||
return {'version': 1, 'runs': []}
|
||||
state = json.loads(self.path.read_text())
|
||||
runs = state.get('runs')
|
||||
if state.get('version') != 1 or not isinstance(runs, list) or len(runs) != len(set(runs)) or any(r not in MODES for r in runs):
|
||||
raise ValueError('invalid ledger')
|
||||
return state
|
||||
|
||||
def reserve(self, mode):
|
||||
state = self.read()
|
||||
if mode not in MODES or mode in state['runs'] or len(state['runs']) >= 3:
|
||||
raise ValueError('run budget exhausted or duplicate mode')
|
||||
state['runs'].append(mode)
|
||||
tmp = self.path.with_suffix('.tmp')
|
||||
with tmp.open('w') as output:
|
||||
json.dump(state, output, indent=2)
|
||||
output.write('\n')
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
tmp.replace(self.path)
|
||||
fd = os.open(self.lab, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return state
|
||||
23
tooling/probes/step20c-rotated-partial-popup/prepare.sh
Normal file
23
tooling/probes/step20c-rotated-partial-popup/prepare.sh
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/bin/sh
|
||||
# EXPERIMENTAL source-only preparation; never launches a compositor.
|
||||
set -eu
|
||||
repo=$(CDPATH= cd -- "$(dirname -- "$0")/../../.." && pwd)
|
||||
fixture="$repo/tooling/probes/step20c-rotated-partial-popup"
|
||||
base="$repo/tooling/probes/step20c-nested-grab/nested-client.c"
|
||||
test "$(sha256sum "$base" | cut -d ' ' -f 1)" = 2e40c98af72a0df8b316810589a0be8f4425a763db1de448b49a7813956b6f7d
|
||||
log=$(mktemp /tmp/rot-partial-prepare-XXXXXXXX)
|
||||
if ! sh "$repo/tooling/probes/step20c-dynamic-output/prepare.sh" > "$log" 2>&1; then
|
||||
printf 'BUILD_FAILED log=%s\n' "$log" >&2
|
||||
tail -40 "$log" >&2
|
||||
exit 1
|
||||
fi
|
||||
lab=$(sed -n 's/^LAB=//p' "$log")
|
||||
test -n "$lab" && test -d "$lab"
|
||||
cp "$base" "$lab/nested-client.c"
|
||||
patch -d "$lab" -p1 --dry-run < "$fixture/rot-cross-client.patch" >> "$log" 2>&1
|
||||
patch -d "$lab" -p1 < "$fixture/rot-cross-client.patch" >> "$log" 2>&1
|
||||
cc -std=c17 -Wall -Wextra -Werror -Wno-unused-parameter -I"$lab" \
|
||||
"$lab/nested-client.c" "$lab/xdg-shell-protocol.c" -o "$lab/nested-client" \
|
||||
$(pkg-config --cflags --libs wayland-client) >> "$log" 2>&1
|
||||
cp "$log" "$lab/partial-build-log.txt"
|
||||
printf 'LAB=%s\nBUILD_LOG=%s\n' "$lab" "$log"
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
--- a/nested-client.c
|
||||
+++ b/nested-client.c
|
||||
@@ -211,6 +211,10 @@
|
||||
} else if (!strcmp(placement,"both_cross")) {
|
||||
a.anchor_x=350; a.anchor_y=80; a.width=350;
|
||||
b.anchor_x=160; b.anchor_y=60; b.width=300;
|
||||
+ } else if (!strcmp(placement,"rot_cross")) {
|
||||
+ /* CONTRACT: both 1x1 anchors stay inside their parent buffers. */
|
||||
+ a.anchor_x=360; a.anchor_y=80; a.width=480;
|
||||
+ b.anchor_x=210; b.anchor_y=60; b.width=300;
|
||||
} else if (!strcmp(placement,"b_cross")) {
|
||||
a.anchor_x=200; a.anchor_y=80; a.width=300;
|
||||
b.anchor_x=220; b.anchor_y=60; b.width=300;
|
||||
538
tooling/probes/step20c-rotated-partial-popup/run.py
Normal file
538
tooling/probes/step20c-rotated-partial-popup/run.py
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
"""EXPERIMENTAL applied-state/input headless campaign. See README."""
|
||||
import argparse
|
||||
from PIL import Image, ImageDraw
|
||||
import sys
|
||||
from budget import Budget
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('lab', type=Path)
|
||||
parser.add_argument('mode', choices=('control', 'private_standard', 'private_mosaic'))
|
||||
|
||||
parser.add_argument('--cycles', type=int, default=3)
|
||||
parser.add_argument('--asi-mode', type=int, choices=(0, 1, 2), default=0)
|
||||
parser.add_argument('--test-hold', action='store_true')
|
||||
parser.add_argument('--test-timeout', action='store_true')
|
||||
parser.add_argument('--moves', type=int, default=18)
|
||||
parser.add_argument('--max-seconds', type=int, default=120)
|
||||
parser.add_argument('--max-captures', type=int, default=16)
|
||||
parser.add_argument('--max-bytes', type=int, default=30_000_000)
|
||||
args = parser.parse_args()
|
||||
if not (1 <= args.max_seconds <= 120 and 1 <= args.max_captures <= 16 and
|
||||
1 <= args.max_bytes <= 30_000_000):
|
||||
parser.error('static R90 run bounds exceeded')
|
||||
if not 1 <= args.cycles <= 30 or not 1 <= args.moves <= 40:
|
||||
parser.error('cycle/move bounds exceeded')
|
||||
|
||||
lab = args.lab.resolve()
|
||||
if not (lab / 'nested-client').is_file():
|
||||
parser.error('prepared LAB required')
|
||||
budget = Budget(lab)
|
||||
budget.reserve(args.mode)
|
||||
run = lab / 'runs' / f'{args.mode}-static-r90-{time.time_ns()}'
|
||||
run.mkdir(parents=True, mode=0o700)
|
||||
(run / 'fixture-source.py').write_text(Path(__file__).read_text())
|
||||
# WHY: five asymmetric markers identify whether PNG uses logical or buffer axes.
|
||||
# CONTRACT: predeclared logical centers and distinct exact RGB codes.
|
||||
marker_path = run / 'marker.png'
|
||||
marker = Image.new('RGB', (800, 600), (30, 30, 46))
|
||||
draw = ImageDraw.Draw(marker)
|
||||
for name, box, color in (
|
||||
('TL', (15, 15, 44, 44), (255, 32, 32)),
|
||||
('TR', (755, 15, 784, 44), (32, 255, 32)),
|
||||
('BL', (15, 555, 44, 584), (32, 32, 255)),
|
||||
('BR', (755, 555, 784, 584), (255, 255, 32)),
|
||||
('C', (385, 285, 414, 314), (192, 32, 255))):
|
||||
draw.rectangle(box, fill=color)
|
||||
marker.save(marker_path)
|
||||
|
||||
runtime = Path(tempfile.mkdtemp(prefix='mtd-', dir='/tmp'))
|
||||
if len(os.fsencode(str(runtime / 'sway-ipc.4294967295.2147483647.sock'))) >= 108:
|
||||
raise RuntimeError('IPC socket path exceeds sockaddr_un')
|
||||
(run / 'runtime-path').write_text(str(runtime) + '\n')
|
||||
(run / 'config').write_text(
|
||||
'output HEADLESS-1 resolution 750x1000 scale 1.25 transform 90 position -800 0\n'
|
||||
'output HEADLESS-2 resolution 800x600 position 0 0\n'
|
||||
'workspace 1 output HEADLESS-1\nworkspace 2 output HEADLESS-2\n'
|
||||
'focus_follows_mouse no\n')
|
||||
(run / 'config').write_text((run / 'config').read_text() + f'output * bg {marker_path} fill\n')
|
||||
env = os.environ.copy()
|
||||
env.update(XDG_RUNTIME_DIR=str(runtime), WLR_BACKENDS='headless',
|
||||
WLR_HEADLESS_OUTPUTS='2', WLR_RENDERER='pixman')
|
||||
for key in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'LD_LIBRARY_PATH'):
|
||||
env.pop(key, None)
|
||||
compositor_env = env.copy()
|
||||
compositor_env['ASI_MODE'] = str(args.asi_mode)
|
||||
if args.test_hold or args.test_timeout:
|
||||
compositor_env['ASI_TEST_HOLD_AFTER_PRESENT'] = '1'
|
||||
readback_dir = None
|
||||
if args.asi_mode:
|
||||
readback_dir = lab / 'buffers' / run.name
|
||||
readback_dir.mkdir(parents=True, mode=0o700)
|
||||
compositor_env['BI_OUTPUT_DIR'] = str(readback_dir)
|
||||
(run / 'asi-mode.json').write_text(json.dumps({'mode': args.asi_mode,
|
||||
'readback_dir': str(readback_dir) if readback_dir else None}) + '\n')
|
||||
binary = lab / 'standard-build/sway/sway' if args.mode == 'control' else lab / 'mosaic-build/sway/swayfx-mosaic'
|
||||
if args.mode != 'control':
|
||||
compositor_env['LD_LIBRARY_PATH'] = str(lab / 'scenefx-build')
|
||||
files, children, events = [], [], []
|
||||
started = time.monotonic()
|
||||
capture_count = 0
|
||||
capture_bytes = 0
|
||||
|
||||
|
||||
def record(kind, **fields):
|
||||
entry = {'monotonic_ns': time.monotonic_ns(), 'kind': kind, **fields}
|
||||
events.append(entry)
|
||||
with (run / 'events.jsonl').open('a') as out:
|
||||
out.write(json.dumps(entry) + '\n')
|
||||
return entry
|
||||
|
||||
|
||||
def spawn(argv, log_name, *, stdin=False, debug=False):
|
||||
log = (run / log_name).open('w')
|
||||
files.append(log)
|
||||
child_env = env.copy()
|
||||
if debug:
|
||||
child_env['WAYLAND_DEBUG'] = '1'
|
||||
proc = subprocess.Popen(argv, env=child_env, stdin=subprocess.PIPE if stdin else None,
|
||||
stdout=log, stderr=subprocess.STDOUT,
|
||||
start_new_session=True, text=stdin)
|
||||
children.append(proc)
|
||||
return proc
|
||||
|
||||
|
||||
def wait(predicate, seconds=12):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
if time.monotonic() - started > args.max_seconds:
|
||||
raise RuntimeError('run duration bound exceeded')
|
||||
result = predicate()
|
||||
if result:
|
||||
return result
|
||||
if compositor.poll() is not None:
|
||||
raise RuntimeError(f'compositor exited {compositor.returncode}')
|
||||
if 'pointer' in globals() and pointer.poll() is not None:
|
||||
raise RuntimeError(f'pointer exited {pointer.returncode}')
|
||||
if 'client' in globals() and client.poll() is not None:
|
||||
raise RuntimeError(f'client exited {client.returncode}')
|
||||
time.sleep(.04)
|
||||
raise RuntimeError('timeout waiting for event')
|
||||
|
||||
|
||||
def ipc(*argv):
|
||||
completed = subprocess.run(['swaymsg', '-s', str(sock), *argv], env=env,
|
||||
text=True, capture_output=True, check=True, timeout=8)
|
||||
result = json.loads(completed.stdout)
|
||||
record('ipc', argv=list(argv), result=result)
|
||||
return result
|
||||
|
||||
|
||||
def command(value):
|
||||
result = ipc(value)
|
||||
if not isinstance(result, list) or not result or not all(x.get('success') for x in result):
|
||||
raise RuntimeError(f'IPC command rejected: {value}: {result}')
|
||||
|
||||
|
||||
def find(node, predicate):
|
||||
if isinstance(node, dict):
|
||||
if predicate(node):
|
||||
return node
|
||||
for child in node.get('nodes', []) + node.get('floating_nodes', []):
|
||||
found = find(child, predicate)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def tree():
|
||||
return ipc('-t', 'get_tree')
|
||||
|
||||
|
||||
def workspace():
|
||||
return find(tree(), lambda n: n.get('type') == 'workspace' and n.get('name') == '1')
|
||||
|
||||
|
||||
def owner():
|
||||
return find(tree(), lambda n: n.get('app_id') == 'org.mosaic.NestedGrabProbe')
|
||||
|
||||
|
||||
def client_text():
|
||||
return (run / 'client.log').read_text()
|
||||
|
||||
|
||||
def count(marker):
|
||||
return client_text().count(marker)
|
||||
|
||||
|
||||
def configured(name):
|
||||
matches = re.findall(rf'{name} ROLE_CONFIGURE x=(-?\d+) y=(-?\d+) w=(\d+) h=(\d+)',
|
||||
client_text())
|
||||
if not matches:
|
||||
raise RuntimeError(f'no {name} role configure')
|
||||
return tuple(map(int, matches[-1]))
|
||||
|
||||
|
||||
def snapshot(tag):
|
||||
global capture_count, capture_bytes
|
||||
t = tree()
|
||||
outputs = ipc('-t', 'get_outputs')
|
||||
(run / f'tree-{tag}.json').write_text(json.dumps(t, indent=2) + '\n')
|
||||
(run / f'outputs-{tag}.json').write_text(json.dumps(outputs, indent=2) + '\n')
|
||||
expected = {'HEADLESS-1': (-800, 0, 800, 600), 'HEADLESS-2': (0, 0, 800, 600)}
|
||||
actual = {o['name']: tuple(o['rect'][k] for k in ('x','y','width','height')) for o in outputs if o['active']}
|
||||
rotated = True
|
||||
h1 = next(o for o in outputs if o['name'] == 'HEADLESS-1')
|
||||
h2 = next(o for o in outputs if o['name'] == 'HEADLESS-2')
|
||||
wanted_mode = (750, 1000) if rotated else (1000, 750)
|
||||
if (actual != expected or h1['scale'] != 1.25 or h2['scale'] != 1 or
|
||||
h2['transform'] != 'normal' or h1['transform'] != ('90' if rotated else 'normal') or
|
||||
(h1['current_mode']['width'], h1['current_mode']['height']) != wanted_mode):
|
||||
raise RuntimeError(f'applied rotation geometry gate failed: {actual} {h1}')
|
||||
record('state', tag=tag, workspaces=ipc('-t','get_workspaces'), workspace=workspace(), owner=owner(), outputs=outputs)
|
||||
for output, side in (('HEADLESS-1', 'left'), ('HEADLESS-2', 'right')):
|
||||
if capture_count >= args.max_captures:
|
||||
raise RuntimeError('capture count bound exceeded')
|
||||
path = run / f'{side}-{tag}.png'
|
||||
before = time.monotonic_ns()
|
||||
subprocess.run(['grim', '-s', str(next(o['scale'] for o in outputs if o['name']==output)), '-o', output, str(path)], env=env, check=True,
|
||||
stdout=subprocess.DEVNULL, timeout=10)
|
||||
after = time.monotonic_ns()
|
||||
capture_count += 1
|
||||
capture_bytes += path.stat().st_size
|
||||
if capture_bytes > args.max_bytes:
|
||||
raise RuntimeError('capture byte bound exceeded')
|
||||
out = next(o for o in outputs if o['name']==output)
|
||||
dimensions = list(Image.open(path).size)
|
||||
record('capture_geometry',tag=tag,output=output,dimensions=dimensions,rect=out['rect'],scale=out['scale'],transform=out['transform'],mode=out['current_mode'], physical_expected=[out['current_mode']['width'],out['current_mode']['height']])
|
||||
record('capture', tag=tag, output=output, path=path.name,
|
||||
before_ns=before, after_ns=after, size=path.stat().st_size)
|
||||
|
||||
|
||||
def inject(action, x, y):
|
||||
outputs = [o for o in ipc('-t','get_outputs') if o['active']]
|
||||
min_x = min(o['rect']['x'] for o in outputs)
|
||||
min_y = min(o['rect']['y'] for o in outputs)
|
||||
max_x = max(o['rect']['x']+o['rect']['width'] for o in outputs)
|
||||
max_y = max(o['rect']['y']+o['rect']['height'] for o in outputs)
|
||||
if not any(o['rect']['x']<=x<o['rect']['x']+o['rect']['width'] and o['rect']['y']<=y<o['rect']['y']+o['rect']['height'] for o in outputs):
|
||||
raise ValueError('global point is outside applied outputs')
|
||||
sent_x,sent_y = int(x-min_x),int(y-min_y)
|
||||
width,height = max_x-min_x,max_y-min_y
|
||||
marker=f'INJECT_DONE {action} {sent_x},{sent_y}'
|
||||
before=(run/'pointer.log').read_text().count(marker)
|
||||
record('pointer_request',action=action,global_point=[x,y],sent=[sent_x,sent_y],extent=[width,height],minimum=[min_x,min_y])
|
||||
pointer.stdin.write(f'{action} {sent_x} {sent_y} {width} {height}\n');pointer.stdin.flush()
|
||||
wait(lambda: (run/'pointer.log').read_text().count(marker)>before)
|
||||
|
||||
|
||||
def client_command(value):
|
||||
marker = f'COMMAND {value} DONE'
|
||||
before = count(marker)
|
||||
record('client_request', value=value)
|
||||
client.stdin.write(value + '\n')
|
||||
client.stdin.flush()
|
||||
wait(lambda: count(marker) > before)
|
||||
|
||||
|
||||
def scroll(target):
|
||||
current = workspace()['mosaic']['rows'][0]['scroll_x']
|
||||
command(f'layout mosaic scroll {target-current:g}')
|
||||
wait(lambda: workspace()['mosaic']['rows'][0]['scroll_x'] == target)
|
||||
record('camera_observed', target=target, owner_id=owner()['id'])
|
||||
|
||||
|
||||
def open_chain(cycle):
|
||||
a_before, b_before = count('POPUP_A MAP'), count('POPUP_B MAP')
|
||||
o = owner()
|
||||
scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0
|
||||
top_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + 100)
|
||||
inject('move', top_x, 150)
|
||||
inject('left', top_x, 150)
|
||||
wait(lambda: count('POPUP_A MAP') > a_before)
|
||||
snapshot(f'{cycle}-S1-A')
|
||||
a_cfg = configured('POPUP_A')
|
||||
a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 40)
|
||||
a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 35)
|
||||
inject('move', a_x, a_y)
|
||||
inject('left', a_x, a_y)
|
||||
wait(lambda: count('POPUP_B MAP') > b_before)
|
||||
if readback_dir:
|
||||
(readback_dir / 'enable').touch()
|
||||
snapshot(f'{cycle}-S2-B')
|
||||
b_cfg = configured('POPUP_B')
|
||||
b_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + b_cfg[0] + 35)
|
||||
b_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + b_cfg[1] + 30)
|
||||
inject('move', b_x, b_y)
|
||||
inject('right', b_x, b_y)
|
||||
time.sleep(.12)
|
||||
snapshot(f'{cycle}-S3-visible')
|
||||
record('chain', cycle=cycle, owner_id=o['id'], scroll_x=scroll_x,
|
||||
a_configure=a_cfg, b_configure=b_cfg,
|
||||
top_global=[top_x, 150], a_global=[a_x, a_y], b_global=[b_x, b_y])
|
||||
|
||||
|
||||
def cycle_once(number):
|
||||
label = f'cycle-{number:03d}'
|
||||
record('cycle_start', cycle=number)
|
||||
snapshot(f'{label}-S0-reference')
|
||||
open_chain(label)
|
||||
if args.mode == 'private_mosaic':
|
||||
scroll(1280)
|
||||
snapshot(f'{label}-S4-hidden-immediate')
|
||||
time.sleep(.18)
|
||||
snapshot(f'{label}-S4-hidden-stable')
|
||||
inject('move', -700, 190)
|
||||
time.sleep(.1)
|
||||
snapshot(f'{label}-S4-hidden-hover')
|
||||
scroll(400)
|
||||
snapshot(f'{label}-S5-return-immediate')
|
||||
time.sleep(.18)
|
||||
snapshot(f'{label}-S5-return-stable')
|
||||
client_command('close_b')
|
||||
snapshot(f'{label}-S6-close-B-immediate')
|
||||
time.sleep(.18)
|
||||
snapshot(f'{label}-S6-close-B-stable')
|
||||
a_cfg = configured('POPUP_A')
|
||||
o = owner()
|
||||
scroll_x = workspace()['mosaic']['rows'][0]['scroll_x'] if args.mode == 'private_mosaic' else 0
|
||||
a_x = int(o['rect']['x'] - scroll_x + o['window_rect']['x'] + a_cfg[0] + 20)
|
||||
a_y = int(o['rect']['y'] + o['window_rect']['y'] + a_cfg[1] + 20)
|
||||
before_a = len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1', client_text()))
|
||||
inject('move', a_x, a_y)
|
||||
inject('right', a_x, a_y)
|
||||
wait(lambda: len(re.findall(r'POINTER_BUTTON target=POPUP_A[^\n]*button=273 state=1',
|
||||
client_text())) > before_a)
|
||||
snapshot(f'{label}-S6-A-interaction')
|
||||
client_command('close_a')
|
||||
snapshot(f'{label}-S7-close-A-immediate')
|
||||
time.sleep(.18)
|
||||
snapshot(f'{label}-S7-close-A-stable')
|
||||
record('cycle_end', cycle=number, owner_id=owner()['id'], client_alive=client.poll() is None)
|
||||
|
||||
|
||||
compositor_log = (run / 'compositor.log').open('w')
|
||||
files.append(compositor_log)
|
||||
compositor = None
|
||||
failure = None
|
||||
try:
|
||||
compositor = subprocess.Popen([str(binary), '-c', str(run / 'config'), '-d'],
|
||||
env=compositor_env, stdout=compositor_log,
|
||||
stderr=subprocess.STDOUT, start_new_session=True)
|
||||
def sockets():
|
||||
s = next(iter(runtime.glob('sway-ipc.*.sock')), None)
|
||||
w = next((p for p in runtime.glob('wayland-*') if p.suffix != '.lock'), None)
|
||||
return (s, w) if s and w else None
|
||||
sock, way = wait(sockets)
|
||||
env.update(SWAYSOCK=str(sock), WAYLAND_DISPLAY=way.name)
|
||||
time.sleep(1.0)
|
||||
snapshot('startup')
|
||||
pointer = spawn([str(lab / 'pointer-daemon'), '1600', '600'], 'pointer.log', stdin=True)
|
||||
wait(lambda: 'POINTER_READY' in (run / 'pointer.log').read_text())
|
||||
command('workspace 1')
|
||||
command('layout mosaic' if args.mode == 'private_mosaic' else 'layout splith')
|
||||
spawn(['kitty', '--class', 'A', '-o', 'cursor_blink_interval=0',
|
||||
'-o', 'background=#aa2222', '/usr/bin/sleep', '900'], 'A.log')
|
||||
wait(lambda: len(workspace()['nodes']) >= 1)
|
||||
client = spawn([str(lab / 'nested-client'), 'rot_cross'], 'client.log', stdin=True, debug=True)
|
||||
wait(lambda: owner() and 'TOPLEVEL MAP' in client_text())
|
||||
for name in ('C', 'D'):
|
||||
spawn(['kitty', '--class', name, '-o', 'cursor_blink_interval=0',
|
||||
'/usr/bin/sleep', '900'], f'{name}.log')
|
||||
time.sleep(.25)
|
||||
command('workspace 2')
|
||||
spawn(['stdbuf', '-oL', 'wev'], 'wev.log')
|
||||
wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev'))
|
||||
command('workspace 1')
|
||||
command('[app_id="org.mosaic.NestedGrabProbe"] focus')
|
||||
if args.mode == 'private_mosaic':
|
||||
scroll(640)
|
||||
record('initial_camera', value=640)
|
||||
time.sleep(.25)
|
||||
# EXPERIMENTAL fragment inserted inside the inherited bounded runner's try.
|
||||
# WHY: persistence observations precede reference clicks that can dismiss grabs.
|
||||
# CONTRACT: four reserved dynamic actions per mode; no additional camera pair.
|
||||
dynamic_stage = 'D0'
|
||||
initial_ws_id = workspace()['id']
|
||||
initial_owner_id = owner()['id']
|
||||
ref_right = find(tree(), lambda n: n.get('app_id') == 'wev')['id']
|
||||
command('workspace 1')
|
||||
left_proc = spawn(['stdbuf','-oL','wev'], 'wev-left.log')
|
||||
wait(lambda: find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right))
|
||||
ref_left = find(tree(), lambda n: n.get('app_id') == 'wev' and n['id'] != ref_right)['id']
|
||||
command('shadows disable'); command('blur disable')
|
||||
|
||||
def closed():
|
||||
client_command('close_a')
|
||||
time.sleep(.12)
|
||||
|
||||
def calibrate(tag):
|
||||
outputs = {o['name']: o for o in ipc('-t','get_outputs') if o['active']}
|
||||
active = {w['output']:w for w in ipc('-t','get_workspaces') if w['visible']}
|
||||
for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]:
|
||||
name = active[out]['name']
|
||||
command(f'[con_id={con_id}] move container to workspace {name}')
|
||||
command(f'[con_id={con_id}] floating enable')
|
||||
command(f'[con_id={con_id}] border none')
|
||||
command(f'[con_id={con_id}] resize set 200 150')
|
||||
ox=outputs[out]['rect']['x']+50; oy=400
|
||||
command(f'[con_id={con_id}] move absolute position {ox} {oy}')
|
||||
time.sleep(.25)
|
||||
# WHY: after return both references can temporarily share workspace 1.
|
||||
# CONTRACT: relocate BOTH before scoring input, avoiding mutual occlusion.
|
||||
for out, con_id, logname in [('HEADLESS-1',ref_left,'wev-left.log'),('HEADLESS-2',ref_right,'wev.log')]:
|
||||
ox=outputs[out]['rect']['x']+50; oy=400
|
||||
text=lambda: (run/logname).read_text()
|
||||
# Force a different surface, then accept actual enter or motion.
|
||||
inject('move', outputs['HEADLESS-2' if out=='HEADLESS-1' else 'HEADLESS-1']['rect']['x']+10,10)
|
||||
before=len(text());inject('move',ox+20,oy+30)
|
||||
pattern=r'(?:enter:[^\n]*|motion:[^\n]*)x, y: ([\d.]+), ([\d.]+)'
|
||||
wait(lambda: re.search(pattern,text()[before:]))
|
||||
ex,ey=map(float,re.findall(pattern,text()[before:])[-1])
|
||||
if ex != 20 or not 0 <= ey < 100:
|
||||
raise RuntimeError(f'calibration interior mismatch {tag} {out}: {ex},{ey}')
|
||||
before=len(text());inject('move',ox+60,oy+50)
|
||||
wait(lambda: f'x, y: {ex+40:.6f}, {ey+20:.6f}' in text()[before:])
|
||||
before=len(text())
|
||||
inject('left',ox+60,oy+50);inject('right',ox+60,oy+50)
|
||||
wait(lambda: 'button: 272' in text()[before:] and 'button: 273' in text()[before:])
|
||||
record('pointer_calibration',tag=tag,output=out,con_id=con_id,global_point=[ox+60,oy+50],local_expected=[ex+40,ey+20],surface_origin=[ox+20-ex,oy+30-ey],passed=True)
|
||||
for x in (-2,-1,0,1,2):inject('move',x,550)
|
||||
inject('move',outputs['HEADLESS-1']['rect']['x']+110,450)
|
||||
command('workspace 1')
|
||||
|
||||
def stable(tag, expected_owner):
|
||||
# CONTRACT: repeated geometry and paired settled captures are evidence,
|
||||
# not proof that intermediate configurations each had a presentation.
|
||||
time.sleep(.35)
|
||||
before=ipc('-t','get_outputs')
|
||||
time.sleep(.12)
|
||||
after=ipc('-t','get_outputs')
|
||||
if before != after:
|
||||
raise RuntimeError(f'unstable output IPC at {tag}')
|
||||
ws=workspace();o=owner()
|
||||
current=next(w for w in ipc('-t','get_workspaces') if w['id']==initial_ws_id)
|
||||
if ws['id']!=initial_ws_id or o['id']!=initial_owner_id or current['output']!=expected_owner:
|
||||
raise RuntimeError(f'workspace/owner identity mismatch at {tag}')
|
||||
snapshot(tag)
|
||||
time.sleep(.16);snapshot(tag+'-repeat')
|
||||
record('identity',tag=tag,workspace_id=ws['id'],owner_id=o['id'],output=current['output'],layout=ws['layout'],order=[n['id'] for n in ws['nodes']],mosaic=ws.get('mosaic'),focused=current['focused'])
|
||||
|
||||
def owner_hit(tag):
|
||||
o=owner();ws=workspace()
|
||||
scroll_x=ws['mosaic']['rows'][0]['scroll_x'] if args.mode=='private_mosaic' else 0
|
||||
point=[int(o['rect']['x']-scroll_x+o['window_rect']['x']+100),150]
|
||||
before=len(client_text());inject('move',*point);inject('right',*point)
|
||||
delivered='POINTER_BUTTON target=TOPLEVEL' in client_text()[before:]
|
||||
record('owner_hit',tag=tag,point=point,delivered=delivered,events=client_text()[before:])
|
||||
if not delivered:raise RuntimeError(f'visible owner input failed {tag}')
|
||||
|
||||
|
||||
# WHY: exact colors identify PNG orientation before interpreting output pixels.
|
||||
# CONTRACT: marker positions/colors were fixed before the first transition.
|
||||
colors = {'TL':(255,32,32),'TR':(32,255,32),'BL':(32,32,255),
|
||||
'BR':(255,255,32),'C':(192,32,255)}
|
||||
def marker_gate(tag, rotated):
|
||||
im = Image.open(run / f'left-{tag}.png').convert('RGB')
|
||||
points = {name: [] for name in colors}
|
||||
reverse = {v:k for k,v in colors.items()}
|
||||
for y in range(im.height):
|
||||
for x in range(im.width):
|
||||
name = reverse.get(im.getpixel((x,y)))
|
||||
if name: points[name].append((x,y))
|
||||
centers = {}
|
||||
for name, positions in points.items():
|
||||
if len(positions) < 100:
|
||||
raise RuntimeError(f'{tag}: marker {name} absent ({len(positions)})')
|
||||
centers[name] = [sum(p[0] for p in positions)/len(positions),
|
||||
sum(p[1] for p in positions)/len(positions)]
|
||||
logical = (im.size == (1000,750) and
|
||||
centers['TL'][0] < centers['TR'][0] and
|
||||
centers['TL'][1] < centers['BL'][1])
|
||||
physical = (im.size == (750,1000) and
|
||||
centers['TL'][0] > centers['BL'][0] and
|
||||
centers['TL'][1] < centers['TR'][1])
|
||||
frame = 'logical-oriented PNG' if logical else 'physical-buffer-oriented PNG' if physical else 'unknown'
|
||||
record('marker_calibration', tag=tag, dimensions=list(im.size),
|
||||
centers=centers, counts={k:len(v) for k,v in points.items()},
|
||||
png_frame=frame)
|
||||
if (rotated and not (logical or physical)) or (not rotated and not logical):
|
||||
raise RuntimeError(f'{tag}: PNG frame not established')
|
||||
return frame
|
||||
|
||||
# CONTRACT: static R90 from compositor startup; no transform command.
|
||||
marker_gate('startup', True)
|
||||
calibrate('static-R90')
|
||||
command('[app_id="org.mosaic.NestedGrabProbe"] focus')
|
||||
snapshot('reference')
|
||||
owner_hit('static-R90')
|
||||
open_chain('rot-cross')
|
||||
o=owner(); ws=workspace(); a_cfg=configured('POPUP_A'); b_cfg=configured('POPUP_B')
|
||||
sx=ws['mosaic']['rows'][0]['scroll_x'] if args.mode=='private_mosaic' else 0
|
||||
base_x=o['rect']['x']-sx+o['window_rect']['x']
|
||||
base_y=o['rect']['y']+o['window_rect']['y']
|
||||
a_box=[base_x+a_cfg[0],base_y+a_cfg[1],base_x+a_cfg[0]+a_cfg[2],base_y+a_cfg[1]+a_cfg[3]]
|
||||
b_box=[a_box[0]+b_cfg[0],a_box[1]+b_cfg[1],a_box[0]+b_cfg[0]+b_cfg[2],a_box[1]+b_cfg[1]+b_cfg[3]]
|
||||
record('applied_popup_geometry',owner_id=o['id'], owner_rect=o['rect'],window_rect=o['window_rect'],
|
||||
camera=sx,a_configure=a_cfg,b_configure=b_cfg,a_box=a_box,b_box=b_box,
|
||||
a_h1=[max(-800,a_box[0]),a_box[1],min(0,a_box[2]),a_box[3]],
|
||||
a_h2=[max(0,a_box[0]),a_box[1],min(800,a_box[2]),a_box[3]],
|
||||
b_h1=[max(-800,b_box[0]),b_box[1],min(0,b_box[2]),b_box[3]],
|
||||
b_h2=[max(0,b_box[0]),b_box[1],min(800,b_box[2]),b_box[3]])
|
||||
if not (b_box[0] < 0 < b_box[2] and b_box[2]>=16):
|
||||
record('geometry_gate',result='NOT EXERCISED',box=b_box)
|
||||
else:
|
||||
record('geometry_gate',result='CROSSING',box=b_box)
|
||||
# WHY: evaluate a shared y within B and scan both sides of the boundary.
|
||||
y=int((max(0,b_box[1])+min(600,b_box[3]))/2)
|
||||
for x in range(-3,4):
|
||||
before=len(client_text()); inject('move',x,y)
|
||||
record('border_hover',point=[x,y],client_events=client_text()[before:])
|
||||
inside_x=max(-30,int((max(-800,b_box[0])+min(0,b_box[2]))/2))
|
||||
before=len(client_text()); inject('move',inside_x,y); inject('right',inside_x,y)
|
||||
record('visible_b_button',point=[inside_x,y],client_events=client_text()[before:])
|
||||
snapshot('after-visible-button')
|
||||
# A left/right roles may dismiss on exterior click. Preserve it last.
|
||||
if b_box[2]>0:
|
||||
outside_x=min(30,int(b_box[2]/2))
|
||||
before=len(client_text()); inject('move',outside_x,y); inject('right',outside_x,y)
|
||||
record('forbidden_b_button',point=[outside_x,y],client_events=client_text()[before:])
|
||||
time.sleep(.15)
|
||||
snapshot('after-forbidden-button')
|
||||
|
||||
except Exception as exc:
|
||||
failure = repr(exc)
|
||||
record('failure', error=failure)
|
||||
finally:
|
||||
for proc in children + ([compositor] if compositor is not None else []):
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
for proc in children + ([compositor] if compositor is not None else []):
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.wait(timeout=5)
|
||||
(run / 'exit-status.json').write_text(json.dumps({
|
||||
'failure': failure, 'compositor': None if compositor is None else compositor.returncode,
|
||||
'children': [{'argv': p.args, 'returncode': p.returncode} for p in children],
|
||||
'captures': capture_count, 'capture_bytes': capture_bytes,
|
||||
'duration_seconds': time.monotonic() - started}, indent=2) + '\n')
|
||||
for file in files:
|
||||
file.close()
|
||||
shutil.rmtree(runtime)
|
||||
record('cleanup', runtime_removed=not runtime.exists())
|
||||
print(f'RUN={run} FAILURE={failure} CAPTURES={capture_count}')
|
||||
if failure:
|
||||
raise SystemExit(1)
|
||||
40
tooling/probes/step20c-rotated-partial-popup/test-budget.py
Normal file
40
tooling/probes/step20c-rotated-partial-popup/test-budget.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Offline ledger tests; no compositor or campaign reservation."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from budget import Budget
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
lab = Path(directory)
|
||||
budget = Budget(lab)
|
||||
assert budget.reserve('control')['runs'] == ['control']
|
||||
try:
|
||||
budget.reserve('control')
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('duplicate accepted')
|
||||
assert budget.reserve('private_standard')['runs'][-1] == 'private_standard'
|
||||
assert budget.reserve('private_mosaic')['runs'][-1] == 'private_mosaic'
|
||||
try:
|
||||
budget.reserve('control')
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('exhaustion bypass')
|
||||
try:
|
||||
Budget(lab)
|
||||
except BlockingIOError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('lock bypass')
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
lab = Path(directory)
|
||||
(lab / 'runs').mkdir()
|
||||
(lab / 'runs' / 'failed').mkdir()
|
||||
try:
|
||||
Budget(lab).read()
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError('missing ledger accepted')
|
||||
print('PASS persistence, duplicate, exhaustion, lock, missing-ledger')
|
||||
Loading…
Reference in a new issue