Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
3e6e7f4
test(MOR-4274): pin scope session demand and restore contract
morozsm Nov 8, 2026
b7e869f
test(MOR-4274): pin profile fixed-edge ranges
morozsm Nov 8, 2026
facbab3
test(MOR-4274): specify shared scope session state transitions
morozsm Nov 8, 2026
06de09f
test(MOR-4274): cover wire lifecycle and websocket pause integration
morozsm Nov 8, 2026
a32f9a3
test(MOR-4274): pin fixed edge profile resolution
morozsm Nov 8, 2026
849060a
test(MOR-4274): pin profile fixed-edge resolution
morozsm Nov 8, 2026
70f51cd
test(MOR-4274): separate behavioral wire regression evidence
morozsm Nov 8, 2026
6272541
test(MOR-4274): reconcile scope session demand and lifecycle contract
morozsm Nov 8, 2026
d65335a
test(MOR-4274): consolidate reconciled session tests in one module
morozsm Nov 8, 2026
8f1fabe
test(MOR-4274): exercise acknowledged scope controls through simulator
morozsm Nov 8, 2026
19123f8
test(MOR-4274): migrate lifecycle coverage to the single scope session
morozsm Nov 9, 2026
165e35a
test(MOR-4274): pin paused startup and demand failure cleanup
morozsm Nov 9, 2026
6493c5d
test(MOR-4274): require minimal viewport restoration writes
morozsm Nov 9, 2026
7cf7cb6
test(MOR-4274): pin the shared fixed-slot observation across receivers
morozsm Nov 9, 2026
505eacb
test(MOR-4274): keep receiver fixed-edge slots independent
morozsm Nov 9, 2026
08a5a13
test(MOR-4274): cancel pending views on pre-readiness release
morozsm Nov 9, 2026
cff1b92
test(MOR-4274): isolate wire restore and transmit deferral regressions
morozsm Nov 9, 2026
ffa10b5
test(MOR-4274): exercise session cancellation through real lifecycle …
morozsm Nov 9, 2026
2d7aa63
test(MOR-4274): distinguish stale out-of-range state from fresh arrival
morozsm Nov 9, 2026
fba69ad
test(MOR-4274): prevent health snapshots delaying first resume
morozsm Nov 9, 2026
1af93a7
test(MOR-4274): preserve actual view observations after restoration
morozsm Nov 9, 2026
85ab765
test(MOR-4274): skip unchanged fixed-slot programming and restore
morozsm Nov 9, 2026
fb990d8
test(MOR-4274): keep receiver snapshots from retargeting shared controls
morozsm Nov 9, 2026
60610be
test(MOR-4274): format reconciled regression coverage
morozsm Nov 9, 2026
746f03e
feat(MOR-4274): unify acknowledged scope demand and view control
morozsm Nov 9, 2026
aabf991
test(MOR-4274): pin SDK lifecycle and real RX review regressions
morozsm Nov 9, 2026
a27e429
test(MOR-4274): exercise the pending output restoration failure
morozsm Nov 9, 2026
35a6eb1
test(MOR-4274): activate controller before scope and provide fresh VE…
morozsm Nov 9, 2026
f53ff7c
test(MOR-4274): exercise a real SDK reconnect and reuse its controlle…
morozsm Nov 9, 2026
bd92997
test(MOR-4274): cover shared fixed-slot readback and format review re…
morozsm Nov 9, 2026
0977b8d
test(MOR-4274): follow selected receiver and Core frame publication c…
morozsm Nov 9, 2026
fced7a7
fix(MOR-4274): reconcile Core lifecycle and receiver-qualified observ…
morozsm Nov 9, 2026
592aecb
test(MOR-4274): preserve MAIN selection during SUB FIXED capture
morozsm Nov 9, 2026
c67c53f
fix(MOR-4274): separate fixed-slot and selected-edge observations
morozsm Nov 9, 2026
36b9aa3
test(MOR-4274): reapply initially unchanged span after panel drift
morozsm Nov 9, 2026
656e444
fix(MOR-4274): compare current observations when suppressing scope wr…
morozsm Nov 9, 2026
b9acc01
test(MOR-4274): retire standalone control setters before health recon…
morozsm Nov 9, 2026
f042603
fix(MOR-4274): retire acknowledged scope settings without demand
morozsm Nov 9, 2026
0e68943
test(MOR-4274): require arrival evidence and hidden transition in hea…
morozsm Nov 9, 2026
bb0bb69
test(MOR-4274): route health regression through Core frame publication
morozsm Nov 9, 2026
86f062d
test(MOR-4274): resume retained SDK demand after late serial identity
morozsm Nov 9, 2026
3466294
fix(MOR-4274): resume scope after every serial identity completion
morozsm Nov 9, 2026
4a10a58
test(MOR-4274): refresh selected display when returning from SUB
morozsm Nov 9, 2026
e5d9c30
fix(MOR-4274): retain dedup facts only for the last selected stream
morozsm Nov 9, 2026
d618f27
test(MOR-4274): reject invalid scope settings before deferral
morozsm Nov 9, 2026
158970e
fix(MOR-4274): validate scope requests before accepting deferred work
morozsm Nov 9, 2026
1121bba
test(MOR-4274): format frozen review regression coverage
morozsm Nov 9, 2026
3520f55
fix(MOR-4274): retain restored observations and verify builder delega…
morozsm Nov 9, 2026
013c174
test(MOR-4274): reproduce stale panel and output facts
morozsm Nov 9, 2026
c20db60
fix(MOR-4274): consume validated panel and output readbacks
morozsm Nov 9, 2026
82e9ebe
test(MOR-4274): require SUB and concurrent MAIN streaming from single…
morozsm Nov 9, 2026
6625185
test(MOR-4274): load both receiver waveform fixtures for streaming re…
morozsm Nov 9, 2026
eda6453
fix(MOR-4274): restore dual streaming through the shared scope session
morozsm Nov 9, 2026
a6bfa60
test(MOR-4274): reproduce stale fixed slot across WebSocket sessions
morozsm Nov 9, 2026
08aefba
fix(MOR-4274): refresh applied facts on each scope acquisition
morozsm Nov 9, 2026
9d8479d
test(MOR-4274): reproduce scope startup failures killing command exec…
morozsm Nov 9, 2026
61e931f
fix(MOR-4274): keep scope recovery pending without blocking command s…
morozsm Nov 9, 2026
f77d411
test(MOR-4274): bind scope stress writer to real session contract
morozsm Nov 9, 2026
bca4ec3
test(MOR-4274): retain MAIN through refused scope view updates
morozsm Nov 9, 2026
b286f0d
fix(MOR-4274): preserve active scope after rejected view negotiation
morozsm Nov 9, 2026
0af70a6
test(MOR-4274): expose frozen spectrum and invisible view refusal
morozsm Nov 9, 2026
1271cad
fix(MOR-4274): retain live spectrum and display view refusals
morozsm Nov 9, 2026
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
24 changes: 24 additions & 0 deletions docs/api/scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,3 +280,27 @@ Combined image: spectrum on top, waterfall below.
**`grayscale`** — black (noise) → white (max)

Custom themes can be added to `scope_render.THEMES` dict with anchor points for linear interpolation.

## SDK lifecycle

`src/rigplane/runtime/_scope_runtime.py: ScopeRuntimeMixin.enable_scope` and the
web radio source share `src/rigplane/runtime/scope_session.py: ScopeSession`.
Scope writes now await a CI-V acknowledgement for every completion policy:
`fast` and `strict` do not wait for frames; `verify` additionally waits for
scope activity. This changes the former fire-and-forget timing of `fast`.
`disable_scope` releases SDK demand; the last SDK or web release restores the
captured settings. The wire lifecycle is covered by
`tests/runtime/test_scope_session_wire.py: test_last_release_restores_wire_snapshot_once`.

Implicit FIXED range selection in
`src/rigplane/commands/scope.py: scope_set_fixed_edge` requires profile `ranges`;
callers can instead provide an explicit `range_index`. Negotiated FIXED views
use only the profiles that declare ranges and their frequency step. The shipped
IC-7610 data cites its CI-V reference in `rigs/ic7610.toml`; profiles without
that data negotiate CENTER views.

Hardware scope providers used by the web server must expose
`src/rigplane/runtime/scope_session.py: ScopeSessionProvider.scope_session`;
this replaces the web-owned writer adapter. Providers must feed frame arrival,
readiness, connection changes and TX observations into that session even when
web subscriptions are paused; Core does this from its RX and lifecycle paths.
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,9 @@ it.each(['AudioSpectrumPanel', 'AmberScope', 'AmberCockpit'] as const)(
flushSync(); expect(scopeChildProbe.pushed).toHaveBeenLastCalledWith(null);
mocks.channel.fire(scopeWire({ source: 'audio_fft', sourceId: 'audio_fft', receiver: null }));
flushSync(); expect(scopeChildProbe.pushed.mock.lastCall?.[0]).toBeInstanceOf(Uint8Array);
mocks.channel.message({ type: 'error', sourceId: 'audio_fft', receiver: null, message: 'source stopped' });
flushSync(); expect(scopeChildProbe.pushed).toHaveBeenLastCalledWith(null);
const retained = scopeChildProbe.pushed.mock.lastCall?.[0];
mocks.channel.message({ type: 'error', sourceId: 'audio_fft', receiver: null, message: 'view update refused' });
flushSync(); expect(scopeChildProbe.pushed.mock.lastCall?.[0]).toBe(retained);
mocks.channel.fire(scopeWire({ source: 'audio_fft', sourceId: 'audio_fft', receiver: null }));
flushSync(); expect(scopeChildProbe.pushed.mock.lastCall?.[0]).toBeInstanceOf(Uint8Array);
mocks.channel.disconnect();
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/components/spectrum/SpectrumPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import { getManagedAppTxController } from '../../lib/runtime/tx-controller/managed-app-host';
import type { ManagedTxState } from '../../lib/runtime/tx-controller/managed-state';
import SpectrumToolbar from './SpectrumToolbar.svelte';
import StatusIndicator from '../../lib/Button/StatusIndicator.svelte';
import { getBandPlanRegion, loadBandPlanConfig } from '$lib/stores/band-plan-region.svelte';
import BandPlanOverlay from './BandPlanOverlay.svelte';
import EiBiBrowser from './EiBiBrowser.svelte';
Expand Down Expand Up @@ -1304,6 +1305,7 @@
// R53: the panel keeps no pixels across a drop — replay may only paint a
// frame the sink actually delivered to THIS display since the last drop.
let samplePlaneLive = false;
let scopeError = $state<string | null>(null);
let cachedWhileOff = false;
let acceptScopeFrame: ((frame: ScopeFrame, slotKey: string) => void) | null = null;

Expand Down Expand Up @@ -1359,7 +1361,7 @@
clearSample();
if (sourceIsAudio) runtime.scope.registerPresentationDriver(presentationResources);
const scopeStream = runtime.scope.stream(sourceIsAudio ? 'audio_fft' : 'hardware');
const stopError = scopeStream.error.subscribe(error => { if (error) clearSample(); });
const stopError = scopeStream.error.subscribe(error => { scopeError = error; });
const stopGeometry = scopeStream.geometry.subscribe((geometry) => { frameGeometry = geometry; });
cachedWhileOff = false;
const acceptFrame = (frame: ScopeFrame, slotKey: string) => {
Expand Down Expand Up @@ -1471,6 +1473,15 @@
<ScopeSourceBadge source="sdr" sdr={sdrState} />
<ScopeSourceSelector sources={[...sdrOtherSources, 'sdr']} active="sdr" />
{/snippet}
{#snippet panelScopeStatus()}
{#if sdrSource}{@render sdrToolbarStatus()}
{:else if scopeStatusIndicator}{@render scopeStatusIndicator()}{/if}
{#if scopeError}
<span class="scope-refusal" role="status" title={scopeError}>
<StatusIndicator active color="amber" label={t('core.scope.viewRefused', { reason: scopeError })} />
</span>
{/if}
{/snippet}
{#if traceCandidate}
<div class="scope-trace-controls" aria-label="Scope render trace">
<button type="button" onclick={() => startTrace()} disabled={traceStatus === 'recording' || !scopeDemandOn || !scopeConnected}>Start trace</button>
Expand All @@ -1492,8 +1503,9 @@
tab instead. An audio-FFT source keeps its original no-toolbar
branch. -->
{:else if !hideToolbar}
<SpectrumToolbar bind:enableAvg bind:enablePeakHold bind:brtLevel bind:colorScheme bind:fullscreen bind:showBandPlan bind:hiddenLayers bind:showEiBi {scopeDemandOn} onScopeDemandChange={setScopeDemand} {hideSourceControls} {hideScopeControls} {hideAutoStepToggle} {scopeControls} scopePresets={scopePresetOptions} onScopePresetSelect={applyScopePreset} onScopeZoom={nudgeScopeZoom} scopeStatusIndicator={sdrSource ? sdrToolbarStatus : scopeStatusIndicator} />
<SpectrumToolbar bind:enableAvg bind:enablePeakHold bind:brtLevel bind:colorScheme bind:fullscreen bind:showBandPlan bind:hiddenLayers bind:showEiBi {scopeDemandOn} onScopeDemandChange={setScopeDemand} {hideSourceControls} {hideScopeControls} {hideAutoStepToggle} {scopeControls} scopePresets={scopePresetOptions} onScopePresetSelect={applyScopePreset} onScopeZoom={nudgeScopeZoom} scopeStatusIndicator={sdrSource || scopeStatusIndicator || scopeError ? panelScopeStatus : undefined} />
{/if}
{#if (audioFft || hideToolbar) && scopeError}{@render panelScopeStatus()}{/if}
<div
class="spectrum-split-region"
bind:this={splitRegion}
Expand Down Expand Up @@ -1668,6 +1680,7 @@
{/key}

<style>
.scope-refusal { min-width: 0; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.waterfall-history { position: absolute; inset: 0; }
.scope-hatch {
position: absolute;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4782,6 +4782,49 @@ describe('SpectrumPanel band-plan region wiring (MOR-3631)', () => {
});
});

describe('scope negotiation refusal (MOR-4274)', () => {
it('keeps the mounted spectrum advancing after refusal and SUB-only traffic', async () => {
const target = mountPanel();
emitFrame({ pixels: new Uint8Array([20, 40, 60]), sequence: 1 });
await flushWaterfallPaint();
const canvas = target.querySelector('.spectrum-area canvas');
expect(canvas).not.toBeNull();
scopeControllerHarness.stream('hardware').handleMessage({ type: 'error', message: 'View negotiation refused' });
flushSync();
for (let sequence = 2; sequence < 5; sequence += 1) {
emitFrame({ receiver: 1, sequence });
await flushWaterfallPaint();
spectrumRendererHarness.render.mockClear();
const pixels = new Uint8Array([20 * sequence, 40, 60]);
emitFrame({ pixels, sequence });
await flushWaterfallPaint();
expect(target.querySelector('.spectrum-area canvas')).toBe(canvas);
expect(spectrumRendererHarness.render).toHaveBeenCalled();
expect([...spectrumRendererHarness.render.mock.calls.at(-1)![1]]).toEqual([...pixels]);
}
scopeControllerHarness.stream('hardware').clearMode();
flushSync();
expect(target.querySelector('.spectrum-area canvas')).toBeNull();
});

it('shows the refusal through panel status alongside the live scope', async () => {
const target = mountPanel();
emitFrame(); await flushWaterfallPaint();
const stream = scopeControllerHarness.stream('hardware');
stream.handleMessage({ type: 'error', message: 'View negotiation refused' });
flushSync();
expect(target.querySelector('[data-testid="toolbar-scope-status"]')?.textContent).toContain('Scope view refused');
expect(target.querySelector('[data-testid="toolbar-scope-status"]')?.textContent).toContain('View negotiation refused');
expect(target.querySelector('.spectrum-area canvas')).not.toBeNull();
const request = stream.subscription!;
stream.handleMessage({ type: 'granted', ...request,
view: { startHz: 14_000_000, endHz: 14_100_000 },
coverage: { startHz: 14_000_000, endHz: 14_100_000 }, fps: request.maxFps });
flushSync();
expect(target.textContent).not.toContain('Scope view refused');
});
});

describe('golden wire through actual WebSocket transport (MOR-4270)', () => {
it('paints panel, LCD pixels and audio overlays from producer bytes with unchanged buffers', async () => {
const previousController = scopeControllerHarness;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"core.overlay.scopeSwitchToCtr": "Switch to CTR",
"core.scope.webglRequired": "WebGL required for the waterfall",
"core.scope.presets": "Scope presets",
"core.scope.viewRefused": "Scope view refused: {reason}",
"core.scope.preset.follow": "Follow VFO",
"core.scope.zoomIn": "Zoom in the panorama view",
"core.scope.zoomOut": "Zoom out the panorama view",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
"core.overlay.scopeSwitchToCtr": "CTR に切り替え",
"core.scope.webglRequired": "ウォーターフォールには WebGL が必要です",
"core.scope.presets": "スコープのプリセット",
"core.scope.viewRefused": "スコープ表示の要求が拒否されました: {reason}",
"core.scope.preset.follow": "VFO に追従",
"core.scope.zoomIn": "パノラマ表示を拡大",
"core.scope.zoomOut": "パノラマ表示を縮小",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/i18n/locales/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@
"core.overlay.scopeSwitchToCtr": "Переключить в CTR",
"core.scope.webglRequired": "Для водопада требуется WebGL",
"core.scope.presets": "Наблюдение панорамы",
"core.scope.viewRefused": "Запрос вида панорамы отклонён: {reason}",
"core.scope.preset.follow": "Следовать за VFO",
"core.scope.zoomIn": "Сузить обзор панорамы",
"core.scope.zoomOut": "Расширить обзор панорамы",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,35 @@ it('retries a refused startup follow subscription after observed facts change',
expect(publish.mock.lastCall?.[0].view).toEqual({ follow: 'vfo', spanHz: 100000 });
});

it.each([0, 1])('keeps MAIN pixels and geometry after a receiver %i update is refused during SUB traffic', receiver => {
const { value } = stream();
const main = Symbol('main'), sub = Symbol('sub');
value.updateSurfaceSubscription(main, request());
value.updateSurfaceSubscription(sub, { ...request(), receiver: 1 });
value.setSlotFilter(scopeSlotKey('radio', 0));
const ingress = (receiver: number) => value.ingress(ingressScopeFrame(
scopeWire({ sourceId: 'radio', receiver }),
{ source: 'radio', sourceId: 'radio', epoch: 1, mode: 3 },
));
ingress(0);
const geometry = get(value.geometry);
const frame = value.latest('radio:0');
expect(geometry).toMatchObject({ receiver: 0, hasPixels: true, freshness: 'fresh' });
expect(frame?.pixels).not.toBeNull();
value.updateSurfaceSubscription(main, { ...request(), view: { startHz: 14_000_000, endHz: 14_100_000 } });
value.handleMessage({ type: 'error', sourceId: 'radio', receiver, message: 'CI-V command 0x27 refused (NAK)' });
ingress(1);
expect(get(value.error)).toBe('CI-V command 0x27 refused (NAK)');
expect(get(value.geometry)).toBe(geometry);
expect(value.latest('radio:0')).toBe(frame);
value.setFreshness('stale');
expect(get(value.geometry)?.freshness).toBe('stale');
value.setSessionEpoch(2);
expect(get(value.geometry)).toBeNull();
expect(value.latest('radio:0')).toBeNull();
value.dispose();
});


it('observes native backing width, intersection and document visibility for demand only', () => {
const { value, publish } = stream();
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/runtime/scope-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ export class ScopeStream {
if (selected && this.matchingSubscription(granted)?.maxFps !== 0) this.publishGeometry(scopeGrantedGeometry(granted, this.context));
} else if (message && typeof message === 'object' && 'type' in message && message.type === 'error'
&& 'message' in message && typeof message.message === 'string') {
this.reset(); this.retryNeeded = true; this.errorStore.set(message.message);
// A rejected update leaves the server's previous subscription running.
if (this.frames.size === 0) this.reset();
this.retryNeeded = true; this.errorStore.set(message.message);
}
}

Expand Down
25 changes: 25 additions & 0 deletions frontend/tests/e2e/journey/operator-journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,9 @@ interface PanoramaWitness {
pinned: boolean;
baselineSignal: number;
pixelLosses: number;
refusals: string[];
changingMain: number;
advancingSpectrum: number;
main: number;
sub: number;
audioWire: number;
Expand All @@ -494,6 +497,7 @@ async function installPanoramaWitness(page: Page): Promise<void> {
const w: PanoramaWitness = {
requests: [], grants: [],
acknowledgments: [], active: false, pinned: false, baselineSignal: 0, pixelLosses: 0, main: 0, sub: 0, audioWire: 0, pcmBuffers: 0,
refusals: [], changingMain: 0, advancingSpectrum: 0,
finiteSamples: 0, nonzeroSamples: 0, invalidSamples: 0, subChecks: 0, losses: 0,
latestMain: -1, lastReceiver: -1, frames: [], samples: [], pcm: [],
};
Expand All @@ -502,6 +506,8 @@ async function installPanoramaWitness(page: Page): Promise<void> {
let canvas: HTMLCanvasElement | null = null;
let width = 0;
let height = 0;
let mainPixels = '';
let renderedSignal: number | null = null;
const selector = '.spectrum-panel[data-waterfall]:not(.audio-fft)';
// The production canvas is transparent. Exclude its ten/five grid lines,
// bottom labels, edges, and central tuning/passband overlays. The remaining
Expand Down Expand Up @@ -568,6 +574,7 @@ async function installPanoramaWitness(page: Page): Promise<void> {
if (message.type === 'response' && message.ok === true) w.acknowledgments.push(String(message.id));
const grant = parseGrant(message);
if (grant?.sourceId === 'radio') w.grants.push(grant);
if (path === '/api/v1/scope' && message.type === 'error') w.refusals.push(message.message);
} catch { /* Non-JSON control traffic is irrelevant to this witness. */ }
return;
}
Expand All @@ -581,6 +588,11 @@ async function installPanoramaWitness(page: Page): Promise<void> {
if (!parsed.ok || parsed.frame.source !== 'radio' || parsed.frame.outOfRange || !parsed.frame.pixels) return;
const { receiver, width: pixels, viewStartHz: start, viewEndHz: end, seq: sequence } = parsed.frame;
if (receiver !== 0 && receiver !== 1) return;
if (receiver === 0 && w.refusals.length > 0) {
const nextPixels = Array.from(parsed.frame.pixels).join(',');
if (mainPixels && nextPixels !== mainPixels) w.changingMain += 1;
mainPixels = nextPixels;
}
if (receiver === 0) { w.main += 1; w.latestMain = sequence; }
else w.sub += 1;
w.lastReceiver = receiver;
Expand All @@ -594,6 +606,11 @@ async function installPanoramaWitness(page: Page): Promise<void> {
const current = document.querySelector<HTMLElement>(selector);
const currentCanvas = current?.querySelector<HTMLCanvasElement>('.spectrum-area canvas') ?? null;
const rect = currentCanvas?.getBoundingClientRect();
if (receiver === 0 && w.pinned && w.refusals.length > 0 && currentCanvas === canvas) {
const signal = signalPixels(currentCanvas);
if (renderedSignal !== null && signal !== renderedSignal) w.advancingSpectrum += 1;
renderedSignal = signal;
}
if (receiver === 0 && w.lastReceiver === 0 && current && currentCanvas && rect && rect.width > 0 && rect.height > 0 && !w.pinned) {
const signal = signalPixels(currentCanvas);
if (signal < 20) return;
Expand Down Expand Up @@ -711,7 +728,9 @@ panoramaTest.describe('simulated mixed receiver panorama with real PCM', () => {
expect(before.nonzeroSamples).toBeGreaterThan(0);
expect(before.invalidSamples).toBe(0);
const modeKey = page.getByTestId('standard-mode-LSB').getByRole('radio');
await expect.poll(async () => (await panoramaWitness(page)).pinned).toBe(true);
await modeKey.click();
await expect.poll(async () => (await panoramaWitness(page)).refusals.length).toBeGreaterThan(0);
await expect.poll(() => sentParams(page, 'set_mode')).toContainEqual({ mode: 'LSB', receiver: 0 });
await expectServerMain(page, { mode: 'LSB' });
await expect(modeKey).toHaveAttribute('aria-checked', 'true');
Expand Down Expand Up @@ -749,7 +768,13 @@ panoramaTest.describe('simulated mixed receiver panorama with real PCM', () => {
await expect.poll(async () => (await panoramaWitness(page)).baselineSignal,
{ message: 'Actual MAIN signal must render while mixed scope, PCM and controls are healthy' }).toBeGreaterThanOrEqual(20);
await expect.poll(async () => (await panoramaWitness(page)).subChecks).toBeGreaterThanOrEqual(20);
await expect.poll(async () => (await panoramaWitness(page)).advancingSpectrum,
{ message: 'Mounted MAIN spectrum must advance after refusal across SUB-only intervals' }).toBeGreaterThan(2);
const rendered = await panoramaWitness(page);
expect(rendered.changingMain, 'MAIN wire pixels must change after negotiation refusal').toBeGreaterThan(2);
expect.soft(rendered.advancingSpectrum, 'Mounted MAIN spectrum must advance after refusal across SUB-only intervals').toBeGreaterThan(2);
await expect.soft(page.locator('[data-testid="toolbar-scope-status"]')).toContainText('Scope view refused');
await expect(page.locator('.spectrum-panel').getByRole('status').filter({ hasText: 'Scope view refused' })).toBeVisible();
expect(rendered.pixelLosses, 'Actual MAIN signal pixels cleared during a SUB-only interval').toBe(0);
expect(rendered.losses, 'MAIN canvas disappeared or changed geometry after valid SUB before next MAIN').toBe(0);
} finally {
Expand Down
4 changes: 3 additions & 1 deletion qa/bench/dogfood/scope_fps.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ async def simulator() -> AsyncIterator[tuple[str, float]]:

previous_gaps = testkit._SCOPE_GAP_S
testkit._SCOPE_GAP_S = {speed: 1 / 30 for speed in previous_gaps}
fake = testkit.MockIcomRadio(scope_stream=True, enable_audio=True)
from rigplane_testkit.icom.scope_control import ScopeControlSimulator

fake = ScopeControlSimulator(scope_stream=True, enable_audio=True)
receiver, templates = next(iter(fake._scope_templates.items()))
wave = parse_civ_frame(templates[0])
native = ScopeAssembler().feed(wave.data[1:], receiver)
Expand Down
Loading
Loading