From 68aa37f093f5afb9f323f1296b6a0e6508f3221c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 12 Sep 2026 19:53:29 +0300 Subject: [PATCH] ui: header indicator shows sim/nosim icons once the server is up - #state-indicator is now a wrapper with a dot plus an 18px dark:invert image; no new compiled Tailwind classes (h-4/w-5 are not in the prebuilt CSS), so sizing uses an inline style and the header height is unchanged - gray dot while probing, red dot when the server is unreachable; server up -> nosim.svg, animated sim_anim.svg while equipping, and sim.svg when the card is equipped (`_pysimEquipping` tracked from status.equipping, refreshed on every 2s poll) - i18n reuses existing strings; tests for all five states, dot color transitions and the markup; SW cache v109 -> v110. --- frontend/index.html | 34 ++++++--- frontend/sw.js | 2 +- frontend/tests/indicator.test.js | 124 +++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 frontend/tests/indicator.test.js diff --git a/frontend/index.html b/frontend/index.html index 45a6dcf..a659fd6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -20,7 +20,10 @@

OTAMan SIM OTA with a Human Face v2.0.0

- + + + + github help @@ -4377,6 +4380,7 @@ async function pysimFetch(path, body) { // ===== UI availability (server / card) ===== let _pysimServerAvailable = null; // null until probed let _pysimCardEquipped = false; +let _pysimEquipping = false; let _pysimAvailabilityTimer = null; function pysimAvailabilityState() { @@ -4396,16 +4400,23 @@ function pysimNeedsHint(needs, state) { function pysimUpdateStateIndicator() { const el = document.getElementById('state-indicator'); - if (!el) return; - const state = pysimAvailabilityState(); - const entry = { - 'server-down': ['text-red-500', t('No server connection')], - 'no-card': ['text-yellow-600', t('Server connected, no card equipped')], - 'card': ['text-emerald-600', t('Card equipped')], - }[state]; - ['text-red-500', 'text-yellow-600', 'text-emerald-600', 'text-gray-400'].forEach(c => el.classList.remove(c)); - el.classList.add(entry[0]); - el.setAttribute('title', entry[1]); + const dot = document.getElementById('state-indicator-dot'); + const img = document.getElementById('state-indicator-img'); + if (!el || !dot || !img) return; + if (_pysimServerAvailable === true) { + const state = pysimAvailabilityState(); + dot.classList.add('hidden'); + img.classList.remove('hidden'); + img.src = _pysimEquipping ? 'sim_anim.svg' : (state === 'card' ? 'sim.svg' : 'nosim.svg'); + el.setAttribute('title', _pysimEquipping ? t('Card inserted — initializing...') + : (state === 'card' ? t('Card equipped') : t('Server connected, no card equipped'))); + } else { + img.classList.add('hidden'); + dot.classList.remove('hidden'); + dot.classList.remove('text-red-500', 'text-gray-400'); + dot.classList.add(_pysimServerAvailable === false ? 'text-red-500' : 'text-gray-400'); + el.setAttribute('title', _pysimServerAvailable === false ? t('No server connection') : t('Connecting...')); + } } function pysimApplyAvailability() { @@ -6614,6 +6625,7 @@ function pysimCardStateUpdate(status) { if (!status || typeof status.connected !== 'boolean') return; _pysimServerAvailable = true; _pysimCardEquipped = !!status.connected; + _pysimEquipping = !!status.equipping; pysimApplyAvailability(); const key = [status.connected, !!status.card_present, !!status.equipping, !!status.auto_equip, status.card_session].join('|'); if (key === _pysimCardStateKey) return; diff --git a/frontend/sw.js b/frontend/sw.js index 80cf7b0..c282149 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v109'; +const CACHE = 'otaman-v110'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/indicator.test.js b/frontend/tests/indicator.test.js new file mode 100644 index 0000000..f06afcb --- /dev/null +++ b/frontend/tests/indicator.test.js @@ -0,0 +1,124 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); + +function extractFunc(src, name) { + const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const m = re.exec(src); + if (!m) throw new Error('function ' + name + ' not found'); + let i = m.index + m[0].length - 1; + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) break; + } + } + return src.slice(m.index, i + 1); +} + +let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\n'; +code += extractFunc(html, 'pysimAvailabilityState') + '\n'; +code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n'; +code += 'globalThis.t = s => s;\n'; +eval(code); + +function fakeEl() { + const classes = new Set(); + return { + classes, + attrs: {}, + classList: { + add: (...cs) => cs.forEach(c => classes.add(c)), + remove: (...cs) => cs.forEach(c => classes.delete(c)), + contains: c => classes.has(c), + }, + setAttribute(k, v) { this.attrs[k] = v; }, + }; +} + +function setup() { + const els = { + 'state-indicator': fakeEl(), + 'state-indicator-dot': fakeEl(), + 'state-indicator-img': fakeEl(), + }; + els['state-indicator-img'].src = ''; + globalThis.document = { getElementById: id => els[id] || null }; + _pysimServerAvailable = null; + _pysimCardEquipped = false; + _pysimEquipping = false; + return els; +} + +test('unprobed server shows a gray dot and a Connecting title', () => { + const els = setup(); + pysimUpdateStateIndicator(); + const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els; + assert.ok(dot.classes.has('text-gray-400')); + assert.ok(!dot.classes.has('hidden')); + assert.ok(img.classes.has('hidden')); + assert.strictEqual(wrap.attrs.title, 'Connecting...'); +}); + +test('unreachable server shows a red dot', () => { + const els = setup(); + _pysimServerAvailable = false; + pysimUpdateStateIndicator(); + const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els; + assert.ok(dot.classes.has('text-red-500')); + assert.ok(!dot.classes.has('text-gray-400')); + assert.ok(img.classes.has('hidden')); + assert.strictEqual(wrap.attrs.title, 'No server connection'); +}); + +test('server up without a card shows nosim.svg', () => { + const els = setup(); + _pysimServerAvailable = true; + pysimUpdateStateIndicator(); + const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els; + assert.ok(dot.classes.has('hidden')); + assert.ok(!img.classes.has('hidden')); + assert.strictEqual(img.src, 'nosim.svg'); + assert.strictEqual(wrap.attrs.title, 'Server connected, no card equipped'); +}); + +test('equipped card shows sim.svg', () => { + const els = setup(); + _pysimServerAvailable = true; + _pysimCardEquipped = true; + pysimUpdateStateIndicator(); + const { 'state-indicator': wrap, 'state-indicator-img': img } = els; + assert.strictEqual(img.src, 'sim.svg'); + assert.strictEqual(wrap.attrs.title, 'Card equipped'); +}); + +test('equipping shows the animated sim_anim.svg', () => { + const els = setup(); + _pysimServerAvailable = true; + _pysimEquipping = true; + pysimUpdateStateIndicator(); + const { 'state-indicator': wrap, 'state-indicator-img': img } = els; + assert.strictEqual(img.src, 'sim_anim.svg'); + assert.strictEqual(wrap.attrs.title, 'Card inserted — initializing...'); +}); + +test('dot color transitions do not accumulate', () => { + const els = setup(); + _pysimServerAvailable = false; + pysimUpdateStateIndicator(); + _pysimServerAvailable = null; + pysimUpdateStateIndicator(); + const dot = els['state-indicator-dot']; + assert.ok(dot.classes.has('text-gray-400')); + assert.ok(!dot.classes.has('text-red-500')); +}); + +test('indicator markup carries the dot and image elements', () => { + assert.match(html, /id="state-indicator-dot"/); + assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/); +});