Merge fix/mute-and-zone-orphan-fallback: per-item mute round-trip + multi-zone orphan-zone fallback

Brings in the mute pair (GET /api/devices/:id muted column + Android YouTube mute),
the zone-orphan fallback (web + Android player recovery, lib/zone-validate single
source, assign-time clearing, dashboard warnings + i18n), and 5 server regression
tests guarding the data contracts. 172/172 pass.
This commit is contained in:
ScreenTinker 2026-06-22 23:21:35 -05:00
commit 184f07c272
19 changed files with 554 additions and 59 deletions

View file

@ -657,7 +657,7 @@ class MainActivity : AppCompatActivity() {
// YouTube content - play in WebView
if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) {
Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}")
mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec)
mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec, item.muted)
wsService?.sendPlaybackState(item.contentId, 0f)
return
}

View file

@ -50,9 +50,14 @@ class MediaPlayerManager(
}
}
fun playYoutube(embedUrl: String, durationSec: Int = 0) {
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl")
// #129: remembered so the live device:mute-changed toggle knows YouTube's current
// state and the IFrame API bridge can flip it without reloading the embed.
private var youtubeMuted = false
fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)")
currentType = MediaType.YOUTUBE
youtubeMuted = muted || wallMute
playerView.visibility = android.view.View.GONE
imageView.visibility = android.view.View.GONE
@ -64,12 +69,25 @@ class MediaPlayerManager(
com.remotedisplay.player.util.WebViewSupport.configure(this, "YouTube")
setBackgroundColor(android.graphics.Color.BLACK)
// Load via an embed wrapper with a valid youtube.com origin (Error 153 fix).
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl)
// #129: initial mute comes from the per-item flag (no longer hardcoded).
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl, youtubeMuted)
if (html != null) loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
else loadUrl(embedUrl)
}
}
// #129: live mute for the YouTube embed via the IFrame API postMessage bridge
// (enablejsapi=1 is set on the embed). Avoids a full reload of the player, which
// would restart the video and flicker. Main thread only (WebView access).
private fun setYoutubeMuted(muted: Boolean) {
youtubeMuted = muted
val func = if (muted) "mute" else "unMute"
val js = "(function(){try{var f=document.querySelector('iframe');" +
"if(f&&f.contentWindow){f.contentWindow.postMessage(" +
"JSON.stringify({event:'command',func:'$func',args:[]}),'*');}}catch(e){}})()"
youtubeWebView?.let { wv -> wv.post { try { wv.evaluateJavascript(js, null) } catch (_: Throwable) {} } }
}
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
fun showWidget(url: String) {
@ -185,12 +203,17 @@ class MediaPlayerManager(
fun isPlayingVideo(): Boolean = currentType == MediaType.VIDEO && (exoPlayer?.isPlaying == true)
// #129: live per-item mute. Applies a dashboard mute toggle to the CURRENTLY playing
// video in real time (decoupled from a playlist reload). Only video carries audio here
// — YouTube embeds autoplay muted and images/widgets are silent — so this targets the
// ExoPlayer volume. Persistence across the next play comes from the playlist payload's
// per-item `muted` (honored in playVideo). Main thread only.
// item in real time (decoupled from a playlist reload). Native video -> ExoPlayer
// volume; YouTube -> the IFrame API mute()/unMute() bridge (setYoutubeMuted), which
// previously this method ignored so YouTube could never be un/muted live. Images/
// widgets are silent. Persistence across the next play comes from the playlist
// payload's per-item `muted` (honored in playVideo/playYoutube). Main thread only.
fun setVideoMuted(muted: Boolean) {
if (currentType == MediaType.VIDEO) exoPlayer?.volume = if (muted) 0f else 1f
when (currentType) {
MediaType.VIDEO -> exoPlayer?.volume = if (muted) 0f else 1f
MediaType.YOUTUBE -> setYoutubeMuted(muted) // #129: was a no-op for YouTube
else -> {}
}
}
// ---- Video-wall (wall:sync) accessors. All must be called on the main thread. ----

View file

@ -97,10 +97,24 @@ class ZoneManager(
}
// Group assignments by zone_id, ordered by sort_order so rotation is stable.
// Zone-orphan fallback: an item whose zone_id isn't a zone in the ACTIVE layout
// (assigned under a different layout, or the layout was duplicated/switched — copies
// get fresh zone ids) would otherwise be SILENTLY DROPPED: its bucket matches no
// rendered zone. Re-bucket it into the LARGEST-area zone's rotation so it shares
// screen time there (one item at a time -> never overlays existing content) and warn
// via DebugLog (mirrored to the dashboard device-log when debug is on).
val validZoneIds = zones.map { it.id }.toHashSet()
val fallbackZone = zones.maxByOrNull { it.widthPercent * it.heightPercent }
val assignmentsByZone = mutableMapOf<String?, MutableList<JSONObject>>()
for (i in 0 until assignments.length()) {
val a = assignments.getJSONObject(i)
val zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
var zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null)
if (zoneId != null && !validZoneIds.contains(zoneId) && fallbackZone != null) {
val item = a.optString("filename", "").ifEmpty { a.optString("content_id", a.optString("widget_id", "?")) }
com.remotedisplay.player.util.DebugLog.w("Zone",
"orphan zone_id=$zoneId item=$item -> fallback zone '${fallbackZone.name}'")
zoneId = fallbackZone.id
}
assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a)
}
assignmentsByZone.values.forEach { list -> list.sortBy { it.optInt("sort_order", 0) } }
@ -199,7 +213,9 @@ class ZoneManager(
// YouTube - render via an embed wrapper with a valid origin (Error 153 fix)
mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> {
val webView = createWebView()
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl)
// #129: initial mute from the per-item flag (was hardcoded mute=1). Live
// per-zone mute isn't wired (device:mute-changed targets the fullscreen item).
val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl, isMuted)
if (html != null) webView.loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null)
else webView.loadUrl(remoteUrl)
webView.layoutParams = params

View file

@ -74,10 +74,18 @@ object WebViewSupport {
* HTML wrapper for a YouTube embed. Loaded via loadDataWithBaseURL(YT_BASE, ...)
* so the iframe has a valid youtube.com origin/referer (a bare loadUrl of the
* embed gives Error 153 "player misconfigured"). Returns null if no video id.
*
* #129: the initial mute now comes from the per-item [muted] flag (was hardcoded
* mute=1, which made YouTube un-unmuteable). The WebView sets
* mediaPlaybackRequiresUserGesture=false, so mute=0 still autoplays WITH audio.
* enablejsapi=1 lets the live device:mute-changed toggle drive the player via the
* IFrame API postMessage bridge (MediaPlayerManager.setYoutubeMuted) without a
* flicker-y reload.
*/
fun youtubeEmbedHtml(url: String): String? {
fun youtubeEmbedHtml(url: String, muted: Boolean = true): String? {
val id = extractYoutubeId(url) ?: return null
val src = "$YT_BASE/embed/$id?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1"
val mute = if (muted) 1 else 0
val src = "$YT_BASE/embed/$id?autoplay=1&mute=$mute&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1&enablejsapi=1"
return "<!DOCTYPE html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
"<style>html,body{margin:0;padding:0;height:100%;background:#000;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style>" +
"</head><body><iframe src=\"$src\" allow=\"autoplay; encrypted-media\" allowfullscreen></iframe></body></html>"

View file

@ -2,6 +2,11 @@
// standard for B2B software in DACH). Native review recommended before
// publicizing as fully supported.
export default {
// #zone-orphan dashboard warnings
'device.pl_item.orphan_zone': 'Zone aus einem anderen Layout — neu zuweisen',
'device.pl_item.orphan_zone_tip': 'Die Zone dieses Elements gehört nicht zum aktuellen Layout des Geräts. Es wird weiterhin abgespielt (in die größte Zone verschoben), sollte aber einer Zone dieses Layouts neu zugewiesen werden.',
'dashboard.device_orphan_tip_one': '{n} Element ist einer Zone zugewiesen, die nicht im Layout dieses Geräts enthalten ist — zum Neuzuweisen das Gerät öffnen',
'dashboard.device_orphan_tip_other': '{n} Elemente sind einer Zone zugewiesen, die nicht im Layout dieses Geräts enthalten ist — zum Neuzuweisen das Gerät öffnen',
// Nav
'nav.displays': 'Bildschirme',
'nav.content': 'Inhalt',

View file

@ -1,6 +1,11 @@
// English translations. This file is the source of truth for keys —
// every other locale should mirror its keys (or fall back to en).
export default {
// #zone-orphan dashboard warnings
'device.pl_item.orphan_zone': 'Zone from a different layout — reassign',
'device.pl_item.orphan_zone_tip': "This item's zone isn't part of the device's current layout. It still plays (recovered into the largest zone), but reassign it to a zone in this layout.",
'dashboard.device_orphan_tip_one': "{n} item assigned to a zone that isn't in this device's layout — open the device to reassign",
'dashboard.device_orphan_tip_other': "{n} items assigned to a zone that isn't in this device's layout — open the device to reassign",
// Nav (sidebar)
'nav.displays': 'Displays',
'nav.content': 'Content',

View file

@ -1,6 +1,11 @@
// Spanish translations. Reviewed for UI register (informal tú).
// Native review still recommended before publicizing as fully supported.
export default {
// #zone-orphan dashboard warnings
'device.pl_item.orphan_zone': 'Zona de otro diseño — reasignar',
'device.pl_item.orphan_zone_tip': 'La zona de este elemento no pertenece al diseño actual del dispositivo. Se sigue reproduciendo (recuperado en la zona más grande), pero reasígnalo a una zona de este diseño.',
'dashboard.device_orphan_tip_one': '{n} elemento asignado a una zona que no está en el diseño de este dispositivo — abre el dispositivo para reasignar',
'dashboard.device_orphan_tip_other': '{n} elementos asignados a una zona que no está en el diseño de este dispositivo — abre el dispositivo para reasignar',
// Nav
'nav.displays': 'Pantallas',
'nav.content': 'Contenido',

View file

@ -2,6 +2,11 @@
// standard for software UIs in France; tu would feel underdressed for a B2B tool).
// Native review recommended before publicizing as fully supported.
export default {
// #zone-orphan dashboard warnings
'device.pl_item.orphan_zone': 'Zone d\'une autre mise en page — réattribuer',
'device.pl_item.orphan_zone_tip': 'La zone de cet élément ne fait pas partie de la mise en page actuelle de l\'appareil. Il continue de s\'afficher (récupéré dans la plus grande zone), mais réattribuez-le à une zone de cette mise en page.',
'dashboard.device_orphan_tip_one': '{n} élément attribué à une zone absente de la mise en page de cet appareil — ouvrez l\'appareil pour le réattribuer',
'dashboard.device_orphan_tip_other': '{n} éléments attribués à une zone absente de la mise en page de cet appareil — ouvrez l\'appareil pour les réattribuer',
// Nav
'nav.displays': 'Écrans',
'nav.content': 'Contenu',

View file

@ -1,6 +1,11 @@
// Italian translations. This file is the source of truth for keys —
// every other locale should mirror its keys (or fall back to en).
export default {
// #zone-orphan dashboard warnings
'device.pl_item.orphan_zone': 'Zona di un altro layout — riassegna',
'device.pl_item.orphan_zone_tip': 'La zona di questo elemento non fa parte del layout attuale del dispositivo. Continua a essere riprodotto (recuperato nella zona più grande), ma riassegnalo a una zona di questo layout.',
'dashboard.device_orphan_tip_one': '{n} elemento assegnato a una zona non presente nel layout di questo dispositivo — apri il dispositivo per riassegnarlo',
'dashboard.device_orphan_tip_other': '{n} elementi assegnati a una zona non presente nel layout di questo dispositivo — apri il dispositivo per riassegnarli',
// Nav (sidebar)
'nav.displays': 'Schermi',
'nav.content': 'Contenuti',

View file

@ -2,6 +2,11 @@
// Reviewed for UI register (informal você). Native review recommended before
// publicizing as fully supported.
export default {
// #zone-orphan dashboard warnings
'device.pl_item.orphan_zone': 'Zona de outro layout — reatribuir',
'device.pl_item.orphan_zone_tip': 'A zona deste item não faz parte do layout atual do dispositivo. Ele continua sendo reproduzido (recuperado na maior zona), mas reatribua-o a uma zona deste layout.',
'dashboard.device_orphan_tip_one': '{n} item atribuído a uma zona que não está no layout deste dispositivo — abra o dispositivo para reatribuir',
'dashboard.device_orphan_tip_other': '{n} itens atribuídos a uma zona que não está no layout deste dispositivo — abra o dispositivo para reatribuir',
// Nav
'nav.displays': 'Telas',
'nav.content': 'Conteúdo',

View file

@ -114,7 +114,10 @@ function renderDeviceCard(device) {
</div>
</div>
<div class="device-card-body">
<div class="device-card-name">${esc(device.name)}</div>
<div class="device-card-name">${esc(device.name)}${device.orphan_count > 0 ? `
<span class="device-orphan-badge" title="${tn('dashboard.device_orphan_tip', device.orphan_count)}" style="margin-left:6px;display:inline-flex;align-items:center;gap:3px;font-size:11px;color:var(--danger);vertical-align:middle">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>${device.orphan_count}
</span>` : ''}</div>
${device.owner_name || device.owner_email ? `<div style="font-size:11px;color:var(--text-muted);margin-bottom:4px">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-1px">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>

View file

@ -580,6 +580,10 @@ function renderPlaylist(assignments) {
${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` &middot; ${a.duration_sec}s` : ''}
${a.schedule_start ? ` &middot; ${a.schedule_start}-${a.schedule_end}` : ''}
</div>
${a.orphan ? `<div class="pl-orphan-warning" data-orphan-assignment="${a.id}" title="${t('device.pl_item.orphan_zone_tip')}" style="margin-top:4px;font-size:11px;color:var(--danger);cursor:pointer;display:inline-flex;align-items:center;gap:4px">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
${t('device.pl_item.orphan_zone')}
</div>` : ''}
</div>
<div class="playlist-item-actions" style="display:flex;align-items:center;gap:4px">
<select class="input zone-select" data-assignment-id="${a.id}" data-current-zone-id="${a.zone_id || ''}" style="width:100px;font-size:11px;padding:2px 4px;background:var(--bg-input);display:none">
@ -1202,26 +1206,37 @@ function attachRemoveHandlers(device) {
// Fetch errors are logged - the dropdowns simply stay hidden (display:none
// is the default from the render), same end-state as before but no longer
// silent.
// Inline per-item zone reassign dropdowns. A device WITH a layout always gets them;
// visibility is NOT gated on whether the zone list arrived (gating on that silently hid
// the selector when the server payload lacked active_layout_zones — e.g. a stale server
// during a deploy skew). Only a genuinely fullscreen device (no layout_id) has no zones.
if (device.layout_id) {
const token = localStorage.getItem('token');
fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` }})
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(layout => {
const zones = layout.zones || [];
// Show the selectors immediately; they become usable once zones populate below.
document.querySelectorAll('.zone-select').forEach(s => { s.style.display = ''; });
// Clicking an item's orphan warning badge scrolls to + focuses its zone-select.
document.querySelectorAll('.pl-orphan-warning').forEach(w => {
w.addEventListener('click', () => {
const sel = document.querySelector('.zone-select[data-assignment-id="' + w.dataset.orphanAssignment + '"]');
if (sel) { sel.scrollIntoView({ block: 'center', behavior: 'smooth' }); sel.focus(); }
});
});
const populateZoneSelects = (zones) => {
const activeIds = new Set((zones || []).map(z => z.id));
document.querySelectorAll('.zone-select').forEach(select => {
select.style.display = '';
while (select.options.length > 1) select.remove(1); // keep the "no zone" placeholder, drop stale options
const assignmentId = select.dataset.assignmentId;
const currentZoneId = select.dataset.currentZoneId || '';
zones.forEach(z => {
(zones || []).forEach(z => {
const opt = document.createElement('option');
opt.value = z.id;
opt.textContent = z.name;
select.appendChild(opt);
});
if (currentZoneId) select.value = currentZoneId;
const orphan = !!currentZoneId && !activeIds.has(currentZoneId);
if (currentZoneId && !orphan) select.value = currentZoneId; // can't select a zone the layout lacks
if (orphan) { select.style.borderColor = 'var(--danger)'; select.style.color = 'var(--danger)'; }
select.onchange = async () => {
try {
await api.updateAssignment(assignmentId, { zone_id: select.value || null });
@ -1230,12 +1245,21 @@ function attachRemoveHandlers(device) {
} catch (err) { showToast(err.message, 'error'); }
};
});
})
.catch(e => {
// No toast - fires once per device-detail load, would be annoying for
// a layout misconfig that's already surfaced via the modal info row.
console.warn('Failed to load layout for edit-zone dropdowns:', e.message);
});
};
if (device.active_layout_zones && device.active_layout_zones.length) {
// Fast path: zones already in the device payload — no round-trip.
populateZoneSelects(device.active_layout_zones);
} else {
// Fallback: payload field absent/empty (server/frontend version skew, or a payload
// change) — fetch the layout so the dropdowns still populate. A missing field must
// never make the selector silently vanish.
const token = localStorage.getItem('token');
fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` } })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(layout => populateZoneSelects(layout.zones || []))
.catch(e => console.warn('Zone dropdowns: layout fetch fallback failed:', e.message));
}
}
// Mute toggle buttons

View file

@ -0,0 +1,66 @@
#!/usr/bin/env node
/*
* Report-only audit: find playlist_items whose zone_id is NOT a zone in the
* device's ACTIVE layout i.e. orphaned cross-layout assignments. Un-patched
* players silently drop these; patched players (this branch) route them to the
* largest zone and emit a "zone" device-log warning. This script only REPORTS;
* it never mutates. Run it against a COPY of the prod DB.
*
* node scripts/find-orphan-zone-items.js [path/to/remote_display.db]
*
* Exit code is always 0 (it's a report); the count is printed.
*/
const path = require('path');
let Database;
try {
Database = require('better-sqlite3');
} catch (e) {
// Resolve from the server's node_modules when run from the repo root.
Database = require(path.join(__dirname, '..', 'server', 'node_modules', 'better-sqlite3'));
}
const dbPath = process.argv[2] || path.join(__dirname, '..', 'server', 'db', 'remote_display.db');
const db = new Database(dbPath, { readonly: true });
// One row per (device, zoned item). A playlist shared by N devices is checked
// against EACH device's layout, since the same item can be valid for one device
// and orphaned for another.
const rows = db.prepare(`
SELECT d.id AS device_id, d.name AS device_name,
d.layout_id AS device_layout, dl.name AS device_layout_name,
pi.id AS item_id, pi.zone_id,
c.filename, c.mime_type,
lz.layout_id AS zone_layout, zl.name AS zone_layout_name, lz.name AS zone_name
FROM devices d
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
LEFT JOIN content c ON c.id = pi.content_id
LEFT JOIN layout_zones lz ON lz.id = pi.zone_id
LEFT JOIN layouts dl ON dl.id = d.layout_id
LEFT JOIN layouts zl ON zl.id = lz.layout_id
WHERE pi.zone_id IS NOT NULL
`).all();
// Orphan = the item's zone doesn't exist any more, OR it belongs to a different
// layout than the device is actually rendering.
const orphans = rows.filter(r => !r.zone_layout || r.zone_layout !== r.device_layout);
if (!orphans.length) {
console.log(`No orphaned zone assignments found in ${dbPath}.`);
db.close();
process.exit(0);
}
console.log(`Found ${orphans.length} orphaned playlist_item(s) in ${dbPath}`);
console.log(`(zone_id references a zone that is NOT in the device's active layout):\n`);
for (const o of orphans) {
const sid = s => (s || '').slice(0, 8);
const where = o.zone_layout
? `zone "${o.zone_name}" lives in layout "${o.zone_layout_name}" (${sid(o.zone_layout)})`
: `zone_id no longer exists`;
console.log(` device "${o.device_name}" (${sid(o.device_id)}) active layout "${o.device_layout_name || '—'}" (${sid(o.device_layout)})`);
console.log(` item #${o.item_id} ${o.filename || '?'} [${o.mime_type || '?'}] zone_id=${sid(o.zone_id)} -> ${where}`);
}
console.log(`\nReport only — nothing changed. Un-patched players drop these; patched players`);
console.log(`route them to the largest zone and log a "zone" warning. Use the hardening`);
console.log(`(remap-on-duplicate / validate-on-assign) to stop new ones being created.`);
db.close();

View file

@ -0,0 +1,51 @@
const { db } = require('../db/database');
// Single source of truth for the "orphaned zone" definition used across the server:
// assignment validation (routes/assignments.js validZoneForLayout), the device payload
// orphan flags/counts (routes/devices.js), and — by the SAME rule, mirrored in their own
// languages — the player fallback (server/player/index.html, ZoneManager.kt) and the
// find-orphan-zone-items.js sweep.
//
// Rule: an item's zone_id is VALID only if it is a zone in the device's ACTIVE layout.
// A null/empty zone_id is "unassigned" (not an orphan). A zone_id on a device with no
// active layout can never be valid -> orphan.
/** True if zoneId belongs to layoutId (or zoneId is empty = unassigned). */
function zoneInLayout(zoneId, layoutId) {
if (!zoneId) return true;
if (!layoutId) return false;
return !!db.prepare('SELECT 1 FROM layout_zones WHERE id = ? AND layout_id = ?').get(zoneId, layoutId);
}
/** True when zoneId is set but NOT a zone in the device's active layout. */
function isOrphanZone(zoneId, layoutId) {
return !!zoneId && !zoneInLayout(zoneId, layoutId);
}
/** Zones (id+name) of a layout, for populating reassign dropdowns. [] if none. */
function layoutZones(layoutId) {
if (!layoutId) return [];
return db.prepare('SELECT id, name FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layoutId);
}
/**
* Bulk: map of device_id -> count of its playlist_items whose zone_id is NOT in the
* device's active layout. Same rule as isOrphanZone, computed in one query for the
* dashboard device list. Devices with zero orphans are omitted from the map.
*/
function orphanCountsByDevice(deviceIds) {
const rows = db.prepare(`
SELECT d.id AS device_id, COUNT(*) AS n
FROM devices d
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
LEFT JOIN layout_zones lz ON lz.id = pi.zone_id AND lz.layout_id = d.layout_id
WHERE pi.zone_id IS NOT NULL AND lz.id IS NULL
GROUP BY d.id
`).all();
const map = {};
const want = deviceIds && deviceIds.length ? new Set(deviceIds) : null;
for (const r of rows) { if (!want || want.has(r.device_id)) map[r.device_id] = r.n; }
return map;
}
module.exports = { zoneInLayout, isOrphanZone, layoutZones, orphanCountsByDevice };

View file

@ -363,6 +363,11 @@
// Per-zone rotation timers (multi-zone). Each zone advances independently on
// its own interval, decoupled from the fullscreen advanceTimer/nextItem.
let zoneTimers = {};
// #zone-orphan: parts of the operator-only PREVIEW banner (never shown on a live
// player). 'layout' = dominant-layout note when items span >1 layout; 'orphans' =
// the list of items whose zone isn't in the active layout. renderPreviewBanner()
// composes them into a single #previewBanner element.
let previewBannerParts = {};
// Video wall state. wallConfig is the tile assignment from the server
// (null when this device isn't in a wall). The leader runs the playlist
// normally and broadcasts wall:sync every second; followers don't run
@ -690,11 +695,9 @@
// playlist-only: items span >1 layout (rare) — server picked the dominant one.
// Device payloads never carry this flag (layout is device-bound, unambiguous).
if (payload.layout && payload.layout._preview_ambiguous) {
const b = document.createElement('div');
b.textContent = 'Previewing layout: ' + (payload.layout.name || '—');
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.92);color:#000;font:12px sans-serif;padding:4px 10px;text-align:center';
document.body.appendChild(b);
previewBannerParts.layout = 'Previewing dominant layout "' + (payload.layout.name || '—') + '" — items span more than one layout';
}
renderPreviewBanner();
handlePlaylistUpdate(payload);
} catch (e) {
console.error('preview fetch failed', e);
@ -702,6 +705,23 @@
}
}
// #zone-orphan: one operator-only banner, built from previewBannerParts. Created on
// first use, removed when there's nothing to report. PREVIEW_MODE only — the live
// player/wall must stay clean for the audience.
function renderPreviewBanner() {
if (!PREVIEW_MODE) return;
const lines = [previewBannerParts.layout, previewBannerParts.orphans].filter(Boolean);
let el = document.getElementById('previewBanner');
if (!lines.length) { if (el) el.remove(); return; }
if (!el) {
el = document.createElement('div');
el.id = 'previewBanner';
el.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:3000;background:rgba(245,158,11,.94);color:#000;font:12px sans-serif;padding:5px 10px;text-align:center;line-height:1.4';
document.body.appendChild(el);
}
el.innerHTML = lines.join('<br>');
}
function showPreviewError(status) {
const msg = (status === 401 || status === 403) ? 'Not authorized to preview this playlist'
: status ? ('Preview failed (' + status + ')') : 'Preview failed to load';
@ -963,6 +983,13 @@
function pipReport(level, msg) {
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'pip', level, message: msg }); } catch (e) {}
}
// Zone diagnostics (orphaned zone_id fallback). Mirrors pipReport: stream to the
// dashboard device-log (tag 'zone') AND the in-page debug overlay buffer.
function zoneReport(level, msg) {
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'zone', level, message: msg }); } catch (e) {}
try { window.__debugLog_push && window.__debugLog_push({ type: 'zone', level: level, msg: msg }); } catch (e) {}
try { console.warn('[zone] ' + msg); } catch (e) {}
}
function pipTeardown() {
try { if (pipTimer) clearTimeout(pipTimer); } catch (e) {}
pipTimer = null; pipCurrent = null;
@ -1741,13 +1768,42 @@
// Group assignments by zone, ordered by sort_order so each zone rotates its
// OWN list independently (images/widgets on a duration timer, videos on end)
// rather than every zone re-rendering on a single global tick.
// Zone-orphan fallback: an item whose zone_id is NOT a zone in the active layout
// (assigned under a different layout, or the layout was duplicated/switched — the new
// zones get fresh ids) would otherwise be SILENTLY DROPPED, because its bucket never
// matches a rendered zone. Re-bucket it into the LARGEST-area zone's rotation so it
// shares screen time there (one item at a time -> never overlays/stacks on existing
// content) and emit telemetry so the stale assignment is diagnosable. (See the
// fallback-rule rationale in the change notes.)
const validZoneIds = new Set(layout.zones.map(z => z.id));
const fallbackZone = layout.zones.reduce(
(a, b) => (((b.width_percent || 0) * (b.height_percent || 0)) > ((a.width_percent || 0) * (a.height_percent || 0)) ? b : a),
layout.zones[0]);
const byZone = {};
const orphanNames = [];
for (const a of playlist) {
const zid = a.zone_id || '__none__';
let zid = a.zone_id || '__none__';
if (a.zone_id && !validZoneIds.has(a.zone_id) && fallbackZone) {
zoneReport('warn', 'orphan zone_id=' + a.zone_id + ' item=' + (a.filename || a.content_id || a.widget_id || '?') +
' device=' + (config.deviceId || 'preview') + ' -> fallback zone "' + (fallbackZone.name || fallbackZone.id) + '"');
orphanNames.push(a.filename || a.content_id || a.widget_id || '?');
zid = fallbackZone.id;
}
(byZone[zid] = byZone[zid] || []).push(a);
}
for (const k in byZone) byZone[k].sort((x, y) => (x.sort_order || 0) - (y.sort_order || 0));
// #zone-orphan: operator-only preview note naming the stale items (NOT on a live
// player — zoneReport already streams those to the dashboard device-log instead).
if (PREVIEW_MODE) {
previewBannerParts.orphans = orphanNames.length
? orphanNames.length + ' item(s) assigned to a different layout: ' +
orphanNames.slice(0, 6).join(', ') + (orphanNames.length > 6 ? '…' : '') +
' — showing in "' + ((fallbackZone && (fallbackZone.name || fallbackZone.id)) || '—') + '"'
: null;
renderPreviewBanner();
}
let unassignedUsed = false;
layout.zones.forEach(zone => {
let items = byZone[zone.id];

View file

@ -7,12 +7,29 @@ const { PLATFORM_ROLES, ELEVATED_ROLES } = require('../middleware/auth');
// already carry workspace_id from Phase 1; this route can use them even
// though playlists.js itself isn't yet workspace-filtered.
const { accessContext } = require('../lib/tenancy');
const { zoneInLayout } = require('../lib/zone-validate');
// Mark playlist as draft (called after any item mutation)
function markDraft(playlistId) {
db.prepare("UPDATE playlists SET status = 'draft', updated_at = strftime('%s','now') WHERE id = ?").run(playlistId);
}
// Hardening (#zone-orphan): a zone_id only renders if it belongs to the layout the
// device is actually showing. Assigning a zone from a DIFFERENT layout (e.g. after a
// layout switch/duplicate) creates an item that the players can't place. We CLEAR a
// stale zone_id to null here (-> "unassigned", which the players route sensibly) rather
// than reject, so this can't break a caller; the cleared write is logged. NOTE for
// review: switch to a 400 reject if you'd rather surface the bad zone to the operator.
// Returns the zone_id to persist (the given one, or null if it isn't in the device's
// active layout). deviceLayoutId may be null (device on fullscreen) -> any zone_id is
// stale, so cleared.
function validZoneForLayout(zoneId, deviceLayoutId, ctx) {
if (!zoneId) return null;
if (zoneInLayout(zoneId, deviceLayoutId)) return zoneId;
console.warn(`[assign] cleared stale zone_id ${zoneId} (not in active layout ${deviceLayoutId || 'none'})${ctx ? ' ' + ctx : ''}`);
return null;
}
// Phase 2.2j: workspace-aware device access check. Returns access context
// (with workspaceRole/actingAs) or null. Caller decides if read or write.
function checkDeviceAccess(req, res, paramName = 'deviceId', requireWrite = true) {
@ -99,6 +116,10 @@ router.post('/device/:deviceId', (req, res) => {
const playlistId = ensureDevicePlaylist(req.params.deviceId, req.user.id);
// Hardening: clear a zone_id that isn't in THIS device's active layout (prevents new orphans).
const devLayout = db.prepare('SELECT layout_id FROM devices WHERE id = ?').get(req.params.deviceId);
const effZone = validZoneForLayout(zone_id, devLayout?.layout_id, `on add to device ${req.params.deviceId}`);
let order = sort_order;
if (order === undefined || order === null) {
const max = db.prepare('SELECT MAX(sort_order) as max_order FROM playlist_items WHERE playlist_id = ?')
@ -110,7 +131,7 @@ router.post('/device/:deviceId', (req, res) => {
const result = db.prepare(`
INSERT INTO playlist_items (playlist_id, content_id, widget_id, zone_id, sort_order, duration_sec)
VALUES (?, ?, ?, ?, ?, ?)
`).run(playlistId, content_id || null, widget_id || null, zone_id || null, order, duration_sec);
`).run(playlistId, content_id || null, widget_id || null, effZone, order, duration_sec);
markDraft(playlistId);
@ -168,7 +189,17 @@ router.put('/:id', (req, res) => {
if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); }
// zone_id can be null (clear the zone) - treat undefined as "no change",
// any other value (including null) as "write this".
if (zone_id !== undefined) { updates.push('zone_id = ?'); values.push(zone_id || null); }
if (zone_id !== undefined) {
// Hardening: if this playlist is bound to exactly ONE device with a layout, clear a
// zone_id that isn't in that layout (prevents new orphans). Multi-device / fullscreen
// playlists can't be bound to one layout here, so we leave those to the player fallback.
let effZone = zone_id || null;
if (effZone) {
const devs = db.prepare('SELECT layout_id FROM devices WHERE playlist_id = ? AND layout_id IS NOT NULL').all(item.playlist_id);
if (devs.length === 1) effZone = validZoneForLayout(effZone, devs[0].layout_id, `on update of item ${req.params.id}`);
}
updates.push('zone_id = ?'); values.push(effZone);
}
// #129: per-item mute (coerced to 0/1). Was silently dropped here before, so the
// dashboard toggle did nothing.
const mutedChanged = muted !== undefined && (item.muted ? 1 : 0) !== (muted ? 1 : 0);

View file

@ -6,6 +6,7 @@ const { PLATFORM_ROLES, ELEVATED_ROLES, isPlatformStaff } = require('../middlewa
// or null based on the caller's reach into a specific workspace.
const { accessContext } = require('../lib/tenancy');
const { stripDeviceSecrets } = require('../lib/device-sanitize');
const { layoutZones, orphanCountsByDevice } = require('../lib/zone-validate');
// List devices in the caller's current workspace.
// Phase 2.2a: filter by workspace_id instead of user_id. The caller's current
@ -40,7 +41,10 @@ router.get('/', (req, res) => {
ORDER BY d.sort_order ASC, d.created_at ASC
LIMIT ? OFFSET ?
`).all(req.workspaceId, limit, offset);
res.json(devices.map(stripDeviceSecrets));
// #zone-orphan: lightweight per-device count of playlist items whose zone_id isn't in
// the device's active layout, so the dashboard can flag screens that need attention.
const orphanCounts = orphanCountsByDevice(devices.map(d => d.id));
res.json(devices.map(d => ({ ...stripDeviceSecrets(d), orphan_count: orphanCounts[d.id] || 0 })));
});
// #106: reorder display tiles (cosmetic, within-section). Writes devices.sort_order
@ -109,7 +113,7 @@ router.get('/:id', (req, res) => {
let playlist_has_published = false;
if (device.playlist_id) {
assignments = db.prepare(`
SELECT pi.id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
SELECT pi.id, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, pi.muted,
pi.created_at, pi.updated_at,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.remote_url,
@ -127,6 +131,14 @@ router.get('/:id', (req, res) => {
}
}
// #zone-orphan: flag any item whose zone_id isn't a zone in the device's ACTIVE layout
// (same rule as lib/zone-validate). The dashboard shows a per-item "reassign" warning;
// active_layout_zones ships the zone list here too so the inline reassign dropdown needs
// no separate /api/layouts round-trip. Informational only — playback uses the fallback.
const active_layout_zones = layoutZones(device.layout_id);
const activeZoneIdSet = new Set(active_layout_zones.map(z => z.id));
for (const a of assignments) a.orphan = !!a.zone_id && !activeZoneIdSet.has(a.zone_id);
// Uptime timeline: get status change events for last 24 hours
const dayAgo = Math.floor(Date.now() / 1000) - 86400;
let statusLog = [];
@ -141,7 +153,7 @@ router.get('/:id', (req, res) => {
'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC'
).all(req.params.id, dayAgo).map(r => r.reported_at);
res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, playlist_status, playlist_has_published, uptimeData, statusLog });
res.json({ ...stripDeviceSecrets(device), telemetry, screenshot, assignments, active_layout_zones, playlist_status, playlist_has_published, uptimeData, statusLog });
});
// Helper: check device write access via the workspace the device belongs to.

View file

@ -227,20 +227,29 @@ router.post('/:id/duplicate', (req, res) => {
db.prepare('INSERT INTO layouts (id, user_id, workspace_id, name, width, height) VALUES (?, ?, ?, ?, ?, ?)')
.run(newId, req.user.id, req.workspaceId, name, source.width, source.height);
// Copy zones
// Copy zones, keeping an old->new zone-id map. The copy gets fresh zone ids, so any
// playlist_items still pointing at the SOURCE zones would be orphaned if a device is
// moved onto this copy. We return the map (zone_id_map) so a follow-up remap can run.
// NOTE for review: we intentionally do NOT auto-rewrite playlist_items.zone_id here —
// the source layout's own assignments must keep pointing at the source. A safe remap is
// a scoped op ("migrate playlist P from layout A to its copy B"), best done explicitly;
// see find-orphan-zone-items.js + the player fallback, which already de-risk the runtime.
const zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ?').all(req.params.id);
const stmt = db.prepare(`
INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const zone_id_map = {};
zones.forEach(z => {
stmt.run(uuidv4(), newId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
const nz = uuidv4();
zone_id_map[z.id] = nz;
stmt.run(nz, newId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
z.z_index, z.zone_type, z.fit_mode, z.background_color, z.sort_order);
});
const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(newId);
layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(newId);
res.status(201).json(layout);
res.status(201).json({ ...layout, zone_id_map });
});
// Assign layout to device.

View file

@ -0,0 +1,166 @@
'use strict';
// Regression tests for the SERVER-SIDE data contracts added by the mute + zone-orphan
// branch. These guard the exact bugs we fixed so they can't silently come back:
// 1. GET /api/devices/:id must carry each item's `muted` — and BOTH true and false
// (the bug was a SELECT that dropped the column; the false case is the one that broke).
// 2. GET /api/devices/:id must return `active_layout_zones` for a multi-zone device
// (the contract the dashboard zone-selector now depends on).
// 3. The single-source orphan rule (lib/zone-validate): a zone in the active layout is
// NOT orphaned; a zone from a DIFFERENT layout IS — surfaced as the per-item `orphan`
// flag and the device-list `orphan_count`.
// 4. Reassigning an orphan to a valid zone drops `orphan_count` to 0.
// 5. Assign-time hardening: a zone_id not in the device's active layout is cleared to
// null on POST; a valid one is kept.
//
// Mirrors mute.test.js: boots the real server.js against an isolated DB and seeds rows on
// one connection (FK off) to avoid WAL visibility races. No player/DOM/Playwright tests.
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs');
const crypto = require('node:crypto');
const Database = require('better-sqlite3');
const PORT = 3996;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-zone-test-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-zone-' + crypto.randomBytes(4).toString('hex') + '.log');
const PW = 'Passw0rd123';
let proc, db;
const S = {};
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const auth = (tok) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json' } });
const post = (tok, obj) => ({ method: 'POST', ...auth(tok), body: JSON.stringify(obj || {}) });
const put = (tok, obj) => ({ method: 'PUT', ...auth(tok), body: JSON.stringify(obj || {}) });
// Find one item in the device payload by playlist_item id (ids are integers; coerce both).
async function getAssignment(itemId) {
const r = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt));
return (r.body.assignments || []).find((a) => Number(a.id) === Number(itemId));
}
// Read the device's orphan_count off the workspace device list.
async function getOrphanCount() {
const r = await jfetch('/api/devices', auth(S.jwt));
const d = (r.body || []).find((x) => x.id === S.deviceId);
return d ? d.orphan_count : undefined;
}
before(async () => {
const logFd = fs.openSync(LOG, 'w');
proc = spawn('node', ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
stdio: ['ignore', logFd, logFd],
});
let up = false;
for (let i = 0; i < 80; i++) {
try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* not yet */ }
await new Promise((r) => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
// First user -> platform_admin; register returns the JWT, the user, and the workspace.
const reg = await jfetch('/api/auth/register', post(null, { email: 'z' + crypto.randomBytes(4).toString('hex') + '@x.local', password: PW }));
S.jwt = reg.body.token;
S.userId = reg.body.user.id;
S.wsA = reg.body.current_workspace_id;
// Active multi-zone layout L1 (Main, Side) + a DIFFERENT layout L2 (Other) — via the API
// so zone ids are real and workspace-scoped. L2's zone is the "different layout" orphan.
const l1 = await jfetch('/api/layouts', post(S.jwt, { name: 'L1', zones: [{ name: 'Main', width_percent: 60, height_percent: 100 }, { name: 'Side', width_percent: 40, height_percent: 100 }] }));
S.L1 = l1.body.id; S.Z1 = l1.body.zones[0].id; S.Z2 = l1.body.zones[1].id;
const l2 = await jfetch('/api/layouts', post(S.jwt, { name: 'L2', zones: [{ name: 'Other', width_percent: 100, height_percent: 100 }] }));
S.ZX = l2.body.zones[0].id;
const pl = await jfetch('/api/playlists', post(S.jwt, { name: 'zone-pl' }));
S.playlistId = pl.body.id;
// Seed content, a device, and playlist_items on one connection (FK off). The orphan item
// is seeded DIRECTLY so it bypasses assign-time validation — that's how real orphans
// arise (assigned under a different layout / layout switched after the fact).
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'), { timeout: 5000 });
db.pragma('foreign_keys = OFF');
const mkContent = (name) => {
const id = crypto.randomUUID();
db.prepare("INSERT INTO content (id, filename, filepath, mime_type, file_size, remote_url) VALUES (?,?,?,?,0,?)")
.run(id, name, '', 'image/png', 'https://example.com/' + name + '.png');
return id;
};
S.cMute = mkContent('mute'); S.cValid = mkContent('valid'); S.cOrphan = mkContent('orphan');
S.cPostStale = mkContent('post-stale'); S.cPostOk = mkContent('post-ok');
S.deviceId = crypto.randomUUID();
db.prepare("INSERT INTO devices (id, name, status, workspace_id, user_id, layout_id, playlist_id) VALUES (?,?,?,?,?,?,?)")
.run(S.deviceId, 'ZoneDev', 'online', S.wsA, S.userId, S.L1, S.playlistId);
const addItem = (contentId, zoneId, sort) =>
db.prepare("INSERT INTO playlist_items (playlist_id, content_id, zone_id, sort_order, duration_sec, muted) VALUES (?,?,?,?,10,0)")
.run(S.playlistId, contentId, zoneId, sort).lastInsertRowid;
S.itemMute = addItem(S.cMute, null, 0); // no zone — for the mute round-trip
S.itemValid = addItem(S.cValid, S.Z1, 1); // zone in the active layout -> NOT orphan
S.itemOrphan = addItem(S.cOrphan, S.ZX, 2); // zone from L2 -> orphan
});
after(async () => {
try { db?.close(); } catch { /* */ }
if (proc) proc.kill('SIGKILL');
for (const f of [DATA_DIR, LOG]) { try { fs.rmSync(f, { recursive: true, force: true }); } catch { /* */ } }
});
// 1. muted must round-trip through the device payload SELECT — both states.
test('GET /api/devices/:id carries per-item muted (true AND false)', async () => {
await jfetch(`/api/assignments/${S.itemMute}`, put(S.jwt, { muted: true }));
let a = await getAssignment(S.itemMute);
assert.ok(a, 'item appears in the device payload');
assert.equal(a.muted, 1, 'muted=true survives the GET /api/devices/:id SELECT');
await jfetch(`/api/assignments/${S.itemMute}`, put(S.jwt, { muted: false }));
a = await getAssignment(S.itemMute);
assert.equal(a.muted, 0, 'muted=false survives too (the case that originally broke)');
});
// 2. active_layout_zones contract for a multi-zone device.
test('GET /api/devices/:id returns active_layout_zones for a multi-zone device', async () => {
const r = await jfetch(`/api/devices/${S.deviceId}`, auth(S.jwt));
const zones = r.body.active_layout_zones;
assert.ok(Array.isArray(zones), 'active_layout_zones is present');
assert.equal(zones.length, 2, 'both zones of the active layout are returned');
assert.deepEqual(zones.map((z) => z.id).sort(), [S.Z1, S.Z2].sort(), 'exactly the active-layout zone ids');
});
// 3. orphan definition: in-layout zone -> not orphan; different-layout zone -> orphan.
test('orphan flag + orphan_count reflect the single-source rule', async () => {
const valid = await getAssignment(S.itemValid);
const orphan = await getAssignment(S.itemOrphan);
assert.equal(valid.orphan, false, 'a zone in the active layout is NOT orphaned');
assert.equal(orphan.orphan, true, 'a zone from a different layout IS orphaned');
assert.equal(await getOrphanCount(), 1, 'device list orphan_count counts exactly the one orphan');
});
// 4. reassigning the orphan to a valid zone clears the count.
test('reassigning an orphan to a valid zone clears orphan_count', async () => {
assert.equal(await getOrphanCount(), 1, 'precondition: one orphan');
const r = await jfetch(`/api/assignments/${S.itemOrphan}`, put(S.jwt, { zone_id: S.Z1 }));
assert.equal(r.body.zone_id, S.Z1, 'reassignment to a valid zone persists');
assert.equal(await getOrphanCount(), 0, 'orphan_count drops to 0 after reassign');
});
// 5. assign-time hardening: stale zone_id cleared, valid kept.
test('POST assignment clears a stale zone_id and keeps a valid one', async () => {
const stale = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cPostStale, zone_id: S.ZX, duration_sec: 10 }));
assert.equal(stale.status, 201);
assert.equal(stale.body.zone_id, null, 'a zone_id from a different layout is cleared to null on add');
const ok = await jfetch(`/api/assignments/device/${S.deviceId}`, post(S.jwt, { content_id: S.cPostOk, zone_id: S.Z2, duration_sec: 10 }));
assert.equal(ok.status, 201);
assert.equal(ok.body.zone_id, S.Z2, 'a zone_id in the active layout is kept');
});