Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions electron/src/renderer/src/components/DpcWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ const VIEW_OPTIONS: readonly { value: View; label: string }[] = [
{ value: 'curl', label: 'Curl' },
]

/** `dpc.BEAM_SHAPES` — the beam region's shape, or off. */
type BeamShape = 'off' | 'circle' | 'ring'
/** `dpc.BEAM_SHAPES`. There is no "off": the region is always on the pattern,
* because it IS what the centre of mass is taken over. */
type BeamShape = 'circle' | 'ring'
const BEAM_SHAPES: readonly { value: BeamShape; label: string }[] = [
{ value: 'off', label: 'Off' },
{ value: 'circle', label: 'Circle' },
{ value: 'ring', label: 'Ring' },
]
Expand Down Expand Up @@ -99,7 +99,7 @@ const DEFAULTS: DpcSaved = {
halfSquareWidth: 0,
centerMode: 'corners',
cornerFraction: 0.05,
beamShape: 'off',
beamShape: 'circle',
beamCx: 0.0,
beamCy: 0.0,
beamR: 0.0,
Expand Down Expand Up @@ -267,7 +267,7 @@ export function DpcWizard({ caretPos, windowId, sendAction, onClose }: Props) {
// it. Same shape as the Crop / Center-Zero-Beam drag→field round trip.
useWizardEvent('spyde:dpc_region', windowId, (d) => {
const r: Region = {
shape: String(d.shape ?? 'off') as BeamShape,
shape: String(d.shape ?? 'circle') as BeamShape,
cx: Number(d.cx ?? 0), cy: Number(d.cy ?? 0),
r: Number(d.r ?? 0), r_inner: Number(d.r_inner ?? 0),
brightness: d.brightness == null ? null : Number(d.brightness),
Expand Down Expand Up @@ -433,7 +433,8 @@ export function DpcWizard({ caretPos, windowId, sendAction, onClose }: Props) {
))}
</div>
</Field>
{beamShape !== 'off' && (
{/* Always rendered: there is no shape that hides these. */}
{(
<>
<Field label="Radius (px)">
<NumInput testid="dpc-beam-r" value={round1(beamR)} step="1"
Expand Down Expand Up @@ -641,16 +642,25 @@ function Info({ text, testid }: { text: string; testid: string }) {
*/
function BeamReadout({ region, shape }: { region: Region | null; shape: BeamShape }) {
const b = region?.brightness
// The region's geometry, as the BACKEND currently holds it. Not shown — it is
// already drawn on the pattern — but a drag test has no other truthful source
// for it: read off the figure's pixels, a circle that runs to the edge of the
// frame is clipped, and the centroid of what is left moves the wrong way.
const geom = {
'data-cx': region ? region.cx.toFixed(3) : undefined,
'data-cy': region ? region.cy.toFixed(3) : undefined,
'data-r': region ? region.r.toFixed(3) : undefined,
}
if (b == null || !Number.isFinite(b)) {
return (
<div data-testid="dpc-beam-readout" style={S.hint}>
<div data-testid="dpc-beam-readout" {...geom} style={S.hint}>
Drag the {shape === 'ring' ? 'ring' : 'circle'} onto the direct beam.
</div>
)
}
const good = b >= 2
return (
<div data-testid="dpc-beam-readout" data-brightness={b.toFixed(2)}
<div data-testid="dpc-beam-readout" data-brightness={b.toFixed(2)} {...geom}
style={{ ...readoutStyle, color: good ? '#a6e3a1' : '#f9e2af' }}>
{b.toFixed(1)}× frame average
<span style={S.hint}>
Expand Down
10 changes: 9 additions & 1 deletion electron/tests/_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,15 @@ function backendErrorLines(backendOrLines) {
// full-path colon form `dbus/bus.cc:405]` (format changed upstream, so an
// Electron bump must not silently turn infrastructure noise back into
// "backend errors").
&& !/:(ERROR|FATAL):[a-z_0-9/]+\.(cc|mm)[(:]\d+[)\]]/.test(l))
&& !/:(ERROR|FATAL):[a-z_0-9/]+\.(cc|mm)[(:]\d+[)\]]/.test(l)
// A CANCELLED compute, reported by the scheduler at ERROR. Any spec that
// supersedes a whole-scan pass mid-flight (drag a virtual detector, drag
// the DPC beam region) produces one per superseded pass: the client is
// still gathering the result of a graph whose keys the scheduler has just
// forgotten. It is the cancellation WORKING — the alternative, letting the
// pass run to the end, is the bug. Deliberately narrow: only the gather of
// a key in a released state, so a genuine worker loss still fails the spec.
&& !/Couldn't gather keys.*'(forgotten|cancelled|released)'/.test(l))
}

/**
Expand Down
255 changes: 255 additions & 0 deletions electron/tests/dpc_live_region.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
/**
* dpc_live_region.spec.ts — the DPC field map must TRACK the beam region as it
* is dragged, the same way a virtual image tracks its detector ROI.
*
* Nothing headless can see this. The Python suite can prove a drag frame asks
* for a re-measure and that the frames coalesce; it cannot prove the map on
* screen actually changed, which is the entire user-visible claim. Before the
* live lane, this exact drag produced ZERO repaints across 6.4 s — the map sat
* frozen for the whole gesture and only caught up on release, because
* `_on_region_drag` armed its debounce on `pointer_up` alone.
*
* So the measurement is: sample the map's own CANVAS pixels on every drag step
* and count how many DISTINCT frames appear. Measured both ways on this drag:
* release-only scores 0, the live lane scores 12 of 24.
*
* Run: npx playwright test tests/dpc_live_region.spec.ts --project=electron \
* --reporter=line --retries=0
*/
import { test, expect } from '@playwright/test'
import * as fs from 'fs'
import * as path from 'path'
const {
launchApp, backendAction, waitForSubwindowCount, backendErrorLines,
} = require('./_harness.cjs')

const SHOTS = path.join(__dirname, '..', 'dpc_live_shots')
const DPC_TITLE = /DPC Field Map/

/** Steps sampled during the drag. Each is one real pointer move. */
const STEPS = 24

/**
* How many of those steps must show a CHANGED map.
*
* Deliberately far below `STEPS`: the pass is superseded and restarted several
* times a second, so how many repaints land inside a 120 ms sampling window is
* a property of the machine, not of the code. What is being pinned is the
* difference between "tracks the pointer" and "frozen until release", and any
* value well above zero says that. Measured: 0 release-only, 12 with the lane.
*/
const MIN_LIVE_FRAMES = 6

test('the DPC field map tracks the beam region while it is dragged', async () => {
test.setTimeout(600_000)
fs.mkdirSync(SHOTS, { recursive: true })

const ctx = await launchApp({ dask: true, env: { SPYDE_LOG_LEVEL: 'INFO' } })
const { page, backend, assertNoJsErrors } = ctx

try {
// LAZY + chunked, so the pass streams through the real cluster and takes
// long enough for "does it restart mid-drag?" to be answerable at all. On
// eager data every pass finishes instantly and the question is vacuous.
await backendAction(page, 'load_test_data_dpc',
{ nav: 48, sig: 64, lazy: true, nav_chunk: 8 })
await waitForSubwindowCount(page, 2, 180_000)
await page.waitForTimeout(3_000)
await page.screenshot({ path: `${SHOTS}/01-loaded.png` })

const sig = page.getByTestId('subwindow')
.filter({ has: page.getByTestId('subwindow-title').filter({ hasText: /S-Synthetic DPC$/ }) })
.last()
await sig.getByTestId('subwindow-title').click()
await sig.getByTestId('subwindow-titlebar').hover()
await sig.getByTestId('action-btn-DPC').click()
await expect(sig.getByTestId('dpc-wizard')).toBeVisible({ timeout: 60_000 })

// The first pass has to land before we drag, or "the map changed" would be
// measuring the opening pass filling in rather than the drag.
await expect.poll(
() => sig.getByTestId('dpc-centering').getAttribute('data-worst'),
{ timeout: 180_000, message: 'the opening pass never produced a field' })
.toMatch(/\d/)
await page.screenshot({ path: `${SHOTS}/02-first-pass.png` })

await sig.getByTestId('dpc-tab-Center').click()
await sig.getByTestId('dpc-beam-circle').click()
await expect(sig.getByTestId('dpc-beam-r')).toBeVisible()
await page.waitForTimeout(4_000)
await page.screenshot({ path: `${SHOTS}/03-beam-on.png` })

const dpcWin = page.getByTestId('subwindow')
.filter({ has: page.getByTestId('subwindow-title').filter({ hasText: DPC_TITLE }) })
.first()
await expect(dpcWin).toBeVisible({ timeout: 60_000 })

/**
* A fingerprint of the MAP'S OWN pixels, read off its canvases.
*
* NOT a screenshot of the window. The "Calculating…" chip sits on top of
* the figure and pulses on a 1.6 s CSS animation, so a screenshot hash
* changes on almost every sample while a pass is running — which is most of
* a drag. Measured with a window screenshot, the RELEASE-ONLY behaviour this
* spec exists to catch scored 22 of 24 "live frames" and the spec passed. A
* canvas readback sees only what the figure drew.
*/
const mapHash = async (): Promise<string> => {
const host = await dpcWin.elementHandle()
if (!host) return 'no-window'
const parts: string[] = []
for (const frame of page.frames()) {
const el = await frame.frameElement().catch(() => null)
if (!el) continue
const inside = await host.evaluate(
(w, f) => w.contains(f as Node), el).catch(() => false)
if (!inside) continue
parts.push(await frame.evaluate(() => {
let h = 0, n = 0
for (const c of Array.from(document.querySelectorAll('canvas'))) {
const cv = c as HTMLCanvasElement
const g = cv.getContext('2d')
if (!g || !cv.width) continue
const d = g.getImageData(0, 0, cv.width, cv.height).data
for (let i = 0; i < d.length; i += 41) { h = (h * 31 + d[i]) | 0; n++ }
}
return `${n}:${h}`
}).catch(() => 'err'))
}
return parts.join('|')
}

/** Where the region IS, as the backend holds it — echoed to the caret on
* every committed move. */
const regionGeometry = async () => {
const el = sig.getByTestId('dpc-beam-readout')
const [cx, cy] = await Promise.all([
el.getAttribute('data-cx'), el.getAttribute('data-cy'),
])
return cx == null || cy == null
? null : { cx: Number(cx), cy: Number(cy) }
}

const figureFrame = async () => {
const host = await sig.elementHandle()
if (!host) return null
for (const frame of page.frames()) {
const el = await frame.frameElement().catch(() => null)
if (!el) continue
const inside = await host.evaluate(
(w, f) => w.contains(f as Node), el).catch(() => false)
if (!inside) continue
const ok = await frame.evaluate(
() => !!(window as any).__apl_imgToCanvas
&& !!(window as any)._aplTiming).catch(() => false)
if (ok) return { frame, el }
}
return null
}

/**
* The region's centre, in PAGE coordinates.
*
* Both halves come from something authoritative rather than from the
* picture: the geometry is the backend's own and the image→canvas transform
* is anyplotlib's own (`__apl_imgToCanvas`, published for exactly this).
*
* NOT the centroid of the circle's teal pixels, which is what this did
* first. The region opens as the INSCRIBED circle, so a nudge in any
* direction runs it off the frame; the visible arc is then clipped on that
* side and its centroid moves the OTHER way. A grab check built on it reads
* "never moved" for a drag that moved perfectly well — indistinguishable
* from the bug this spec exists to catch. And aiming has to be exact
* either way: the centre hot-spot is 9 px, and a miss lands on the radius
* handle or on nothing.
*/
const centrePoint = async () => {
const g = await regionGeometry()
const f = await figureFrame()
if (!g || !f) return null
const hit = await f.frame.evaluate(([x, y]: number[]) => {
const w: any = window
for (const id of Object.keys(w._aplTiming || {})) {
const c = w.__apl_imgToCanvas(id, x, y)
if (!c) continue
// The panel's own canvas — the biggest one, since the axis strips,
// the colour bar and the status line are canvases too. The image,
// overlay and marker layers share its box, which is the box
// `__apl_imgToCanvas` returns coordinates in.
const cv = Array.from(document.querySelectorAll('canvas'))
.map((c2) => (c2 as HTMLCanvasElement).getBoundingClientRect())
.sort((a, b) => b.width * b.height - a.width * a.height)[0]
if (!cv) return null
return { x: cv.left + c[0], y: cv.top + c[1] }
}
return null
}, [g.cx, g.cy]).catch(() => null)
if (!hit) return null
const fb = await f.el.boundingBox()
return { x: (fb?.x ?? 0) + hit.x, y: (fb?.y ?? 0) + hit.y }
}

const sigBox = await sig.boundingBox()
const before = await regionGeometry()
// `_aplTiming` gains a panel's entry only once that panel has drawn twice,
// so on a slow machine the transform can simply not be there yet. Wait for
// it rather than reporting it as a missing circle.
let start = await centrePoint()
for (let i = 0; i < 40 && !start; i++) {
await page.waitForTimeout(500)
start = await centrePoint()
}
if (!sigBox || !before || !start) throw new Error('could not find the beam circle')

// Prove the grab took before measuring anything (see centrePoint).
let grabbed = false
for (let attempt = 0; attempt < 4 && !grabbed; attempt++) {
await page.mouse.move(start.x, start.y)
await page.mouse.down()
await page.mouse.move(start.x, start.y + 12)
await page.waitForTimeout(400)
const now = await regionGeometry()
grabbed = !!now && Math.abs(now.cy - before.cy) > 0.5
if (!grabbed) {
await page.mouse.up()
await page.waitForTimeout(400)
}
}
expect(grabbed, 'never managed to grab the beam circle').toBe(true)

const seen: string[] = [await mapHash()]
for (let i = 1; i <= STEPS; i++) {
const t = i / STEPS
await page.mouse.move(
start.x + Math.sin(t * Math.PI * 2) * sigBox.width * 0.12,
start.y + t * sigBox.height * 0.18)
await page.waitForTimeout(120)
const h = await mapHash()
if (h !== seen[seen.length - 1]) seen.push(h)
}
await page.mouse.up()
const liveFrames = seen.length - 1
await page.screenshot({ path: `${SHOTS}/04-drag-end.png` })

console.log(`[dpc-live] distinct map frames across ${STEPS} drag steps: ${liveFrames}`)
expect(liveFrames,
'the field map did not repaint while the beam region was dragged — ' +
'the re-measure is waiting for pointer_up again')
.toBeGreaterThanOrEqual(MIN_LIVE_FRAMES)

// The region moved, so the RESTING field must differ from the one the map
// opened with. Without the trailing settle the map would be left showing
// whichever superseded partial happened to be up when the pointer stopped.
await expect.poll(mapHash, {
timeout: 90_000,
message: 'the settle never measured the resting region',
}).not.toBe(seen[seen.length - 1])
await page.screenshot({ path: `${SHOTS}/05-after-settle.png` })

expect(backendErrorLines(backend), 'the backend reported an error')
.toEqual([])
} finally {
assertNoJsErrors()
await ctx.app?.close()
}
})
26 changes: 11 additions & 15 deletions electron/tests/dpc_workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,20 +366,22 @@ test('the Center tab offers all three references, each with its own furniture',
// The caret is still open from the previous test, parked on its Map tab.
await page.getByTestId('dpc-tab-Center').click()

// The beam region FIRST — Manual below depends on it being on, since the
// region's centre is what Manual adopts.
// The beam region FIRST — Manual below depends on it, since the region's
// centre is what Manual adopts.
//
// One draggable shape that BOTH masks the centre of mass and marks the zero
// beam. Toggling Circle→Ring swaps the anyplotlib widget type, so this
// checks the shape actually changed on the PATTERN, not just in the caret.
// beam. It is ALWAYS on the pattern: there is no "off", because the region
// is what the centre of mass is taken over, and switching it off only meant
// taking the whole frame with no handle to grab. Toggling Circle→Ring swaps
// the anyplotlib widget type, so this checks the shape actually changed on
// the PATTERN, not just in the caret.
const beamPixels = () => colorPixelsIn(page, sourceWindow(page), IS_BEAM)
expect(await beamPixels(), 'the region should start off').toBe(0)
await expect.poll(beamPixels, {
timeout: 30_000, message: 'the beam region was not on the pattern at open',
}).toBeGreaterThan(0)

await page.getByTestId('dpc-beam-circle').click()
await expect(page.getByTestId('dpc-beam-r')).toBeVisible()
await expect.poll(beamPixels, {
timeout: 30_000, message: 'the beam circle never appeared on the pattern',
}).toBeGreaterThan(0)
await expect(page.getByTestId('dpc-beam-readout'))
.toHaveAttribute('data-brightness', /\d/, { timeout: 30_000 })
await shot('10-beam-circle')
Expand All @@ -401,14 +403,8 @@ test('the Center tab offers all three references, each with its own furniture',
await page.getByTestId('dpc-info-beam').click()
await expect(page.getByTestId('dpc-info-beam-text')).toHaveCount(0)

await page.getByTestId('dpc-beam-off').click()
await expect.poll(beamPixels, {
timeout: 30_000, message: 'turning the region off left its widget behind',
}).toBe(0)

// Manual — the beam region IS the marker, so this is one click, not a
// separate crosshair to place. Turn the region back on so there is a centre
// to adopt.
// separate crosshair to place. Back to a circle for it.
await page.getByTestId('dpc-beam-circle').click()
await expect.poll(beamPixels, { timeout: 30_000 }).toBeGreaterThan(0)
await page.getByTestId('dpc-center-mode').click()
Expand Down
Loading
Loading