import io import json import re import shutil import threading import time import pytest from fastapi.testclient import TestClient from songtrace.config import Config from songtrace.pipeline import STAGE_ORDER, PipelineError, PipelineResult, song_slug from songtrace.web import STAGE_LINE, create_app def make_client(tmp_path, runner): cfg = Config(whisper_url="u", work_dir=tmp_path / "work", output_dir=tmp_path / "out") return TestClient(create_app(cfg, runner=runner)), cfg def ok_runner(inp, cfg, log=print, force_from=None, stages=None): log("[acquire] running") return PipelineResult(output_dir=cfg.output_dir / "X", warnings=["check 1:32"]) def wait_status(client, job_id, status, timeout=5): for _ in range(int(timeout / 0.05)): job = client.get(f"/api/jobs/{job_id}").json() if job["status"] == status: return job time.sleep(0.05) raise AssertionError(f"job never reached {status}: {job}") def test_submit_and_complete(tmp_path): client, _ = make_client(tmp_path, ok_runner) resp = client.post("/api/jobs", data={ "title": "T", "artist": "A", "lyrics": "la la", "youtube_url": "http://y"}) assert resp.status_code == 201 job = wait_status(client, resp.json()["id"], "done") assert job["warnings"] == ["check 1:32"] assert any("[acquire] running" in line for line in job["log"]) def test_failure_reported(tmp_path): def bad_runner(*a, **kw): raise PipelineError("boom") client, _ = make_client(tmp_path, bad_runner) resp = client.post("/api/jobs", data={ "title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}) job = wait_status(client, resp.json()["id"], "failed") assert job["error"] == "boom" def test_submit_normalises_a_blank_genre_to_none(tmp_path): seen = [] client, _ = make_client(tmp_path, capture_runner_factory(seen)) resp = client.post("/api/jobs", data={ "title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y", "genre": ""}) wait_status(client, resp.json()["id"], "done") assert seen[0]["inp"].genre is None def test_requires_one_source(tmp_path): client, _ = make_client(tmp_path, ok_runner) resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"}) assert resp.status_code == 400 def test_rejects_both_sources(tmp_path): client, _ = make_client(tmp_path, ok_runner) resp = client.post("/api/jobs", data={ "title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}, files={"video": ("v.mp4", b"data", "video/mp4")}) assert resp.status_code == 400 def test_job_list_and_404(tmp_path): client, _ = make_client(tmp_path, ok_runner) assert client.get("/api/jobs").json() == [] assert client.get("/api/jobs/nope").status_code == 404 def test_index_served(tmp_path): client, _ = make_client(tmp_path, ok_runner) resp = client.get("/") assert resp.status_code == 200 and " 0 assert queued["started_at"] is None and queued["finished_at"] is None finally: gate.set() wait_status(client, first.json()["id"], "done") def test_failed_job_still_records_finish(tmp_path): def bad_runner(*a, **kw): raise PipelineError("boom") client, _ = make_client(tmp_path, bad_runner) resp = client.post("/api/jobs", data={ "title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}) job = wait_status(client, resp.json()["id"], "failed") assert job["finished_at"] is not None and job["finished_at"] >= job["started_at"] def test_job_list_includes_timestamps(tmp_path): client, _ = make_client(tmp_path, ok_runner) resp = client.post("/api/jobs", data={ "title": "T", "artist": "A", "lyrics": "la", "youtube_url": "http://y"}) wait_status(client, resp.json()["id"], "done") summary = client.get("/api/jobs").json()[0] assert {"created_at", "started_at", "finished_at"} <= set(summary) def test_stages_endpoint_matches_stage_order(tmp_path): client, _ = make_client(tmp_path, ok_runner) assert client.get("/api/stages").json()["stages"] == STAGE_ORDER def test_cached_stage_line_advances_stage(tmp_path): def cached_runner(inp, cfg, log=print, force_from=None, stages=None): log("[acquire] cached, skipping") log("[separate] cached, skipping") log("[transcribe] running") return PipelineResult(output_dir=cfg.output_dir / "X", warnings=[]) client, _ = make_client(tmp_path, cached_runner) resp = client.post("/api/jobs", data={ "title": "T", "artist": "A", "lyrics": "la", "youtube_url": "http://y"}) job = wait_status(client, resp.json()["id"], "done") assert job["stage"] == "transcribe" def test_cached_only_run_reports_last_cached_stage(tmp_path): def all_cached(inp, cfg, log=print, force_from=None, stages=None): for name in ("acquire", "separate", "transcribe"): log(f"[{name}] cached, skipping") return PipelineResult(output_dir=cfg.output_dir / "X", warnings=[]) client, _ = make_client(tmp_path, all_cached) resp = client.post("/api/jobs", data={ "title": "T", "artist": "A", "lyrics": "la", "youtube_url": "http://y"}) job = wait_status(client, resp.json()["id"], "done") assert job["stage"] == "transcribe" def test_unrelated_log_lines_do_not_change_stage(tmp_path): def noisy(inp, cfg, log=print, force_from=None, stages=None): log("[align] running") log("some incidental output") log("[not a stage line]") return PipelineResult(output_dir=cfg.output_dir / "X", warnings=[]) client, _ = make_client(tmp_path, noisy) resp = client.post("/api/jobs", data={ "title": "T", "artist": "A", "lyrics": "la", "youtube_url": "http://y"}) job = wait_status(client, resp.json()["id"], "done") assert job["stage"] == "align" def test_fonts_are_served(tmp_path): client, _ = make_client(tmp_path, ok_runner) resp = client.get("/static/fonts/chakra-petch-600.woff2") assert resp.status_code == 200 assert resp.content[:4] == b"wOF2" def test_font_licence_is_bundled(): from songtrace.web import STATIC licence = (STATIC / "fonts" / "OFL.txt").read_text(encoding="utf-8", errors="replace") assert "SIL OPEN FONT LICENSE" in licence.upper() def _index_html(): from songtrace.web import STATIC return (STATIC / "index.html").read_text(encoding="utf-8") def test_index_has_required_hooks(): html = _index_html() for hook in ("song-form", "jobs", "detail", "review", "now-bar", "sidebar"): assert f'id="{hook}"' in html, hook for name in ("title", "artist", "lyrics", "youtube_url", "video", "genre", "year", "language", "bpm", "cover"): assert f'name="{name}"' in html, name def test_video_source_fields_are_outside_advanced_details(): html = _index_html() form_start = html.index('id="song-form"') details_start = html.index("
", form_start) before = html[form_start:details_start] assert 'name="youtube_url"' in before assert 'name="video"' in before after = html[details_start:] for name in ("genre", "year", "language", "bpm", "cover"): assert f'name="{name}"' in after def test_song_form_lyrics_uses_a_two_column_grid(): css = _page_css(_index_html()) assert re.search(r"\.song-form-grid\s*\{[^}]*grid-template-columns:\s*1fr 1fr", css, re.S) def test_song_form_grid_stacks_on_narrow_screens(): css = _page_css(_index_html()) assert re.search( r"@media \(max-width: 720px\)\s*\{[^}]*\.song-form-grid\s*\{[^}]*" r"grid-template-columns:\s*1fr\s*;", css, re.S) def test_index_makes_no_external_requests(): import re as _re html = _index_html() for url in _re.findall(r'(?:src|href)\s*=\s*["\']([^"\']+)', html): assert not url.startswith(("http://", "https://", "//")), url assert "@import" not in html def test_index_renders_api_data_without_innerhtml(): """API-derived values must go through textContent, never innerHTML.""" import re as _re html = _index_html() for m in _re.finditer(r"\.(innerHTML|outerHTML|insertAdjacentHTML)\s*(=|\()\s*(.*)", html): assert "${" not in m.group(3), f"interpolation into {m.group(1)}: {m.group(3)[:80]}" # the page reaches the same end by never naming a markup-parsing sink at all, # which also catches `el.innerHTML = job.title` (no interpolation to spot) for sink in ("innerHTML", "outerHTML", "insertAdjacentHTML"): assert sink not in html, f"{sink} must not appear in the page" def test_index_respects_reduced_motion(): assert "prefers-reduced-motion" in _index_html() def test_index_has_lyrics_review_hook(): html = _index_html() assert 'id="lyrics-review"' in html assert 'id="warnings"' not in html def test_index_review_uses_svg_not_canvas(): html = _index_html() assert "createElementNS" in html assert ""):html.index("")] def _focus_flaws(html): """Ways the 3s poll could detach — and so blur — a focused node. Both the queue cards and #copy-path are focusable and both are rewritten on every poll, so each one has to be patched in place rather than rebuilt. """ flaws = [] queue = _fn_body(html, "renderJobs") if 'querySelectorAll(".job")' not in queue or "dataset.id" not in queue: flaws.append("queue cards are not matched to the payload by id") if re.search(r"replaceChildren\(\s*\.\.\.", queue): flaws.append("the queue is re-rendered wholesale, detaching every card") for line in re.findall(r".*insertBefore.*", queue): if not re.search(r"if \((\w+)\.children\[(\w+)\] !== (\w+)\) " r"\1\.insertBefore\(\s*\3\s*,\s*\1\.children\[\2\]", line): flaws.append(f"a card is moved without checking it needs to be: {line.strip()}") out = _fn_body(html, "renderOutputPath") guard = re.search(r"if \((\w+) === (\w+)\) return;", out[:out.index("replaceChildren")]) if guard is None: flaws.append("#copy-path is rebuilt without first checking whether it changed") elif not re.search(rf"(?:{guard[1]}|{guard[2]}) = outputPathDiv\.querySelector", out[:guard.start()]): flaws.append("the #copy-path guard does not compare against what is rendered") return flaws def test_polling_cannot_blur_a_focused_control(): assert _focus_flaws(_index_html()) == [] def _now_bar_flaws(html): body = _fn_body(html, "renderNowBar") picked = re.search(r'const (\w+) = jobs\.find\(\w+ => \w+\.status === "running"\)', body) if picked is None: return ["the bar does not pick out the running job"] job, flaws = picked[1], [] if not re.search(rf'classList\.toggle\("idle", !{job}\)', body): flaws.append("the idle class does not track whether a job is running") if f"renderStageTrack({job})" not in body: flaws.append("the stage track is not driven by the running job") if f"{job} ?" not in body: flaws.append("the now-bar title is not driven by the running job") return flaws def test_now_bar_state_follows_the_running_job(): assert _now_bar_flaws(_index_html()) == [] def _stale_detail_flaws(html): """A slow response for a since-deselected job must not overwrite the UI.""" body = _fn_body(html, "refreshDetail") m = re.search(r"const (\w+) = selectedId;", body) if m is None: return ["refreshDetail does not snapshot selectedId before fetching"] if f"if ({m[1]} !== selectedId) return;" not in body: return ["refreshDetail does not bail when selectedId changed during the fetch"] return [] def test_stale_detail_response_cannot_override_a_newer_selection(): assert _stale_detail_flaws(_index_html()) == [] def _pulse_flaws(html): css, flaws = _page_css(html), [] if "@keyframes stage-pulse" not in css: flaws.append("the stage-pulse keyframes are missing") applied = re.search(r"([^{}]*)\{[^{}]*animation: stage-pulse[^{}]*\}", css) if applied is None: flaws.append("stage-pulse is defined but never applied") else: selector = applied[1].strip().splitlines()[-1].strip() if ".stage-active" not in selector or ".stage-dot" not in selector: flaws.append(f"the pulse is on {selector!r}, not the running stage's dot") if '"stage-active"' not in _fn_body(html, "renderStageTrack"): flaws.append("nothing ever gets the stage-active class") reduced = re.search(r"@media \(prefers-reduced-motion: reduce\) \{(.*?)\n\}", css, re.S) if reduced is None or not re.search(r"\*[^{}]*\{[^{}]*animation: none !important", reduced[1]): flaws.append("reduced motion does not disable animation everywhere") return flaws def test_running_stage_pulses_unless_motion_is_reduced(): assert _pulse_flaws(_index_html()) == [] def _submit_body(html): start = html.index('form.addEventListener("submit"') return html[start:html.index("\n});\n", start)] def _listener_body(html, prefix): start = html.index(prefix) return html[start:html.index("\n});\n", start)] def _rebuild_flaws(html): """Ways rebuild mode could quietly stop working.""" flaws, submit = [], _submit_body(html) if "rebuildOf ?" not in submit or "/rebuild" not in submit: flaws.append("the form always posts to /api/jobs, so edits create a new song") if "form.reset()" in submit or "exitRebuild()" in submit: flaws.append("a successful submission empties the form, so correcting " "or varying the song just sent means retyping it") actions = _fn_body(html, "renderActions") if not re.search(r"if \(.*dataset\.jobId === job\.id.*\) return;", actions): flaws.append("the action row is rebuilt on every poll, blurring a focused button") if '"failed"' not in actions or '"done"' not in actions: flaws.append("the action row does not key off the job status") load = _fn_body(html, "loadInputs") if "/input" not in load or "form.elements[" not in load: flaws.append("rebuild mode does not prefill the form from the job's input") select = _fn_body(html, "selectJob") if "loadInputs(" not in select: flaws.append("selecting a queue entry does not bring its inputs back up") if "formDirty" not in select or "confirm(" not in select: flaws.append("selecting a queue entry discards unsaved edits without asking") if "if (rebuildOf === id) exitRebuild();" not in _fn_body(html, "deleteJob"): flaws.append("deleting the loaded job leaves the form pointed at it") # selecting a queue entry loads it, so the sidebar button is now the only # way back to a blank form create = _listener_body(html, 'createSong.addEventListener("click"') if "exitRebuild()" not in create: flaws.append("the queue's CREATE SONG button does not clear the form") if "formDirty" not in create or "confirm(" not in create: flaws.append("the queue's CREATE SONG button discards edits without asking") if "clearDetail(" not in create: flaws.append("CREATE SONG empties the form but leaves the previous " "song's review and player sitting under it") if "composing" not in create or "composing" not in _fn_body(html, "refreshJobs"): flaws.append("the blank page CREATE SONG made is selected back over by " "the next poll of the queue") mode = _fn_body(html, "setFormMode") if 'querySelector("details").open' not in mode: flaws.append("prefilled genre/year/language/bpm stay hidden behind the " "closed Advanced summary, so they rebuild unseen") if "submitButton.disabled" not in _fn_body(html, "setSubmitLock"): flaws.append("the form lets you submit a rebuild the API will answer 409") return flaws def _rebuild_full_and_delete_flaws(html): flaws = [] actions = _fn_body(html, "renderActions") for action in ('"rebuild-full"', '"delete"'): if action not in actions: flaws.append(f"{action} is not offered from renderActions") rf = _fn_body(html, "rebuildFullJob") if "/rebuild-full" not in rf or 'method: "POST"' not in rf: flaws.append("rebuildFullJob does not POST to /rebuild-full") d = _fn_body(html, "deleteJob") confirm_idx = d.find("confirm(") fetch_idx = d.find("fetch(") if confirm_idx == -1: flaws.append("deleteJob does not confirm before deleting") elif fetch_idx != -1 and confirm_idx > fetch_idx: flaws.append("deleteJob fetches before confirming") if 'method: "DELETE"' not in d: flaws.append("deleteJob does not send a DELETE request") return flaws def test_rebuild_full_and_delete_controls_are_wired_up(): assert _rebuild_full_and_delete_flaws(_index_html()) == [] def _empty_state_flaws(html): refresh = _fn_body(html, "refreshJobs") if "clearDetail(" not in refresh: return ["refreshJobs never resets the panel when no job is selected"] return [] def test_review_panel_resets_when_no_job_is_selected(): assert _empty_state_flaws(_index_html()) == [] def _playback_flaws(html): """Ways the review could stop tracking the media element.""" flaws = [] source = _fn_body(html, "setPlayerSource") if "media/audio" not in source: flaws.append("the player is not pointed at the job's packaged audio, " "which is the file the game plays and the notes track") if "media/video" not in source: flaws.append("the packaged video is never shown alongside the audio") # the mp3 and the video carry the same recording; playing both is an echo if not re.search(r"]*\bmuted\b", html): flaws.append("the background video is not muted, so its own audio " "plays on top of the mp3") pump = _fn_body(html, "pumpPlayback") if "slaveVideo(" not in pump: flaws.append("the picture is never pulled back onto the audio clock, " "so it drifts out of sync over a long song") if "requestAnimationFrame" not in _fn_body(html, "pumpPlayback"): flaws.append("sync is not driven by the frame loop, so the playhead " "steps at timeupdate's ~4Hz instead of following the audio") if "media.paused" not in _fn_body(html, "focusActive"): flaws.append("the view is scrolled even while paused, fighting a reader " "who scrolled away to look at something else") sync = _fn_body(html, "syncPlayback") if "clearEntry(" not in sync: flaws.append("the line the playhead left keeps its highlight") clear = _fn_body(html, "clearEntry") for name in ("review-word-sung", "review-word-active", "review-line-active"): if name not in clear: flaws.append(f"{name} is never taken off a line the playhead left") # every branch that gives up on rendering a review has to unload the media # too, or the previous song stays loaded and playable under the new one render = _fn_body(html, "renderLyricsReview") if render.count("resetPlayer()") < render.count("emptyNote("): flaws.append("a review the page cannot render leaves the previous " "song's media loaded in the player") return flaws def test_review_playback_tracks_the_media_element(): assert _playback_flaws(_index_html()) == [] # each mutation is a plausible regression; the checker above must reject all of them MUTATIONS = [ (_focus_flaws, "queue: card moved unconditionally", "if (jobsDiv.children[i] !== card) jobsDiv.insertBefore", "jobsDiv.insertBefore"), (_focus_flaws, "queue: rebuilt wholesale", "jobsDiv.insertBefore(card, jobsDiv.children[i] ?? null)", "jobsDiv.replaceChildren(...jobs.map(jobCard))"), (_focus_flaws, "copy-path: unchanged panel rebuilt anyway", "if (path === shown) return;", ""), (_now_bar_flaws, "now bar: idle keyed off the queue instead of the running job", 'classList.toggle("idle", !running)', 'classList.toggle("idle", !jobs.length)'), (_now_bar_flaws, "now bar: shows the newest job instead of the running one", 'const running = jobs.find(j => j.status === "running")', "const running = jobs[0]"), (_stale_detail_flaws, "refreshDetail: stale-response guard removed", "if (id !== selectedId) return;", ""), (_pulse_flaws, "pulse: attached to the wrong stage", ".stage-active .stage-dot { color: var(--aqua); animation: stage-pulse", ".stage-pending .stage-dot { color: var(--aqua); animation: stage-pulse"), (_pulse_flaws, "pulse: reduced motion no longer wins", "animation: none !important", "animation: none"), (_rebuild_flaws, "rebuild: form posts to /api/jobs even in rebuild mode", 'const url = rebuildOf ? `/api/jobs/${rebuildOf}/rebuild` : "/api/jobs";', 'const url = "/api/jobs";'), (_rebuild_flaws, "rebuild: action row rebuilt on every poll", "if (reviewActions.dataset.jobId === job.id && shown.join() === wanted.join()) return;", ""), (_rebuild_full_and_delete_flaws, "delete: confirmation dialog removed", 'if (!confirm("Delete this song? This removes all its files and cannot be undone.")) return;', ""), (_rebuild_full_and_delete_flaws, "rebuild-full: no longer offered on a done job", '["rebuild-full", "delete"]', '["delete"]'), (_rebuild_flaws, "prefill: selecting a queue entry no longer loads its inputs", " if (reload) await loadInputs(id, JOBS.find(j => j.id === id)?.status);\n", ""), (_rebuild_flaws, "prefill: unsaved edits discarded without asking", ' if (reload && formDirty && !confirm("Discard your unsaved edits to this form?")) return;\n', ""), (_rebuild_flaws, "prefill: deleting the loaded job leaves the form pointed at it", " if (rebuildOf === id) exitRebuild();\n", ""), (_rebuild_flaws, "prefill: advanced fields stay hidden behind a closed summary", 'form.querySelector("details").open =', "const unusedAdvancedOpen ="), (_rebuild_flaws, "prefill: an in-progress job can be submitted for rebuild anyway", "submitButton.disabled = locked;", ""), (_rebuild_flaws, "submit: a successful submission empties the form again", " rebuildOf = null;\n formDirty = false;\n setFormMode(null, null);\n refreshJobs();", " exitRebuild();\n refreshJobs();"), (_rebuild_flaws, "create: the queue button no longer clears the form", " exitRebuild();\n clearDetail(\"nothing selected\");\n", ""), (_rebuild_flaws, "create: the review is left behind under a blank form", ' clearDetail("nothing selected");\n', ""), (_rebuild_flaws, "create: the next poll selects a song back into the blank page", " composing = true;\n", ""), (_rebuild_flaws, "create: the queue button discards edits without asking", ' if (formDirty && !confirm("Discard your unsaved edits to this form?")) return;\n composing = true;', " composing = true;"), (_playback_flaws, "playback: sync falls back to the timeupdate event's ~4Hz", "syncFrame = media.paused ? null : requestAnimationFrame(pumpPlayback);", "syncFrame = null;"), (_playback_flaws, "playback: the view is scrolled even while paused", "if (!activeEntry || media.paused) return;", "if (!activeEntry) return;"), (_playback_flaws, "playback: the line the playhead left keeps its highlight", " if (activeEntry) clearEntry(activeEntry);\n", ""), (_playback_flaws, "playback: a word stays lit after the playhead moves on", 'w.el.classList.remove("review-word-sung", "review-word-active");', 'w.el.classList.remove("review-word-sung");'), (_playback_flaws, "playback: an unrenderable review leaves the old song loaded", " resetPlayer();\n", ""), (_playback_flaws, "playback: the player is not pointed at the packaged audio", 'const src = available?.audio ? `/api/jobs/${job.id}/media/audio` : "";', 'const src = "";'), (_playback_flaws, "playback: the background video plays its own audio too", '