mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Both players carried calls that compile, read correctly, and are documented to
do something else. Verified line by line against docs.brightsign.biz and
Samsung's Smart TV Filesystem reference; every fix below cites the doc that
proves it, and the linter has been extended so each one fails here next time.
TIZEN
The offline media cache could never have worked on a panel. Its adapter used
the deprecated Filesystem API in three ways the IDL rules out:
`tizen.filesystem.resolve()` is declared `void`, so `var dir = resolve(...)`
was always undefined and MediaCache.create() returned null on every panel in
the fleet; `openStream()` is asynchronous, so appendPart read `written` before
any callback could run and returned 0 forever; and `moveTo()` is asynchronous,
belongs on the parent directory, and takes (origin, destination) — it was
called on a file handle with the arguments transposed. Rewritten against the
5.0 synchronous FileSystemManager, which is genuinely synchronous and is what
the decision layer needs. A Tizen 4.0 panel now reports available() false
instead of being handed a cache that silently writes nothing.
Writes are now POSITIONED rather than appended at EOF. Power cut between a
write and the index save — the exact event this feature exists for — replayed
the last chunk, and an append landed it twice: a silently corrupt video that
promoted as complete. A positioned write makes the replay idempotent.
Three decision-layer bugs alongside it: a 206 with no readable Content-Range
fell back to Content-Length, which is the CHUNK length, so the first megabyte
of a 50MB video promoted as a complete 1MB asset; a 200 whose body was short of
its own Content-Length returned 'done'; and a server with no ETag or
Last-Modified was re-fetched from zero on every sweep, forever, on precisely
the marginal link this feature exists to be gentle on.
The volume slider was dead. The dashboard sends `{level: 0..1}`; this handler
read `value`/`volume` as a 0..100 percentage, so it matched nothing and logged
"no usable value in payload" on every slider move while the panel declared
audio.volume as working. Both halves had to move together — taking `level` as a
percentage turns 50% into 0.5%, which is inaudible and looks like a fix.
Verified by driving the real handler in headless Chrome, before and after.
BRIGHTSIGN
FindMemberFunction is documented as available only when
roDeviceInfo.HasFeature("FindMemberFunction") is true. It was called
unguarded from the capability probe and from host telemetry — both on the event
loop — so a player without the feature would have died within a minute of boot
and taken the display with it. The guard needed guarding.
The boot report never arrived. The host flushed its buffer straight after
Show(), before the page had been fetched, while the player correctly waits for
its socket before subscribing. Between two correct decisions every boot line
fell on the floor. The host now waits for the page's `probe`, and the bridge
buffers until a consumer registers.
offline.cache was claimed on `navigator.serviceWorker` being present. It is
present on a BrightSign widget and will not run a worker — our XT245 passes the
check and never fetches sw.js. Now requires a controller, matching the web
player. Removed from the brightsign baseline for the same reason.
display.resolution was claimed on @brightsign/videooutput, which has no
setMode at all; mode setting lives on @brightsign/videomodeconfiguration.
roStorageHotplug.GetStorages() answers "USB1:/" while GetStorageStatus() is
documented as unreliable for "USBn:" — feeding one to the other re-created the
bug the static fallback list exists to avoid, and only on the OS versions that
have the enumerator.
dual/clone output mode put two full-screen widgets on output ONE, on top of
each other, while output two stayed dark: roHtmlWidget has no output selector,
and a second output is addressed by its display_x/display_y within the
SetScreenModes canvas. Now positioned properly, or refused with a reason.
Also: a manifest missing sha256/size passed `invalid` into typed parameters, a
runtime error at the call the comment already described and did not prevent;
storage_quota was a string where the docs say use a double; and the comment
crediting brightsign_js_objects_enabled with gating require("@brightsign/*")
named the wrong flag — it is nodejs_enabled.
TESTS
The two suites that mattered most were the ones that passed while the code was
broken, because they asserted on source text or against a fake more correct
than the platform. The host-diagnostics regexes now execute the bridge; the
media-cache suite now drives the shipped adapter against a fake tizen.filesystem
written from Samsung's IDL. Ten new rules in the BrightScript linter, each
verified to fail against the source it was written to reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
141 lines
6.4 KiB
JavaScript
141 lines
6.4 KiB
JavaScript
'use strict';
|
|
|
|
/*
|
|
* The volume slider, end to end.
|
|
*
|
|
* The dashboard sends `set_volume` with `{ level: <0..1 fraction> }`
|
|
* (frontend/js/views/device-detail.js: `{ level: parseInt(el.value, 10) / 100 }`). The Android
|
|
* player reads exactly that (`payload.optDouble("level")`). The Tizen player read `value`/`volume`
|
|
* as a 0..100 PERCENTAGE, so it matched nothing the dashboard has ever sent: every slider move
|
|
* logged "no usable value in payload" and changed nothing, on a panel that declared audio.volume as
|
|
* a working capability.
|
|
*
|
|
* Two mistakes, and fixing either one alone is worse than fixing neither:
|
|
* - the KEY: `level`, not `value`/`volume`
|
|
* - the SCALE: a fraction, not a percentage
|
|
* Take `level` while still treating it as a percentage and a request for 50% becomes 0.5% — silent,
|
|
* and indistinguishable from a slider that works.
|
|
*
|
|
* The handler is EXECUTED here, lifted out of the shipped app.js the same way the wall-geometry
|
|
* parity test lifts the Tizen tile maths. A regex asserting that the file mentions "level" would
|
|
* pass on the 0.5%-instead-of-50% version, which is the one failure mode that matters.
|
|
*/
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..', '..');
|
|
const APP = fs.readFileSync(path.join(ROOT, 'tizen', 'js', 'app.js'), 'utf8');
|
|
|
|
/**
|
|
* Lift the real applyVolume out of app.js and run it against recording stubs.
|
|
* Returns { set(payload) -> {tv, media, warned} }.
|
|
*/
|
|
function loadHandler({ hasTvAudio = true } = {}) {
|
|
const m = /(\n\s*var mediaVolume = null;[\s\S]*?\n function applyVolume\(payload\) \{[\s\S]*?\n \})/.exec(APP);
|
|
assert.ok(m, 'could not find applyVolume in tizen/js/app.js');
|
|
|
|
const calls = { tv: null, media: null, logs: [] };
|
|
|
|
// A vm context rather than `new Function`, because the handler reaches STCapabilities as a BARE
|
|
// global (`window.STCapabilities ? STCapabilities.tvAudio() : null`) — a local `var window` would
|
|
// leave that a ReferenceError, the try/catch would swallow it, and the test would silently
|
|
// exercise only the fallback path while claiming to cover the TV one.
|
|
const vm = require('node:vm');
|
|
const sandbox = {
|
|
STCapabilities: {
|
|
tvAudio: () => (hasTvAudio ? { setVolume: (v) => { calls.tv = v; } } : null),
|
|
},
|
|
reportCmd: (level, cmd, msg) => calls.logs.push(level + ':' + msg),
|
|
Number, Math, isFinite,
|
|
__calls: calls,
|
|
};
|
|
sandbox.window = sandbox;
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(`
|
|
function applyMediaVolume() { __calls.media = mediaVolume; }
|
|
${m[1]}
|
|
`, sandbox);
|
|
const harness = sandbox.applyVolume;
|
|
assert.equal(typeof harness, 'function');
|
|
|
|
return {
|
|
set(payload) {
|
|
calls.tv = null; calls.media = null; calls.logs = [];
|
|
harness(payload);
|
|
return {
|
|
tv: calls.tv,
|
|
media: calls.media,
|
|
warned: calls.logs.some((l) => l.startsWith('warn')),
|
|
logs: calls.logs,
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
test('THE DASHBOARD PAYLOAD: {level: 0..1} reaches the TV as 0..100', () => {
|
|
const h = loadHandler();
|
|
// Exactly what frontend/js/views/device-detail.js sends for slider positions 0 / 50 / 100.
|
|
assert.deepEqual(h.set({ level: 0 }).tv, 0);
|
|
assert.deepEqual(h.set({ level: 0.5 }).tv, 50, 'half volume must be 50, not 0.5');
|
|
assert.deepEqual(h.set({ level: 1 }).tv, 100, 'full volume must be 100, not 1');
|
|
assert.equal(h.set({ level: 0.5 }).warned, false, 'and must not report the payload unusable');
|
|
});
|
|
|
|
test('THE TRAP: reading `level` as a percentage would be inaudible, not merely wrong', () => {
|
|
// 0.5 interpreted as a percentage is 0.5% — near silence. It changes the volume, logs success,
|
|
// and looks from the dashboard exactly like a working slider. This is the assertion that
|
|
// distinguishes a real fix from a plausible one.
|
|
const h = loadHandler();
|
|
assert.ok(h.set({ level: 0.5 }).tv > 1, 'a fraction must be scaled, not clamped into near-silence');
|
|
});
|
|
|
|
test('tvaudiocontrol is preferred — it is the volume that reaches the panel speakers', () => {
|
|
// Tizen has two volumes and only one of them is audible on a TV: tizen.tvaudiocontrol is the
|
|
// SET's own volume and applies to AVPlay video on the hardware plane, which the media elements
|
|
// cannot touch at all. Portrait video (#170) plays through AVPlay, so a media-element-only
|
|
// implementation would leave a rotated panel at full blast.
|
|
const withTv = loadHandler({ hasTvAudio: true }).set({ level: 0.3 });
|
|
assert.equal(withTv.tv, 30);
|
|
assert.equal(withTv.media, null, 'the media fallback must not also run');
|
|
});
|
|
|
|
test('...and a build with no TV profile still moves the media elements', () => {
|
|
// The URL-Launcher path and a plain browser have no tv.audio surface. Falling through keeps the
|
|
// control honest rather than silently doing nothing.
|
|
const noTv = loadHandler({ hasTvAudio: false }).set({ level: 0.4 });
|
|
assert.equal(noTv.tv, null);
|
|
assert.ok(Math.abs(noTv.media - 0.4) < 1e-9, 'media elements take a 0..1 fraction');
|
|
});
|
|
|
|
test('legacy percentage senders still work, so one dead control is not traded for another', () => {
|
|
// The group-command route and hand-issued commands use `value`. These are percentages, not
|
|
// fractions — a different key, so there is no ambiguity to resolve.
|
|
const h = loadHandler();
|
|
assert.equal(h.set({ value: 25 }).tv, 25);
|
|
assert.equal(h.set({ volume: 70 }).tv, 70);
|
|
});
|
|
|
|
test('a payload with nothing usable is refused loudly rather than defaulting to silence', () => {
|
|
const h = loadHandler();
|
|
const r = h.set({ nothing: true });
|
|
assert.equal(r.tv, null);
|
|
assert.ok(r.warned, 'an unusable payload must say so — a silent 0 reads as broken hardware');
|
|
});
|
|
|
|
test('out-of-range values are clamped, not passed through to the panel API', () => {
|
|
const h = loadHandler();
|
|
assert.equal(h.set({ level: 5 }).tv, 100);
|
|
assert.equal(h.set({ level: -2 }).tv, 0);
|
|
});
|
|
|
|
test('the dashboard really does send `level` as a fraction — the other half of the contract', () => {
|
|
// Pinned against the sender, because this test is only meaningful while that stays true. If the
|
|
// dashboard ever switches to percentages, this fails here instead of on a shop floor.
|
|
const ui = fs.readFileSync(path.join(ROOT, 'frontend', 'js', 'views', 'device-detail.js'), 'utf8');
|
|
assert.match(ui, /sendCommand\(device\.id, cmd, \{ level: parseInt\(el\.value, 10\) \/ 100 \}\)/,
|
|
'the set_volume wire format is { level: 0..1 }');
|
|
});
|