Files
ISR/tests/test_web.py
T
adminandClaude Opus 5.5 2a19515c27 feat: split the web UI into modules and rework it for screen readers
webui.html becomes webui/ (index.html, style.css, ES modules in js/, no
build step); web.py serves it under / and /ui/<path> with the same
containment check as recordings.

How it feels with a screen reader:
- Days read "Today, Saturday 17 October"; recordings are named by their
  wall-clock span ("22:00 to 23:00"), which is the row header and what
  every button says. The filename moves to the last column.
- Focus is never dropped: busy buttons stay focused (aria-disabled),
  finished analyses focus their first section, delete moves to the next
  row, closing the clip bar (x or Escape) returns to where you were.
- Reloads rebuild only the days whose content changed; DOM ids are stable
  per filename, so a rollover no longer resets the reading position.
- Only user-initiated actions are announced; highlights completion is
  spoken (the progress area was stuck aria-busy before).
- Media Session previous/next track step sections like J/K (AirPods,
  lock screen, media keys).
- Row layout: Time | Length | Loudness | Player | Options | File. The
  player opens inside its cell; Download/Cut/Delete are always there,
  and Cut toggles its start/end fields in place.
- Settings tucked behind a <details>; pre-roll and auto-advance are
  remembered per browser; no CSS uppercase on headers or badges.

Fixes the REC badge showing on every row: its inline-flex display beat
the UA [hidden] rule. One global [hidden]{display:none !important} now
covers every case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:05:15 +02:00

118 lines
4.0 KiB
Python

"""Tests for the HTTP layer and file listing in web.py."""
import io
import web
def test_listing_is_ordered_by_the_filename_clock_not_mtime(tmp_path, write_wav):
# mtime is the last write (≈ the end of the recording) and drifts from the
# name on any copy or round-trip. Here the older recording is touched last.
import os
early, late = tmp_path / '20260523_080000.wav', tmp_path / '20260523_090000.wav'
write_wav(early)
write_wav(late)
os.utime(late, (1000, 1000)) # newest recording, oldest mtime
names = [f['name'] for f in web.list_files(str(tmp_path))]
assert names == ['20260523_090000.wav', '20260523_080000.wav']
# ---------------------------------------------------------------------------
# /api/clip error handling
# ---------------------------------------------------------------------------
class _OfflineHandler(web._Handler):
"""A _Handler with no socket, for driving handler methods directly."""
def __init__(self, recordings_dir):
self.recordings_dir = str(recordings_dir)
self.analyses_dir = str(recordings_dir)
self.command = 'GET' # _send skips the body on HEAD
self.wfile = io.BytesIO()
self.close_connection = False
self.codes = []
def send_response(self, code):
self.codes.append(code)
def send_header(self, key, value):
pass
def end_headers(self):
pass
def test_clip_failure_after_headers_closes_connection_instead_of_writing_json(tmp_path, write_wav):
# Once the audio headers are out, an error body would be a second response
# on a keep-alive connection that already promised audio/wav plus an exact
# Content-Length — desyncing every later request on that connection.
write_wav(tmp_path / 'rec.wav')
h = _OfflineHandler(tmp_path)
def boom(path, start, end):
h._send_clip_headers(b'HDR', 10, 1, 2)
raise RuntimeError('decode exploded')
h._clip_wav = boom
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
assert h.close_connection is True
assert h.codes == [200] # no second status line
assert b'error' not in h.wfile.getvalue() # no JSON body
def test_clip_failure_before_headers_still_reports_json(tmp_path, write_wav):
write_wav(tmp_path / 'rec.wav')
h = _OfflineHandler(tmp_path)
def boom(path, start, end):
raise RuntimeError('could not open')
h._clip_wav = boom
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
assert h.close_connection is False
assert h.codes == [500]
assert b'could not open' in h.wfile.getvalue()
# ---------------------------------------------------------------------------
# Browser UI static files (webui/)
# ---------------------------------------------------------------------------
def test_ui_serves_index_and_modules_with_their_types(tmp_path):
h = _OfflineHandler(tmp_path)
types = []
h.send_header = lambda key, value: types.append(value) if key == 'Content-Type' else None
h._ui('index.html')
h._ui('js/main.js')
h._ui('style.css')
assert h.codes == [200, 200, 200]
assert types == ['text/html; charset=utf-8', 'text/javascript; charset=utf-8',
'text/css; charset=utf-8']
def test_ui_refuses_paths_outside_webui_and_unlisted_types(tmp_path):
h = _OfflineHandler(tmp_path)
h._ui('../web.py') # escapes webui/
h._ui('..\web.py')
h._ui('js') # a directory
h._ui('missing.js')
assert h.codes == [404, 404, 404, 404]
assert b'import' not in h.wfile.getvalue()
def test_every_ui_module_import_resolves():
# No JS toolchain on the dev box: at least catch a renamed module or a
# misspelt relative import before the browser does.
import re
js = web.WEBUI_DIR / 'js'
for mod in js.glob('*.js'):
for target in re.findall(r"from '\./([\w.]+)'", mod.read_text(encoding='utf-8')):
assert (js / target).is_file(), f'{mod.name} imports missing {target}'