mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 06:18:31 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
196008a98e | ||
|
|
a9be210f77 |
+3
-1
@@ -79,7 +79,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
style ignores are greyed out with a reason on hover (Custom video and
|
style ignores are greyed out with a reason on hover (Custom video and
|
||||||
Butterchurn use neither; Custom image uses Intensity but not Reactive), so
|
Butterchurn use neither; Custom image uses Intensity but not Reactive), so
|
||||||
a knob is never present-but-inert. The control disappears when a non-3D
|
a knob is never present-but-inert. The control disappears when a non-3D
|
||||||
renderer is selected.
|
renderer is selected. The whole group also greys out while the Venue scene
|
||||||
|
override is active, since none of the three controls reach a mounted style
|
||||||
|
in that mode.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
|
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
|
||||||
|
|||||||
@@ -478,12 +478,24 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
|||||||
_evict_audio_cache()
|
_evict_audio_cache()
|
||||||
|
|
||||||
# Send song metadata
|
# Send song metadata
|
||||||
|
# For drum-only sloppaks the placeholder arrangement has 0 notes
|
||||||
|
# (drum_tab hits live separately). Surface the real hit count so
|
||||||
|
# the UI shows e.g. "Drums (1922)" instead of "Drums (0)".
|
||||||
|
_DRUM_KEYWORDS = ("drum", "percussion")
|
||||||
|
_dt_hit_count = 0
|
||||||
|
if is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None:
|
||||||
|
_dt_hit_count = len(loaded_slop.drum_tab.get("hits") or [])
|
||||||
arr_list = [
|
arr_list = [
|
||||||
{
|
{
|
||||||
"index": i,
|
"index": i,
|
||||||
"name": a.name,
|
"name": a.name,
|
||||||
"smart_name": smart_names[i],
|
"smart_name": smart_names[i],
|
||||||
"notes": len(a.notes) + sum(len(c.notes) for c in a.chords),
|
"notes": (
|
||||||
|
_dt_hit_count
|
||||||
|
if (len(a.notes) == 0 and not a.chords and _dt_hit_count > 0
|
||||||
|
and any(kw in (a.name or "").lower() for kw in _DRUM_KEYWORDS))
|
||||||
|
else len(a.notes) + sum(len(c.notes) for c in a.chords)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for i, a in enumerate(song.arrangements)
|
for i, a in enumerate(song.arrangements)
|
||||||
]
|
]
|
||||||
|
|||||||
+13
-8
@@ -895,16 +895,21 @@ def load_song(
|
|||||||
log.warning("sloppak: drum_tab %r failed validation: %s",
|
log.warning("sloppak: drum_tab %r failed validation: %s",
|
||||||
drum_tab_rel, reason)
|
drum_tab_rel, reason)
|
||||||
|
|
||||||
# Drum-only sloppak: every GP track was percussion, so it ships a
|
# Drum sloppak: when a drum_tab is present but no existing arrangement is
|
||||||
# drum_tab but no pitched arrangements. The highway WS rejects an empty
|
# a drum part, synthesize a minimal placeholder so the drums appear in the
|
||||||
# arrangements list with "No arrangements found" *before* it serves the
|
# arrangement picker and the drum_tab reaches the drum highway. The editor
|
||||||
# drum_tab, leaving the drums unplayable even in the drum highway.
|
# strips drum arrangements out of the manifest (converting them to
|
||||||
# Synthesize a minimal placeholder arrangement so the stream proceeds and
|
# drum_tab), so without this a drum+bass sloppak would show only Bass in
|
||||||
# the drum_tab reaches the drum highway. It carries no notes (the guitar
|
# the picker with no way to reach the drums. It carries no notes (the
|
||||||
# highway just shows an empty board) and, when the manifest omits a
|
# guitar highway just shows an empty board) and, when the manifest omits a
|
||||||
# duration, derives a song length from the last drum hit so the timeline
|
# duration, derives a song length from the last drum hit so the timeline
|
||||||
# isn't zero-length.
|
# isn't zero-length.
|
||||||
if not song.arrangements and drum_tab_data is not None:
|
_DRUM_KEYWORDS = ("drum", "percussion")
|
||||||
|
_has_drum_arr = any(
|
||||||
|
any(kw in (getattr(a, "name", "") or "").lower() for kw in _DRUM_KEYWORDS)
|
||||||
|
for a in song.arrangements
|
||||||
|
)
|
||||||
|
if not _has_drum_arr and drum_tab_data is not None:
|
||||||
if song.song_length <= 0:
|
if song.song_length <= 0:
|
||||||
# validate_drum_tab() intentionally does NOT type-check individual
|
# validate_drum_tab() intentionally does NOT type-check individual
|
||||||
# hits (they're sanitized at WS-stream time), so a hit may carry a
|
# hits (they're sanitized at WS-stream time), so a hit may carry a
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "highway_3d",
|
"id": "highway_3d",
|
||||||
"name": "3D Highway",
|
"name": "3D Highway",
|
||||||
"version": "3.34.0",
|
"version": "3.34.1",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"script": "screen.js",
|
"script": "screen.js",
|
||||||
|
|||||||
+110
-22
@@ -2786,6 +2786,21 @@
|
|||||||
if (globalVal !== null && globalVal !== undefined) return _bgCoerce(key, globalVal);
|
if (globalVal !== null && globalVal !== undefined) return _bgCoerce(key, globalVal);
|
||||||
return BG_DEFAULTS[key];
|
return BG_DEFAULTS[key];
|
||||||
}
|
}
|
||||||
|
// Read a setting's GLOBAL value, ignoring any per-panel override. The
|
||||||
|
// player-chrome control is a single shared instance, so it must always
|
||||||
|
// read (and write) the global slot. Passing null as a panelKey to
|
||||||
|
// _bgReadSetting happened to work only because 'h3d_bg_null_<key>' never
|
||||||
|
// exists; this states the intent directly and can't be shadowed if a
|
||||||
|
// panelKey of null is ever used deliberately. Mirrors the global half of
|
||||||
|
// _bgReadSetting exactly (mem-fallback precedence, then persisted, then
|
||||||
|
// default).
|
||||||
|
function _bgReadGlobal(key) {
|
||||||
|
let globalVal = null;
|
||||||
|
try { globalVal = localStorage.getItem('h3d_bg_' + key); } catch (_) { /* storage blocked */ }
|
||||||
|
if (key in _bgMemFallback) return _bgCoerce(key, _bgMemFallback[key]);
|
||||||
|
if (globalVal !== null && globalVal !== undefined) return _bgCoerce(key, globalVal);
|
||||||
|
return BG_DEFAULTS[key];
|
||||||
|
}
|
||||||
// Shared "stored string -> bool" coercion for every boolean
|
// Shared "stored string -> bool" coercion for every boolean
|
||||||
// setting. Mirrors settings.html's coerceBool so the renderer and
|
// setting. Mirrors settings.html's coerceBool so the renderer and
|
||||||
// the UI hydration always agree on what a corrupted/unknown value
|
// the UI hydration always agree on what a corrupted/unknown value
|
||||||
@@ -4024,8 +4039,10 @@
|
|||||||
* add a style there and it shows up in both places automatically.
|
* add a style there and it shows up in both places automatically.
|
||||||
*
|
*
|
||||||
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
|
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
|
||||||
* instances but these settings are global, so N copies of the control
|
* instances but these settings are global — a panel may set a per-panel
|
||||||
* would be N ways to set one value. init() acquires, destroy() releases,
|
* override, but this single shared control only ever reads/writes the
|
||||||
|
* global slot (via _bgReadGlobal), so N copies would be N ways to set
|
||||||
|
* one value. init() acquires, destroy() releases,
|
||||||
* and the last release unmounts — so the control disappears when the user
|
* and the last release unmounts — so the control disappears when the user
|
||||||
* switches to a non-3D renderer instead of lingering as a dead knob.
|
* switches to a non-3D renderer instead of lingering as a dead knob.
|
||||||
*
|
*
|
||||||
@@ -4046,9 +4063,11 @@
|
|||||||
//
|
//
|
||||||
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
|
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
|
||||||
// build() reads settings.intensity, and uses `reactive` if its update()
|
// build() reads settings.intensity, and uses `reactive` if its update()
|
||||||
// dereferences the `bands` argument. 'butterchurn' is not a BG_STYLES entry
|
// dereferences the `bands` argument. 'butterchurn' is a mode, not a
|
||||||
// at all - _bgMountStyle falls through to BG_STYLES.off - and it drives its
|
// BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
|
||||||
// own audio tap and opacity, so both are false for it.
|
// drives its own audio tap and canvas opacity (only the fog-scenery half
|
||||||
|
// falls through to BG_STYLES.off). So neither knob here reaches it - both
|
||||||
|
// are false, and the tooltip points at Butterchurn's own controls.
|
||||||
//
|
//
|
||||||
// KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity
|
// KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity
|
||||||
// and its row is not updated, the control stays greyed out and lies the
|
// and its row is not updated, the control stays greyed out and lies the
|
||||||
@@ -4063,6 +4082,10 @@
|
|||||||
image: { intensity: true, reactive: false, why: 'This background does not react to audio' },
|
image: { intensity: true, reactive: false, why: 'This background does not react to audio' },
|
||||||
video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' },
|
video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' },
|
||||||
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
|
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
|
||||||
|
// Not in BG_STYLE_IDS, so it never appears in the dropdown - reached
|
||||||
|
// only via the viz-picker Venue flow (h3dVenueSceneSetActive). While
|
||||||
|
// active it is the EFFECTIVE style, so both knobs drive nothing.
|
||||||
|
venue: { intensity: false, reactive: false, why: 'Venue visualization is active - pick a background from the visualization picker' },
|
||||||
};
|
};
|
||||||
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
|
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
|
||||||
// Non-disabled wrappers around the two greyable controls. A native-disabled
|
// Non-disabled wrappers around the two greyable controls. A native-disabled
|
||||||
@@ -4070,7 +4093,7 @@
|
|||||||
// shows on hover — the whole "greyed out, says why on hover" affordance
|
// shows on hover — the whole "greyed out, says why on hover" affordance
|
||||||
// would be dead. The reason lives on these wrappers instead, and the
|
// would be dead. The reason lives on these wrappers instead, and the
|
||||||
// disabled control gets pointer-events:none so the hover reaches them.
|
// disabled control gets pointer-events:none so the hover reaches them.
|
||||||
let _pcReactiveWrap = null, _pcIntensityWrap = null;
|
let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
|
||||||
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
|
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
|
||||||
|
|
||||||
// The player chrome exposes this slot once it has initialised. A host
|
// The player chrome exposes this slot once it has initialised. A host
|
||||||
@@ -4078,7 +4101,12 @@
|
|||||||
// page remains the way in.
|
// page remains the way in.
|
||||||
function _pcSlot() {
|
function _pcSlot() {
|
||||||
try {
|
try {
|
||||||
const fn = window.feedBack && window.feedBack.ui && window.feedBack.ui.playerControlSlot;
|
// Gate on the v3 shell per docs/plugin-v3-ui.md (matches the tuner
|
||||||
|
// precedent). The playerControlSlot typeof check below already
|
||||||
|
// covers the practical case - only v3 exposes it - but the
|
||||||
|
// documented checklist asks plugins to detect v3 explicitly.
|
||||||
|
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return null;
|
||||||
|
const fn = window.feedBack.ui && window.feedBack.ui.playerControlSlot;
|
||||||
return typeof fn === 'function' ? fn() : null;
|
return typeof fn === 'function' ? fn() : null;
|
||||||
} catch (_) { return null; }
|
} catch (_) { return null; }
|
||||||
}
|
}
|
||||||
@@ -4126,6 +4154,8 @@
|
|||||||
btn._on = !!on && !disabled;
|
btn._on = !!on && !disabled;
|
||||||
btn.disabled = !!disabled;
|
btn.disabled = !!disabled;
|
||||||
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
|
||||||
|
// A toggle button must expose its state, not just its label.
|
||||||
|
btn.setAttribute('aria-pressed', btn._on ? 'true' : 'false');
|
||||||
// pointer-events:none lets the hover fall through to _pcReactiveWrap,
|
// pointer-events:none lets the hover fall through to _pcReactiveWrap,
|
||||||
// which carries the reason a disabled button's own title can't show.
|
// which carries the reason a disabled button's own title can't show.
|
||||||
btn.style.pointerEvents = disabled ? 'none' : '';
|
btn.style.pointerEvents = disabled ? 'none' : '';
|
||||||
@@ -4151,22 +4181,51 @@
|
|||||||
// whenever the settings bus reports one of our keys changed, so editing
|
// whenever the settings bus reports one of our keys changed, so editing
|
||||||
// from the Settings page updates this control and vice-versa.
|
// from the Settings page updates this control and vice-versa.
|
||||||
function _pcSync() {
|
function _pcSync() {
|
||||||
|
// The active style is the EFFECTIVE one, not the stored one: while the
|
||||||
|
// Venue scene override is on it is what's mounted, and it ignores the
|
||||||
|
// whole Background group - picking a style writes `style` but
|
||||||
|
// _bgMountStyle resolves back to venue, so the dropdown would look
|
||||||
|
// broken. So under Venue the ENTIRE group goes inert (dropdown too),
|
||||||
|
// and the user exits Venue from the visualization picker where they
|
||||||
|
// entered it. An unknown id enables everything rather than disabling
|
||||||
|
// it, so a style added without a _PC_USES row is merely unhelpful.
|
||||||
|
const venue = !!_venueSceneOverride;
|
||||||
|
const effectiveStyle = venue ? 'venue' : _bgReadGlobal('style');
|
||||||
|
const uses = _PC_USES[effectiveStyle] || { intensity: true, reactive: true };
|
||||||
|
const why = uses.why || 'This background style ignores this setting';
|
||||||
|
if (_pcReason) _pcReason.textContent = why;
|
||||||
|
// Point a screen reader at the reason, but only while a control is
|
||||||
|
// inert - cleared otherwise so an enabled control is not described by a
|
||||||
|
// stale reason.
|
||||||
|
const _pcDescribe = (el, inert) => {
|
||||||
|
if (!el) return;
|
||||||
|
if (inert) el.setAttribute('aria-describedby', 'h3d-pc-reason');
|
||||||
|
else el.removeAttribute('aria-describedby');
|
||||||
|
};
|
||||||
|
_pcDescribe(_pcSel, venue);
|
||||||
|
_pcDescribe(_pcReactive, !uses.reactive);
|
||||||
|
_pcDescribe(_pcIntensity, !uses.intensity);
|
||||||
if (_pcSel) {
|
if (_pcSel) {
|
||||||
// The custom slots stay unselectable until something is uploaded -
|
// The custom slots stay unselectable until something is uploaded -
|
||||||
// same rule settings.html applies.
|
// same rule settings.html applies.
|
||||||
const img = _pcSel.querySelector('option[value="image"]');
|
const img = _pcSel.querySelector('option[value="image"]');
|
||||||
const vid = _pcSel.querySelector('option[value="video"]');
|
const vid = _pcSel.querySelector('option[value="video"]');
|
||||||
if (img) img.disabled = !_bgReadSetting(null, 'customImageDataUrl');
|
if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
|
||||||
if (vid) vid.disabled = !_bgReadSetting(null, 'customVideoName');
|
if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
|
||||||
_pcSel.value = _bgReadSetting(null, 'style');
|
_pcSel.value = _bgReadGlobal('style');
|
||||||
|
// The dropdown still SHOWS the stored style (venue has no option),
|
||||||
|
// but it's inert while Venue owns the scene.
|
||||||
|
_pcSel.disabled = venue;
|
||||||
|
_pcSel.setAttribute('aria-disabled', venue ? 'true' : 'false');
|
||||||
|
_pcSel.style.opacity = venue ? '.45' : '1';
|
||||||
|
_pcSel.style.cursor = venue ? 'not-allowed' : '';
|
||||||
|
// Restore the base tooltip when Venue exits — blanking it would
|
||||||
|
// permanently drop the mount-time 'Background style' hint. Matches
|
||||||
|
// how the intensity slider and Reactive pill restore theirs.
|
||||||
|
_pcSel.title = venue ? why : 'Background style';
|
||||||
}
|
}
|
||||||
// Grey out whichever controls the ACTIVE style ignores (see _PC_USES).
|
|
||||||
// An unknown id enables both rather than disabling both, so a style
|
|
||||||
// added without a table row is merely unhelpful, never inert.
|
|
||||||
const uses = _PC_USES[_bgReadSetting(null, 'style')] || { intensity: true, reactive: true };
|
|
||||||
const why = uses.why || 'This background style ignores this setting';
|
|
||||||
if (_pcReactive) {
|
if (_pcReactive) {
|
||||||
_pcPaint(_pcReactive, !!_bgReadSetting(null, 'reactive'), !uses.reactive,
|
_pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
|
||||||
uses.reactive ? 'React to the audio' : why);
|
uses.reactive ? 'React to the audio' : why);
|
||||||
}
|
}
|
||||||
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
|
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
|
||||||
@@ -4176,7 +4235,7 @@
|
|||||||
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
|
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
|
||||||
}
|
}
|
||||||
if (_pcIntensity) {
|
if (_pcIntensity) {
|
||||||
_pcIntensity.value = String(_bgReadSetting(null, 'intensity'));
|
_pcIntensity.value = String(_bgReadGlobal('intensity'));
|
||||||
_pcIntensity.disabled = !uses.intensity;
|
_pcIntensity.disabled = !uses.intensity;
|
||||||
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
|
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
|
||||||
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
|
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
|
||||||
@@ -4203,10 +4262,10 @@
|
|||||||
function _pcSyncSettingsPanel() {
|
function _pcSyncSettingsPanel() {
|
||||||
try {
|
try {
|
||||||
const st = document.getElementById('h3d-bg-style');
|
const st = document.getElementById('h3d-bg-style');
|
||||||
if (st) st.value = _bgReadSetting(null, 'style');
|
if (st) st.value = _bgReadGlobal('style');
|
||||||
const re = document.getElementById('h3d-bg-reactive');
|
const re = document.getElementById('h3d-bg-reactive');
|
||||||
if (re) re.checked = !!_bgReadSetting(null, 'reactive');
|
if (re) re.checked = !!_bgReadGlobal('reactive');
|
||||||
const inten = _bgReadSetting(null, 'intensity');
|
const inten = _bgReadGlobal('intensity');
|
||||||
const ie = document.getElementById('h3d-bg-intensity');
|
const ie = document.getElementById('h3d-bg-intensity');
|
||||||
if (ie) ie.value = String(inten);
|
if (ie) ie.value = String(inten);
|
||||||
// The panel prints the numeric value beside the slider; keep its
|
// The panel prints the numeric value beside the slider; keep its
|
||||||
@@ -4226,6 +4285,16 @@
|
|||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
box.className = 'h3d-pc';
|
box.className = 'h3d-pc';
|
||||||
box.style.cssText = 'display:flex;flex-direction:column;width:100%;';
|
box.style.cssText = 'display:flex;flex-direction:column;width:100%;';
|
||||||
|
// Visually-hidden text carrying the "why greyed out" reason to screen
|
||||||
|
// readers; disabled controls point aria-describedby here. A title alone
|
||||||
|
// is announced unreliably and never on touch. One span suffices - every
|
||||||
|
// greyed control shares the same reason (derived from the single
|
||||||
|
// effective style).
|
||||||
|
_pcReason = document.createElement('span');
|
||||||
|
_pcReason.id = 'h3d-pc-reason';
|
||||||
|
_pcReason.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;'
|
||||||
|
+ 'margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
|
||||||
|
box.appendChild(_pcReason);
|
||||||
|
|
||||||
box.appendChild(_pcGroupLabel('Background'));
|
box.appendChild(_pcGroupLabel('Background'));
|
||||||
// A dropdown, not pills: the style list is 8 entries and growing, and
|
// A dropdown, not pills: the style list is 8 entries and growing, and
|
||||||
@@ -4234,6 +4303,7 @@
|
|||||||
// raw <select>.
|
// raw <select>.
|
||||||
_pcSel = document.createElement('select');
|
_pcSel = document.createElement('select');
|
||||||
_pcSel.title = 'Background style';
|
_pcSel.title = 'Background style';
|
||||||
|
_pcSel.setAttribute('aria-label', 'Background style');
|
||||||
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
|
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
|
||||||
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
|
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
|
||||||
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
|
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
|
||||||
@@ -4244,6 +4314,7 @@
|
|||||||
_pcSel.appendChild(o);
|
_pcSel.appendChild(o);
|
||||||
}
|
}
|
||||||
_pcSel.addEventListener('change', () => {
|
_pcSel.addEventListener('change', () => {
|
||||||
|
if (_pcSel.disabled) return; // inert under the Venue override
|
||||||
try { window.h3dBgSetStyle(_pcSel.value); }
|
try { window.h3dBgSetStyle(_pcSel.value); }
|
||||||
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
|
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
|
||||||
});
|
});
|
||||||
@@ -4269,6 +4340,7 @@
|
|||||||
_pcIntensity.type = 'range';
|
_pcIntensity.type = 'range';
|
||||||
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
|
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
|
||||||
_pcIntensity.title = 'Background intensity';
|
_pcIntensity.title = 'Background intensity';
|
||||||
|
_pcIntensity.setAttribute('aria-label', 'Background intensity');
|
||||||
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
|
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
|
||||||
// 'change' (fires on release), NOT 'input'. Every write goes through
|
// 'change' (fires on release), NOT 'input'. Every write goes through
|
||||||
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
|
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
|
||||||
@@ -4288,7 +4360,11 @@
|
|||||||
_pcSync();
|
_pcSync();
|
||||||
_pcListener = (key) => {
|
_pcListener = (key) => {
|
||||||
if (key === 'style' || key === 'reactive' || key === 'intensity'
|
if (key === 'style' || key === 'reactive' || key === 'intensity'
|
||||||
|| key === 'customImageDataUrl' || key === 'customVideoName') {
|
|| key === 'customImageDataUrl' || key === 'customVideoName'
|
||||||
|
|| key === 'venueScene') {
|
||||||
|
// 'venueScene' has no dropdown/settings widget of its own, but
|
||||||
|
// toggling Venue changes the EFFECTIVE style, so the greying
|
||||||
|
// must re-evaluate (see _pcSync's effectiveStyle).
|
||||||
_pcSync();
|
_pcSync();
|
||||||
_pcSyncSettingsPanel();
|
_pcSyncSettingsPanel();
|
||||||
}
|
}
|
||||||
@@ -4300,12 +4376,18 @@
|
|||||||
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
|
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
|
||||||
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
|
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
|
||||||
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
|
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
|
||||||
_pcReactiveWrap = null; _pcIntensityWrap = null;
|
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
|
||||||
}
|
}
|
||||||
function _pcAcquire() {
|
function _pcAcquire() {
|
||||||
_pcRefs++;
|
_pcRefs++;
|
||||||
_pcBindScreenHook();
|
_pcBindScreenHook();
|
||||||
if (_pcMount()) return;
|
if (_pcMount()) return;
|
||||||
|
// A non-v3 shell has no slot and never will — _pcAcquire only runs once
|
||||||
|
// the renderer is viable inside the v3 player chrome, and player-chrome.js
|
||||||
|
// sets uiVersion synchronously as it builds that chrome, so a missing 'v3'
|
||||||
|
// here means v2, not a not-yet-ready v3. Skip the retry loop rather than
|
||||||
|
// spinning it out to the ~3s budget for a slot that will never appear.
|
||||||
|
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return;
|
||||||
// The rail popover may not be built yet on a cold load. Retry a few
|
// The rail popover may not be built yet on a cold load. Retry a few
|
||||||
// times, then give up quietly — Settings still works.
|
// times, then give up quietly — Settings still works.
|
||||||
if (_pcRetryTimer) return;
|
if (_pcRetryTimer) return;
|
||||||
@@ -4313,6 +4395,12 @@
|
|||||||
const tick = () => {
|
const tick = () => {
|
||||||
_pcRetryTimer = 0;
|
_pcRetryTimer = 0;
|
||||||
if (_pcRefs <= 0) return; // renderer went away mid-retry
|
if (_pcRefs <= 0) return; // renderer went away mid-retry
|
||||||
|
// Re-attempt the bus subscription too, not just the mount. On a cold
|
||||||
|
// load the renderer can init before window.feedBack.on exists; the
|
||||||
|
// first _pcBindScreenHook() then no-ops and, without this, the hook
|
||||||
|
// never binds and the control goes permanently deaf to screen
|
||||||
|
// changes. Idempotent via the _pcScreenHook guard.
|
||||||
|
_pcBindScreenHook();
|
||||||
if (_pcMount()) return;
|
if (_pcMount()) return;
|
||||||
if (++_pcRetry > 12) return; // ~3s at 250ms
|
if (++_pcRetry > 12) return; // ~3s at 250ms
|
||||||
_pcRetryTimer = setTimeout(tick, 250);
|
_pcRetryTimer = setTimeout(tick, 250);
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ const END_LF = ' /* =========================================================
|
|||||||
// table, which would only assert that the table equals itself.
|
// table, which would only assert that the table equals itself.
|
||||||
// intensity: true => the style's build() reads settings.intensity
|
// intensity: true => the style's build() reads settings.intensity
|
||||||
// reactive: true => the style's update() dereferences its `bands` argument
|
// reactive: true => the style's update() dereferences its `bands` argument
|
||||||
// 'butterchurn' is not a BG_STYLES entry at all (mount falls through to
|
// 'butterchurn' is a mode, not a BG_STYLES fog-scenery entry: _bcSyncMode
|
||||||
// BG_STYLES.off) and drives its own audio tap, so both are false.
|
// owns its controller and drives its own audio tap + canvas opacity (only
|
||||||
|
// the fog-scenery half falls through to BG_STYLES.off), so both are false.
|
||||||
const EXPECTED_USES = {
|
const EXPECTED_USES = {
|
||||||
off: { intensity: false, reactive: false },
|
off: { intensity: false, reactive: false },
|
||||||
particles: { intensity: true, reactive: true },
|
particles: { intensity: true, reactive: true },
|
||||||
@@ -73,6 +74,7 @@ function makeDom() {
|
|||||||
}
|
}
|
||||||
addEventListener(t, fn) { (this.listeners[t] || (this.listeners[t] = [])).push(fn); }
|
addEventListener(t, fn) { (this.listeners[t] || (this.listeners[t] = [])).push(fn); }
|
||||||
setAttribute(k, v) { this[k] = v; }
|
setAttribute(k, v) { this[k] = v; }
|
||||||
|
removeAttribute(k) { delete this[k]; }
|
||||||
get isConnected() {
|
get isConnected() {
|
||||||
let n = this;
|
let n = this;
|
||||||
while (n.parentNode) n = n.parentNode;
|
while (n.parentNode) n = n.parentNode;
|
||||||
@@ -127,7 +129,12 @@ function load({ store: initialStore } = {}) {
|
|||||||
const sandbox = {
|
const sandbox = {
|
||||||
console,
|
console,
|
||||||
BG_STYLE_IDS,
|
BG_STYLE_IDS,
|
||||||
|
// Module-scope in screen.js; the _pc* block reads it to resolve the
|
||||||
|
// effective style under the Venue override. Tests flip it via
|
||||||
|
// sandbox._venueSceneOverride and fire the 'venueScene' bus key.
|
||||||
|
_venueSceneOverride: false,
|
||||||
_bgReadSetting: (_panelKey, key) => store[key],
|
_bgReadSetting: (_panelKey, key) => store[key],
|
||||||
|
_bgReadGlobal: (key) => store[key],
|
||||||
_bgSubscribe: (fn) => listeners.add(fn),
|
_bgSubscribe: (fn) => listeners.add(fn),
|
||||||
_bgUnsubscribe: (fn) => listeners.delete(fn),
|
_bgUnsubscribe: (fn) => listeners.delete(fn),
|
||||||
setTimeout: (fn) => { timers.push(fn); return timers.length; },
|
setTimeout: (fn) => { timers.push(fn); return timers.length; },
|
||||||
@@ -139,6 +146,7 @@ function load({ store: initialStore } = {}) {
|
|||||||
},
|
},
|
||||||
window: {
|
window: {
|
||||||
feedBack: {
|
feedBack: {
|
||||||
|
uiVersion: 'v3', // _pcSlot gates on this (docs/plugin-v3-ui.md)
|
||||||
ui: { playerControlSlot: () => dom.slot },
|
ui: { playerControlSlot: () => dom.slot },
|
||||||
// The real bus is an EventTarget wrapper exposing on/off. Modelled
|
// The real bus is an EventTarget wrapper exposing on/off. Modelled
|
||||||
// here so the screen:changed subscription — and its removal — are
|
// here so the screen:changed subscription — and its removal — are
|
||||||
@@ -165,6 +173,7 @@ function load({ store: initialStore } = {}) {
|
|||||||
+ ' get sel() { return _pcSel; },'
|
+ ' get sel() { return _pcSel; },'
|
||||||
+ ' get react() { return _pcReactive; },'
|
+ ' get react() { return _pcReactive; },'
|
||||||
+ ' get intens() { return _pcIntensity; },'
|
+ ' get intens() { return _pcIntensity; },'
|
||||||
|
+ ' get reason() { return _pcReason; },'
|
||||||
+ ' get refs() { return _pcRefs; } })',
|
+ ' get refs() { return _pcRefs; } })',
|
||||||
sandbox,
|
sandbox,
|
||||||
);
|
);
|
||||||
@@ -173,6 +182,50 @@ function load({ store: initialStore } = {}) {
|
|||||||
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
|
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slice the real _bgReadSetting + _bgReadGlobal out of screen.js and run them
|
||||||
|
// against a localStorage stub. The main suite stubs both helpers identically,
|
||||||
|
// so it can't tell the #2 refactor from a no-op; this one proves the actual
|
||||||
|
// helper bodies differ where they must: _bgReadGlobal ignores a per-panel
|
||||||
|
// override that _bgReadSetting(panelKey, ...) still honours.
|
||||||
|
test('_bgReadGlobal reads the global slot, ignoring per-panel overrides', () => {
|
||||||
|
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||||
|
const rgStart = src.indexOf(' function _bgReadSetting(panelKey, key) {');
|
||||||
|
const rgEnd = src.indexOf(' // Shared "stored string -> bool" coercion');
|
||||||
|
assert.ok(rgStart !== -1 && rgEnd > rgStart, 'could not slice the read helpers');
|
||||||
|
const block = src.slice(rgStart, rgEnd);
|
||||||
|
|
||||||
|
const storage = new Map();
|
||||||
|
const sandbox = {
|
||||||
|
localStorage: { getItem: (k) => (storage.has(k) ? storage.get(k) : null) },
|
||||||
|
_bgCoerce: (_key, v) => v, // identity: we test key resolution, not coercion
|
||||||
|
_bgMemFallback: Object.create(null),
|
||||||
|
BG_DEFAULTS: { style: 'particles' },
|
||||||
|
};
|
||||||
|
sandbox.globalThis = sandbox;
|
||||||
|
const api = vm.runInNewContext(block + '\n({ _bgReadSetting, _bgReadGlobal, _bgMemFallback })', sandbox);
|
||||||
|
|
||||||
|
storage.set('h3d_bg_style', 'lights'); // global
|
||||||
|
storage.set('h3d_bg_panel3_style', 'geometric'); // a per-panel override
|
||||||
|
|
||||||
|
// The renderer, reading with a panel key, honours the per-panel override...
|
||||||
|
assert.equal(api._bgReadSetting('panel3', 'style'), 'geometric');
|
||||||
|
// ...but the shared control's global read must NOT see it - this is the
|
||||||
|
// whole point of #2 (previously _bgReadSetting(null, ...) relied on
|
||||||
|
// 'h3d_bg_null_style' never existing).
|
||||||
|
assert.equal(api._bgReadGlobal('style'), 'lights');
|
||||||
|
|
||||||
|
// In-memory staged value wins over the persisted global (matches
|
||||||
|
// _bgReadSetting's precedence).
|
||||||
|
api._bgMemFallback.style = 'aurora';
|
||||||
|
assert.equal(api._bgReadGlobal('style'), 'aurora');
|
||||||
|
delete api._bgMemFallback.style;
|
||||||
|
|
||||||
|
// Nothing stored -> BG_DEFAULTS.
|
||||||
|
assert.equal(api._bgReadGlobal('style'), 'lights');
|
||||||
|
storage.delete('h3d_bg_style');
|
||||||
|
assert.equal(api._bgReadGlobal('style'), 'particles');
|
||||||
|
});
|
||||||
|
|
||||||
test('mounts one control into the player-control slot', () => {
|
test('mounts one control into the player-control slot', () => {
|
||||||
const { api, dom } = load();
|
const { api, dom } = load();
|
||||||
api._pcAcquire();
|
api._pcAcquire();
|
||||||
@@ -199,6 +252,29 @@ test('multiple renderer instances share a single control', () => {
|
|||||||
assert.equal(api.el, null);
|
assert.equal(api.el, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('binds the screen hook on a retry when the bus was not ready at acquire', () => {
|
||||||
|
const ctl = load();
|
||||||
|
// Cold load: on a fresh page the renderer can init before the event bus is
|
||||||
|
// wired AND before the rail popover exists. Simulate both being absent.
|
||||||
|
const savedOn = ctl.sandbox.window.feedBack.on;
|
||||||
|
const savedUi = ctl.sandbox.window.feedBack.ui;
|
||||||
|
delete ctl.sandbox.window.feedBack.on;
|
||||||
|
ctl.sandbox.window.feedBack.ui = {}; // no playerControlSlot -> mount fails
|
||||||
|
|
||||||
|
ctl.api._pcAcquire();
|
||||||
|
assert.equal(ctl.screenHooks(), 0, 'nothing to bind to yet');
|
||||||
|
assert.equal(ctl.api.el, null, 'no slot yet, so nothing mounted');
|
||||||
|
|
||||||
|
// Bus + slot come online; the retry tick must bind the hook, not only mount.
|
||||||
|
ctl.sandbox.window.feedBack.on = savedOn;
|
||||||
|
ctl.sandbox.window.feedBack.ui = savedUi;
|
||||||
|
ctl.timers.shift()(); // run one retry tick
|
||||||
|
|
||||||
|
assert.equal(ctl.screenHooks(), 1, 'the retry tick failed to bind the screen hook');
|
||||||
|
assert.ok(ctl.api.el, 'and it should have mounted too');
|
||||||
|
ctl.api._pcRelease();
|
||||||
|
});
|
||||||
|
|
||||||
test('the last release unbinds the screen:changed hook', () => {
|
test('the last release unbinds the screen:changed hook', () => {
|
||||||
const ctl = load();
|
const ctl = load();
|
||||||
ctl.api._pcAcquire();
|
ctl.api._pcAcquire();
|
||||||
@@ -261,6 +337,18 @@ test('re-mounts into a fresh slot when the player chrome is rebuilt', () => {
|
|||||||
assert.equal(listenerCount(), 1, 'remount must not double-subscribe');
|
assert.equal(listenerCount(), 1, 'remount must not double-subscribe');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a non-v3 host mounts nothing (uiVersion gate)', () => {
|
||||||
|
const ctl = load();
|
||||||
|
ctl.sandbox.window.feedBack.uiVersion = 'v2'; // pre-v3 shell
|
||||||
|
ctl.api._pcAcquire();
|
||||||
|
assert.equal(ctl.api.el, null, 'must not mount when uiVersion is not v3');
|
||||||
|
assert.equal(ctl.dom.slot.children.length, 0);
|
||||||
|
// A non-v3 shell has no slot and never will, so no retry should be scheduled
|
||||||
|
// at all — the loop is for a not-yet-built v3 slot, not for polling v2.
|
||||||
|
assert.equal(ctl.timers.length, 0, 'a non-v3 host must not schedule the retry loop');
|
||||||
|
ctl.api._pcRelease();
|
||||||
|
});
|
||||||
|
|
||||||
test('a host with no player-control slot mounts nothing and does not throw', () => {
|
test('a host with no player-control slot mounts nothing and does not throw', () => {
|
||||||
const { api, dom, sandbox, timers } = load();
|
const { api, dom, sandbox, timers } = load();
|
||||||
sandbox.window.feedBack.ui = {};
|
sandbox.window.feedBack.ui = {};
|
||||||
@@ -300,6 +388,49 @@ test('the dropdown and Reactive pill drive the real setters', () => {
|
|||||||
assert.ok(writes.some((w) => w[0] === 'reactive'));
|
assert.ok(writes.some((w) => w[0] === 'reactive'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('exposes state and reasons to assistive tech', () => {
|
||||||
|
const ctl = load({ store: { style: 'image', reactive: true } }); // image: reactive inert
|
||||||
|
ctl.api._pcAcquire();
|
||||||
|
|
||||||
|
// The reason live-region must be a REAL mounted element with the id the
|
||||||
|
// controls reference - not a dangling pointer. Assert resolution, not a
|
||||||
|
// literal (a wrong id in code would still equal the literal).
|
||||||
|
const reason = ctl.api.reason;
|
||||||
|
assert.ok(reason, 'the reason span was not created');
|
||||||
|
assert.equal(reason.id, 'h3d-pc-reason');
|
||||||
|
assert.equal(reason.parentNode, ctl.api.el, 'the reason span must be mounted in the control');
|
||||||
|
|
||||||
|
// aria-pressed: a toggle button must expose its state. image greys
|
||||||
|
// Reactive, so not-pressed AND disabled, and it points at the reason.
|
||||||
|
assert.equal(ctl.api.react['aria-pressed'], 'false', 'greyed toggle is not pressed');
|
||||||
|
assert.equal(ctl.api.react['aria-disabled'], 'true');
|
||||||
|
// Pointer must resolve to the actual span's id (kills a wrong-id mutation),
|
||||||
|
// and the span must carry the current reason text (kills a never-set-text
|
||||||
|
// mutation).
|
||||||
|
assert.equal(ctl.api.react['aria-describedby'], reason.id, 'inert control must reference the reason span');
|
||||||
|
assert.equal(reason.textContent, 'This background does not react to audio', 'reason text must match the style');
|
||||||
|
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'an ENABLED control carries no reason');
|
||||||
|
|
||||||
|
// The intensity describe path: a style where INTENSITY is inert.
|
||||||
|
ctl.store.style = 'video'; ctl.emit('style');
|
||||||
|
assert.equal(ctl.api.intens.disabled, true, 'precondition: video greys intensity');
|
||||||
|
assert.equal(ctl.api.intens['aria-describedby'], reason.id, 'inert intensity must reference the reason');
|
||||||
|
assert.equal(reason.textContent, 'The video plays as-is - nothing to adjust here');
|
||||||
|
|
||||||
|
// Both enabled: describedby drops, aria-pressed follows the value.
|
||||||
|
ctl.store.style = 'particles'; ctl.store.reactive = true; ctl.emit('style');
|
||||||
|
assert.equal(ctl.api.react['aria-describedby'], undefined, 'enabled control drops the reason');
|
||||||
|
assert.equal(ctl.api.intens['aria-describedby'], undefined);
|
||||||
|
assert.equal(ctl.api.react['aria-pressed'], 'true', 'reactive on for particles');
|
||||||
|
ctl.store.reactive = false; ctl.emit('reactive');
|
||||||
|
assert.equal(ctl.api.react['aria-pressed'], 'false', 'aria-pressed follows the value');
|
||||||
|
|
||||||
|
// Accessible names on the non-label controls.
|
||||||
|
assert.equal(ctl.api.sel['aria-label'], 'Background style');
|
||||||
|
assert.equal(ctl.api.intens['aria-label'], 'Background intensity');
|
||||||
|
ctl.api._pcRelease();
|
||||||
|
});
|
||||||
|
|
||||||
test('greys out exactly the controls each style ignores', () => {
|
test('greys out exactly the controls each style ignores', () => {
|
||||||
const { api, store, emit } = load();
|
const { api, store, emit } = load();
|
||||||
api._pcAcquire();
|
api._pcAcquire();
|
||||||
@@ -311,6 +442,48 @@ test('greys out exactly the controls each style ignores', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the Venue override greys the whole Background group', () => {
|
||||||
|
const ctl = load({ store: { style: 'particles' } }); // a style that uses both
|
||||||
|
ctl.api._pcAcquire();
|
||||||
|
assert.equal(ctl.api.intens.disabled, false, 'precondition: both enabled off-venue');
|
||||||
|
assert.equal(ctl.api.react.disabled, false);
|
||||||
|
|
||||||
|
// Venue turns on: the effective style is now 'venue', which uses neither.
|
||||||
|
// The transition arrives on the settings bus as the 'venueScene' key.
|
||||||
|
ctl.sandbox._venueSceneOverride = true;
|
||||||
|
ctl.emit('venueScene');
|
||||||
|
assert.equal(ctl.api.intens.disabled, true, 'intensity should grey under Venue');
|
||||||
|
assert.equal(ctl.api.react.disabled, true, 'reactive should grey under Venue');
|
||||||
|
assert.equal(ctl.api.sel.disabled, true, 'the dropdown should be inert under Venue too');
|
||||||
|
assert.match(ctl.api.intens.title, /venue/i, 'reason should mention Venue');
|
||||||
|
// All three inert controls point at the reason under Venue (kills a
|
||||||
|
// 'describe reactive only' regression on the select/intensity paths).
|
||||||
|
const vReason = ctl.api.reason.id;
|
||||||
|
assert.equal(ctl.api.sel['aria-describedby'], vReason, 'select must reference the reason under Venue');
|
||||||
|
assert.equal(ctl.api.intens['aria-describedby'], vReason, 'intensity must reference the reason under Venue');
|
||||||
|
assert.equal(ctl.api.react['aria-describedby'], vReason, 'reactive must reference the reason under Venue');
|
||||||
|
assert.match(ctl.api.reason.textContent, /venue/i, 'the reason span carries the Venue text');
|
||||||
|
|
||||||
|
// The dropdown still shows the stored style (venue has no option), but
|
||||||
|
// selecting must not write while it's inert.
|
||||||
|
assert.equal(ctl.api.sel.value, 'particles');
|
||||||
|
const before = ctl.writes.length;
|
||||||
|
ctl.api.sel.value = 'lights';
|
||||||
|
ctl.api.sel.fire('change');
|
||||||
|
assert.equal(ctl.writes.length, before, 'a disabled dropdown must not write');
|
||||||
|
|
||||||
|
// Venue off: controls come back per the stored style.
|
||||||
|
ctl.sandbox._venueSceneOverride = false;
|
||||||
|
ctl.emit('venueScene');
|
||||||
|
assert.equal(ctl.api.intens.disabled, false, 'intensity re-enables when Venue exits');
|
||||||
|
assert.equal(ctl.api.react.disabled, false);
|
||||||
|
assert.equal(ctl.api.sel.disabled, false, 'the dropdown re-enables when Venue exits');
|
||||||
|
assert.equal(ctl.api.sel.title, 'Background style', 'the base tooltip must come back, not blank');
|
||||||
|
assert.equal(ctl.api.sel['aria-describedby'], undefined, 'select drops the reason off-Venue');
|
||||||
|
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'intensity drops the reason off-Venue');
|
||||||
|
ctl.api._pcRelease();
|
||||||
|
});
|
||||||
|
|
||||||
test('an unknown style enables both controls (fails open)', () => {
|
test('an unknown style enables both controls (fails open)', () => {
|
||||||
const { api, store, emit } = load();
|
const { api, store, emit } = load();
|
||||||
api._pcAcquire();
|
api._pcAcquire();
|
||||||
|
|||||||
Reference in New Issue
Block a user