mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Trim the Radio Next/Previous change to the HAL-722 scope
- Selections take the playback fallback as a `playFallback` option and set it after releasing a stale stream, instead of callers arming state that the release then had to save and restore. - Move the tuner `station-unavailable` release and the Pause label while a stream connects out of this branch: both were wrong on main already and go in a separate change. - Share the Radio layer test setup in src/testSupport/radioLayerHarness.mjs and fold overlapping Next/Previous tests (13 to 7). - One-line comments and a two-line changelog entry.
This commit is contained in:
1 parent
fa840ae3c3
commit
a55450e2e7
10 files changed
+172
-340
No files matched your search
+10
-73
@@ -51,6 +51,7 @@ import {
|
||||
radioViewIsGlobal,
|
||||
stationMatchesRadioCategory,
|
||||
} from './radio.js';
|
||||
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
|
||||
|
||||
const stations = [
|
||||
{ id: 'news', tags: ['News', 'Weather Radio', 'air traffic'] },
|
||||
@@ -883,83 +884,24 @@ test('tuner drag keeps one immutable catalog resolution through refresh and rele
|
||||
});
|
||||
|
||||
test('failed exact tuner release cannot consume a stale playback fallback', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalAudio = globalThis.Audio;
|
||||
const playUrls = [];
|
||||
const stationRows = [
|
||||
{
|
||||
radioStationRow({
|
||||
id: '00000000-0000-4000-8000-000000000071',
|
||||
name: 'Exact target',
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
streamUrl: 'https://radio.example.com/exact.mp3',
|
||||
homepage: null,
|
||||
tags: ['news'],
|
||||
languages: ['English'],
|
||||
state: 'Texas',
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
},
|
||||
{
|
||||
}),
|
||||
radioStationRow({
|
||||
id: '00000000-0000-4000-8000-000000000072',
|
||||
name: 'Stale fallback',
|
||||
lat: 31,
|
||||
lon: -98,
|
||||
streamUrl: 'https://radio.example.com/fallback.mp3',
|
||||
homepage: null,
|
||||
tags: ['news'],
|
||||
languages: ['English'],
|
||||
state: 'Texas',
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
},
|
||||
}),
|
||||
];
|
||||
globalThis.Audio = class FakeAudio {
|
||||
constructor() {
|
||||
this.volume = 0.8;
|
||||
this.src = '';
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
pause() {}
|
||||
play() {
|
||||
playUrls.push(this.src);
|
||||
if (this.src === stationRows[0].streamUrl) {
|
||||
return Promise.reject(new DOMException('Exact target unavailable', 'NotSupportedError'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
removeAttribute() { this.src = ''; }
|
||||
load() {}
|
||||
};
|
||||
globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/')
|
||||
? { ok: true }
|
||||
: new Response(JSON.stringify({
|
||||
stations: stationRows,
|
||||
stale: false,
|
||||
degraded: false,
|
||||
acceptedGeneration: 1,
|
||||
catalogInstance: 'qa-instance-a',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
|
||||
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
entities: { add(entity) { return entity; }, remove() {} },
|
||||
};
|
||||
|
||||
radioLayer.destroy();
|
||||
try {
|
||||
radioLayer.init(viewer);
|
||||
radioLayer.enable();
|
||||
await radioLayer.update();
|
||||
await withRadioLayer({
|
||||
stations: stationRows,
|
||||
failingUrls: [stationRows[0].streamUrl],
|
||||
}, async ({ playUrls }) => {
|
||||
assert.equal(radioLayer.selectStation(stationRows[0].id, { autoplay: false }), true);
|
||||
assert.equal(radioLayer.cycleStation(1, {
|
||||
stationIds: stationRows.map(({ id }) => id),
|
||||
@@ -976,12 +918,7 @@ test('failed exact tuner release cannot consume a stale playback fallback', asyn
|
||||
assert.equal(state.audioState, 'error');
|
||||
assert.equal(state.tuningStatic, true);
|
||||
assert.equal(state.tuningAwaitingStationId, stationRows[0].id);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalAudio === undefined) delete globalThis.Audio;
|
||||
else globalThis.Audio = originalAudio;
|
||||
radioLayer.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('tuner cancellation restores the frozen start marker after catalog removal', async () => {
|
||||
|
||||
@@ -5,79 +5,18 @@ import test from 'node:test';
|
||||
import { radioLayer } from './radio.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { renderRadioState } from '../ui/radioPresentation.js';
|
||||
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
|
||||
|
||||
const READY = 'Ready — playback starts only from your action';
|
||||
const ids = [1, 2, 3].map((n) => `00000000-0000-4000-8000-00000000009${n}`);
|
||||
const stationRows = ids.map((id, index) => ({
|
||||
const stations = ids.map((id, index) => radioStationRow({
|
||||
id,
|
||||
name: `Station ${'ABC'[index]}`,
|
||||
lat: 30 + index,
|
||||
lon: -97 - index,
|
||||
streamUrl: `https://radio.example.com/${'abc'[index]}.mp3`,
|
||||
homepage: null,
|
||||
tags: ['news'],
|
||||
languages: ['English'],
|
||||
state: 'Texas',
|
||||
country: 'United States',
|
||||
countryCode: 'US',
|
||||
metadataTrust: 'untrusted-community',
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
}));
|
||||
|
||||
async function withLayer(failingUrls, body) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalAudio = globalThis.Audio;
|
||||
const playUrls = [];
|
||||
const catalog = { rows: stationRows, generation: 1 };
|
||||
globalThis.Audio = class FakeAudio {
|
||||
constructor() {
|
||||
this.volume = 0.8;
|
||||
this.src = '';
|
||||
}
|
||||
|
||||
addEventListener() {}
|
||||
pause() {}
|
||||
play() {
|
||||
playUrls.push(this.src);
|
||||
if (failingUrls.includes(this.src)) {
|
||||
return Promise.reject(new DOMException('unavailable', 'NotSupportedError'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
removeAttribute() { this.src = ''; }
|
||||
load() {}
|
||||
};
|
||||
globalThis.fetch = async (url) => String(url).startsWith('/api/radio/click/')
|
||||
? { ok: true }
|
||||
: new Response(JSON.stringify({
|
||||
stations: catalog.rows,
|
||||
stale: false,
|
||||
degraded: false,
|
||||
acceptedGeneration: catalog.generation,
|
||||
catalogInstance: 'qa-instance-a',
|
||||
updatedAt: new Date().toISOString(),
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 7_000_000, y: 0, z: 0 } },
|
||||
scene: { canvas: { disableRootEvents: true, onwheel: null, addEventListener() {}, removeEventListener() {} } },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
entities: { add(entity) { return entity; }, remove() {} },
|
||||
};
|
||||
radioLayer.destroy();
|
||||
try {
|
||||
radioLayer.init(viewer);
|
||||
radioLayer.enable();
|
||||
await radioLayer.update();
|
||||
await body(playUrls, catalog);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalAudio === undefined) delete globalThis.Audio;
|
||||
else globalThis.Audio = originalAudio;
|
||||
radioLayer.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const withLayer = (failingUrls, body) => withRadioLayer({ stations, failingUrls }, body);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// What the Radio panel and the Cockpit player show for a state.
|
||||
@@ -125,24 +64,27 @@ function render(state) {
|
||||
}
|
||||
}
|
||||
|
||||
test('Next before Play selects without starting a stream, and Play starts the new station', async () => {
|
||||
await withLayer([], async (playUrls) => {
|
||||
const readyToPlay = { status: READY, error: false, button: 'PLAY', stopDisabled: true, cockpit: '▶' };
|
||||
|
||||
test('Next before Play only selects, and Play tries just that station', async () => {
|
||||
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
|
||||
await settle();
|
||||
assert.deepEqual(playUrls, []);
|
||||
assert.equal(radioLayer.getUIState().audioState, 'stopped');
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
await settle();
|
||||
const state = radioLayer.getUIState();
|
||||
assert.deepEqual(playUrls, [stationRows[1].streamUrl]);
|
||||
assert.equal(state.audioState, 'playing');
|
||||
assert.equal(state.playingStationId, ids[1]);
|
||||
assert.deepEqual(playUrls, [stations[1].streamUrl], 'no fallback picked by the silent step');
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
test('Next after a failed stream leaves the new station ready to play, without the old error', async () => {
|
||||
setLocale('en');
|
||||
await withLayer([stationRows[0].streamUrl], async (playUrls) => {
|
||||
await withLayer([stations[0].streamUrl], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
assert.equal(radioLayer.getUIState().audioState, 'error');
|
||||
@@ -151,22 +93,16 @@ test('Next after a failed stream leaves the new station ready to play, without t
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'stopped');
|
||||
assert.equal(state.playingStationId, null);
|
||||
assert.deepEqual(render(state), {
|
||||
status: READY,
|
||||
error: false,
|
||||
button: 'PLAY',
|
||||
stopDisabled: true,
|
||||
cockpit: '▶',
|
||||
});
|
||||
assert.deepEqual(render(state), readyToPlay);
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
assert.deepEqual(playUrls, [stationRows[0].streamUrl, stationRows[1].streamUrl]);
|
||||
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
|
||||
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
|
||||
});
|
||||
});
|
||||
|
||||
test('Next while paused releases the paused stream, and Play starts the new station', async () => {
|
||||
setLocale('en');
|
||||
await withLayer([], async (playUrls) => {
|
||||
await withLayer([], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
@@ -176,15 +112,9 @@ test('Next while paused releases the paused stream, and Play starts the new stat
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'stopped');
|
||||
assert.equal(state.playingStationId, null);
|
||||
assert.deepEqual(render(state), {
|
||||
status: READY,
|
||||
error: false,
|
||||
button: 'PLAY',
|
||||
stopDisabled: true,
|
||||
cockpit: '▶',
|
||||
});
|
||||
assert.deepEqual(render(state), readyToPlay);
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
assert.deepEqual(playUrls, [stationRows[0].streamUrl, stationRows[1].streamUrl]);
|
||||
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
|
||||
assert.equal(radioLayer.getUIState().audioState, 'playing');
|
||||
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
|
||||
});
|
||||
@@ -192,12 +122,12 @@ test('Next while paused releases the paused stream, and Play starts the new stat
|
||||
|
||||
test('a catalog refresh after a paused Next cannot bring back Resume for an unnamed station', async () => {
|
||||
setLocale('en');
|
||||
await withLayer([], async (_playUrls, catalog) => {
|
||||
await withLayer([], async ({ catalog }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
|
||||
catalog.rows = stationRows.filter(({ id }) => id !== ids[1]);
|
||||
catalog.rows = stations.filter(({ id }) => id !== ids[1]);
|
||||
catalog.generation = 2;
|
||||
await radioLayer.update();
|
||||
const state = radioLayer.getUIState();
|
||||
@@ -209,71 +139,9 @@ test('a catalog refresh after a paused Next cannot bring back Resume for an unna
|
||||
});
|
||||
});
|
||||
|
||||
test('Play after a non-playing Next cannot consume a fallback picked by that step', async () => {
|
||||
await withLayer([stationRows[1].streamUrl], async (playUrls) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
|
||||
assert.deepEqual(playUrls, []);
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
await settle();
|
||||
const state = radioLayer.getUIState();
|
||||
assert.deepEqual(playUrls, [stationRows[1].streamUrl]);
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
test('a voice step keeps its broadcaster fallback for the playback voice starts', async () => {
|
||||
await withLayer([stationRows[1].streamUrl], async (playUrls) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
// gevActions prepares the station silently; the voice handoff then plays it.
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||
await radioLayer.play({ origin: 'voice' });
|
||||
await settle();
|
||||
await settle();
|
||||
const state = radioLayer.getUIState();
|
||||
assert.deepEqual(playUrls, [stationRows[1].streamUrl, stationRows[2].streamUrl]);
|
||||
assert.equal(state.selected?.id, ids[2]);
|
||||
assert.equal(state.audioState, 'playing');
|
||||
});
|
||||
});
|
||||
|
||||
test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => {
|
||||
await withLayer([stationRows[1].streamUrl], async (playUrls) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||
// The handoff is cancelled (barge-in), so voice never starts playback.
|
||||
await settle();
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
await settle();
|
||||
await settle();
|
||||
const state = radioLayer.getUIState();
|
||||
assert.deepEqual(playUrls, [stationRows[1].streamUrl]);
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
test('while a stream connects the transport buttons offer Pause, because a click pauses', () => {
|
||||
setLocale('en');
|
||||
const shown = render({
|
||||
enabled: true,
|
||||
audioState: 'loading',
|
||||
audioError: null,
|
||||
selected: { id: 'a', name: 'Station A' },
|
||||
selectedIndex: 0,
|
||||
filteredCount: 1,
|
||||
stationCount: 1,
|
||||
volume: 1,
|
||||
});
|
||||
assert.equal(shown.status, 'Connecting directly to broadcaster…');
|
||||
assert.equal(shown.button, 'PAUSE');
|
||||
assert.equal(shown.cockpit, 'Ⅱ');
|
||||
});
|
||||
|
||||
test('a voice station request after a voice pause keeps the fallback of that request', async () => {
|
||||
const failing = [];
|
||||
await withLayer(failing, async (playUrls) => {
|
||||
await withLayer(failing, async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
||||
await settle();
|
||||
radioLayer.pause({ origin: 'voice-duck' });
|
||||
@@ -293,98 +161,34 @@ test('a voice station request after a voice pause keeps the fallback of that req
|
||||
});
|
||||
});
|
||||
|
||||
test('an unavailable tuner release stops the paused stream, and Play still falls back', async () => {
|
||||
setLocale('en');
|
||||
const failing = [];
|
||||
await withLayer(failing, async (playUrls, catalog) => {
|
||||
radioLayer.selectStation(ids[2], { autoplay: true, origin: 'user' });
|
||||
test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => {
|
||||
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||
// The handoff is cancelled (barge-in), so voice never starts playback.
|
||||
await settle();
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||
// A directory refresh changes the station under the needle mid-drag.
|
||||
catalog.rows = stationRows.map((row) => row.id === ids[1]
|
||||
? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' }
|
||||
: row);
|
||||
catalog.generation = 2;
|
||||
await radioLayer.update();
|
||||
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable');
|
||||
const state = radioLayer.getUIState();
|
||||
assert.equal(state.selected, null);
|
||||
assert.equal(state.audioState, 'stopped');
|
||||
assert.equal(state.playingStationId, null);
|
||||
assert.equal(render(state).button, 'PLAY');
|
||||
playUrls.length = 0;
|
||||
// Play picks the nearest station; its broadcaster fails.
|
||||
const play = globalThis.Audio.prototype.play;
|
||||
globalThis.Audio.prototype.play = function playFirstFails() {
|
||||
if (!failing.length) failing.push(this.src);
|
||||
return play.call(this);
|
||||
};
|
||||
try {
|
||||
await radioLayer.togglePlayback({ origin: 'user' });
|
||||
await settle();
|
||||
await settle();
|
||||
} finally {
|
||||
globalThis.Audio.prototype.play = play;
|
||||
}
|
||||
assert.equal(playUrls.length, 2);
|
||||
assert.equal(radioLayer.getUIState().audioState, 'playing');
|
||||
assert.deepEqual(playUrls, [stations[1].streamUrl]);
|
||||
assert.equal(state.selected?.id, ids[1]);
|
||||
assert.equal(state.audioState, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
test('a user step that does not play tells voice to drop its prepared playback', async () => {
|
||||
test('only a user step to another station tells voice to drop its prepared playback', async () => {
|
||||
await withLayer([], async () => {
|
||||
const controls = [];
|
||||
const unsubscribe = radioLayer.subscribePlaybackControls((event) => controls.push(event));
|
||||
try {
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
||||
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
|
||||
radioLayer.cycleStation(1, { stationIds: [ids[1]], autoplay: false, origin: 'user' });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
assert.equal(radioLayer.getUIState().selected?.id, ids[1]);
|
||||
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['select', 'user']]);
|
||||
});
|
||||
});
|
||||
|
||||
test('an unavailable tuner release publishes one final state and stops a voice-prepared playback', async () => {
|
||||
await withLayer([], async (_playUrls, catalog) => {
|
||||
radioLayer.selectStation(ids[2], { autoplay: false });
|
||||
assert.equal(radioLayer.beginTuning(), true);
|
||||
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
||||
catalog.rows = stationRows.map((row) => row.id === ids[1]
|
||||
? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' }
|
||||
: row);
|
||||
catalog.generation = 2;
|
||||
await radioLayer.update();
|
||||
const states = [];
|
||||
const controls = [];
|
||||
const unsubscribe = radioLayer.subscribe((state) => states.push(state));
|
||||
const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => controls.push(event));
|
||||
states.length = 0;
|
||||
try {
|
||||
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable');
|
||||
} finally {
|
||||
unsubscribe();
|
||||
unsubscribeControls();
|
||||
}
|
||||
assert.equal(states.length, 1);
|
||||
assert.equal(states[0].tuningUnavailableStationId, ids[1]);
|
||||
assert.equal(states[0].audioState, 'stopped');
|
||||
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['stop', 'user']]);
|
||||
});
|
||||
});
|
||||
|
||||
test('a user step that keeps the same station leaves voice playback alone', async () => {
|
||||
await withLayer([], async () => {
|
||||
radioLayer.selectStation(ids[0], { autoplay: false });
|
||||
const controls = [];
|
||||
const unsubscribe = radioLayer.subscribePlaybackControls((event) => controls.push(event));
|
||||
try {
|
||||
assert.equal(radioLayer.cycleStation(1, { stationIds: [ids[0]], autoplay: false, origin: 'user' }), true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
assert.equal(radioLayer.getUIState().selected?.id, ids[0]);
|
||||
assert.deepEqual(controls, []);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user