The test tree is wired through CMake and registers fourteen CTest entries: one aggregate unit_tests binary, eleven standalone unit executables, and two entries over a single integration_tests binary — an offscreen QML startup check, and a native-OpenGL framebuffer suite that selects only the GL test functions. A green ctest run is 14 tests, not 11 or 12.
This file documents the automated tests and how to run them. The interactive GUI checklist lives in manual-qa.md as MT-001.
./build.sh
ctest --test-dir build/tests --output-on-failureCMAKE_PREFIX_PATH is cached. Passing --qt or changing CHADVIS_QT_PATH against an existing build directory used to have no effect: the cache still held the old prefix and the failure named files from the old Qt, not the one you just pointed at. build.sh now tells the two cases apart. A cached prefix that has lost its lib/cmake/Qt6/Qt6Config.cmake is reconfigured in place — every object is still valid. A different Qt installation (a prefix change) is not a moved prefix: brew's Qt and a qt.io SDK at the same version ship different feature sets, so the objects were built against other headers and the cache carries feature values the new Qt contradicts. build.sh reconfigures that directory in place (the changed include paths force the rebuild anyway) and tells you to run --rebuild if you want a clean tree. Build directories are never parked or backed up: --rebuild deletes the old one, because a parked tree is disk the next build needs and nobody restores it. Copy a binary you want to keep out by hand first. The lesson is worth keeping even though the trap is handled: the cache is the authority on which Qt a build directory uses, not the flag you typed.
Where Qt comes from now: Qt 6.12.0 from qt.io, installed under $HOME/Qt/<version>/macos (aqt), with ~/.zshrc exporting CMAKE_PREFIX_PATH to it. The Homebrew umbrella is gone from this machine, and with it the reason scripts/qt-stitch.sh existed (T0196): the umbrella used to be half-upgraded (qt at 6.11.1 while qtbase/qtdeclarative/qtmultimedia/qttools were 6.12.0), so its cmake files described a bin/ whose tools had moved, and a prefix of symlinks was the workaround. Neither the stitch nor the --qt guidance it needed is in the tree any more. build.sh still probes .qt-stitch last — a leftover directory must never shadow a real install — and resolves, in order: CMAKE_PREFIX_PATH from the environment, the newest $HOME/Qt/*/macos, MacPorts, Homebrew, a distro prefix.
ctest --test-dir build reports SUCCESS while discovering zero tests, and it will keep doing so until someone notices there is no build/CTestTestfile.cmake. The test tree is configured by tests/CMakeLists.txt, so the generated CTestTestfile.cmake lands in build/tests/, not build/. Confirm it before you trust a green run:
ls build/tests/CTestTestfile.cmake # exists
ls build/CTestTestfile.cmake # does not existCTest treats "no tests found" as a success exit code, so the wrong directory is indistinguishable from a real pass unless you read the test count. Use build/tests. A correct run prints 14 tests and ends with 100% tests passed, 0 tests failed out of 14.
build.sh performs an incremental build by default and preserves the existing CMake configuration. Use ./build.sh --rebuild (or --clean) for a clean rebuild; -d/--debug and -r/--release explicitly select a configuration. The script does not accept a run argument.
Who supplies each dependency, and why the build does not use Conan, is DEPENDENCIES.md. The build commands below assume that arrangement.
tests/unit/CMakeLists.txt builds a single unit_tests executable from 45 sources:
| Source | Coverage |
|---|---|
tests/unit/test_main.cpp |
Aggregate runner; declares and calls all 44 runTest* entry points. |
tests/unit/core/test_Logger.cpp |
Logger lifecycle coverage. |
tests/unit/core/test_ConfigParsers.cpp |
TOML parsing and serialization coverage. |
tests/unit/core/test_ConfigLoader.cpp |
Every serialized field surviving a round trip; the [recording.video]/[recording.audio] sub-tables written as sub-tables rather than collapsed onto one key; no credential reaching either the serialized document or the saved file, across three successive saves, while the legacy values stay readable for migration; overlay.enabled round-tripping in both states; unknown keys dropped; and corrupt, malformed or missing input diagnosed rather than thrown on. Also pins that the shipped config/default.toml declares every key the serializer emits. |
tests/unit/core/test_FileUtils.cpp |
Filename sanitization coverage. |
tests/unit/core/test_Signal.cpp |
The corrected cost model: an rvalue emit moves and never copies, an lvalue emit copies exactly once per subscriber, a const& payload is not copied at all; plus the emit-chain guards — disconnecting from inside a slot takes effect on the next emit, a nested emit does not take the outer emit's chain cleanup, and a throwing subscriber does not strand the chain. |
tests/unit/core/test_Version.cpp |
--version banner pinned to version.txt: the injected CHADVIS_VERSION, the exact banner text, no stale 1.0.0 literal, and the real binary's --version output. |
tests/unit/recorder/test_RecordingPipeline.cpp |
Frame submissions reach the encoder, recorder audio queue drained before and after the worker starts, sanitized container path from config. |
tests/unit/recorder/test_AudioFileDecoder.cpp |
Exact-value PCM survival from synthesis through avcodec/swresample; a mono source arriving in stereo at unity rather than at swr's default −3 dB rematrix; the drained-resample frame count that makes the final flush load-bearing; a truncated WAV reported as indistinguishable from a short one; bounded-queue drop accounting and a worker draining every chunk; a real AAC track in an MP4 decoded to completion; and soxr selected only when the library actually accepted it, with a stated reason when it did not. |
tests/unit/recorder/test_RenderQueue.cpp |
The job model and scheduler with no worker, GL, FFmpeg or real I/O — which is what lets the concurrency ceiling be proved: failure classification (a full disk permanent, errno overriding a worker's guess), every state/slug round-trip, validation naming the field it rejected, the versioned render receipt (exact round trip, corrupt receipts refused distinctly, a receipt refusing to certify a short render or a non-terminal outcome), frame-weighted batch progress, FIFO handing-out with a retry jumping the queue, and a claim of completion after too few frames settling as partial. |
tests/unit/audio/test_Playlist.cpp |
Selection notification, snapshot lifetime, re-entrant reads, the thread contract, shuffle/repeat traversal, skip-while-paused, end-of-track advance, and destruction flushing the session playlist. |
tests/unit/audio/test_PcmFormat.cpp |
Bit-exact float copy including NaN; int16 scaling exact for every possible input, not a sample of them; interleaved stereo ordering; and every rejection named rather than partially applied — unsupported format, sample ceiling boundary, destination too small, oversized by one, truncated source. |
tests/unit/audio/test_LoudnessAnalysis.cpp |
K-weighted loudness against exact references on synthesized sines (full-scale 1 kHz is not zero, a −20 dB sine measures −20 LUFS), digital silence gated out rather than averaged in, and gain application exact with 0 dB touching nothing. |
tests/unit/visualizer/test_PresetScanner.cpp |
Preset scanning, the hoisted author regex across filename shapes and repeated scans, and async rescan: pinned generations stay valid, a missing publish context falls back to sync, a failed scan keeps the published generation. |
tests/unit/suno/test_AuthModule.cpp |
JWT, auth headers, and file-backed credential-store coverage. |
tests/unit/suno/test_StoredCredentialClassification.cpp |
Credential-shape classification and confirmation that diagnostics leak no credential material. |
tests/unit/suno/test_CredentialRestoreWorker.cpp |
Restore worker never blocks on the credential backend from its constructor thread, concurrent restores coalesce, empty restore reopens the gate, and a discarded restore still notifies every waiter. |
tests/unit/suno/test_SunoLibraryManager.cpp |
Library refresh waits for an in-flight credential restore instead of treating it as signed out. |
tests/unit/suno/test_AuthCoordinator.cpp |
Insecure backend refuses from signed out, secure backend without capture backing refuses, one validated callback installed then local sign-out, distinguishable keychain read-status mapping. |
tests/unit/suno/test_LoopbackListener.cpp |
Ephemeral loopback port binding, valid callback acceptance with a fixed page, silent rejection of malformed requests, pipelined extra request ignored. |
tests/unit/suno/test_OAuthLoginService.cpp |
Transaction entropy/uniqueness, known S256 challenge vector, state-string QML contract stability, empty launch staying capture-gated without opening a browser, single-flight begin, mismatched-state rejection, deadline expiry. |
tests/unit/suno/test_ClipParser.cpp |
Captured clip and feed/v3 envelope parsing coverage. |
tests/unit/suno/test_DownloadQueue.cpp |
Failure classification, backoff, resume, FIFO concurrency, cancellation, and atomic file replacement using injected fake replies. |
tests/unit/suno/test_CredentialStorePolicy.cpp |
The platform-free half of CredentialStore: each platform selecting its own backend only when that backend is compiled, no backend compiled failing closed rather than writing plaintext, CHADVIS_NO_KEYCHAIN outranking a working backend (on Windows too), every Secret Service failure mapped to its own reason and reported in the order it is checked, a locked collection winning over an absent one, and no refusal message ever claiming success or naming a file. Plus UTF-8 secret encoding (lone and mismatched surrogates refused, a valid pair accepted, the secret never echoed) and the byte ceilings. |
tests/unit/suno/test_SunoDownloader.cpp |
The save/play seam: a save-only batch never touches the playlist, save-and-play still selects the downloaded clip, an existing file is reused without playing, same-title clips get distinct files, a live request is escalated rather than discarded, a failed download saves nothing and leaves playback alone, and both refusals — no captured mp3, and WAV — happen before anything is resolved and say why. |
tests/unit/suno/test_SunoEndpoints.cpp |
Endpoint-map invariants, including the single-/api composition rule and allowed URL shapes. |
tests/unit/suno/test_BoundedBody.cpp |
The bounded reader as the mandatory accessor for a pumped reply: readAll() reads empty, the accessor returns every byte and not a prefix, a body exactly at the cap is accepted while the overflowing byte is detected but never stored, a hostile origin is cut off one byte past the cap and aborts mid-stream, and each migrated call site names the limit instead of reporting invalid JSON — while still parsing valid pages through it. Also one reader per live reply and none after sign-out, and a repo-level check that no call site reads a body directly off a pumped reply. |
tests/unit/lyrics/test_LyricsPipeline.cpp |
Captured aligned-payload fields and metadata lyrics, invalid-payload rejection, sync line selection and clamped progress, empty load as terminal, downloader signal after an existing file jump, search and SRT/LRC export, lazy bridge updates. |
tests/unit/lyrics/test_LyricsExport.cpp |
ASS header and lyric golden output including \kf word timing, a timestamp with non-zero centiseconds surviving, braces escaped and newlines neutralised so a lyric or title cannot forge a header key, a degenerate remote payload still producing a valid file, and the SRT/LRC golden bytes surviving non-finite times. |
tests/unit/lyrics/test_LyricTiming.cpp |
The reflow model exact on a worked example, undo restoring a bitwise-identical document after a multi-step drag, plausibility refusing the named violations while accepting a good document, and an exact serialisation round trip on every field. |
tests/unit/util/test_PathSafety.cpp |
sanitizeFilename as a single component and traversal-proof: the byte budget never splits a UTF-8 sequence, composed and decomposed spellings produce one name, titles differing only by a combining mark do not collide, Win32 reserved names are refused in every spelling and truncation cannot manufacture one, traversal yields a bare filename, an embedded NUL cannot truncate silently. Plus parseHexColor's verdicts, a remote .m3u line never becoming a track, and a session file written by saveM3U round-tripping its titles and durations. |
test_SunoBridge.cpp |
tests/unit/qml_bridge/test_SunoBridge.cpp |
test_CliArgs.cpp |
tests/unit/core/test_CliArgs.cpp |
test_SunoAuthFailure.cpp |
tests/unit/suno/test_SunoAuthFailure.cpp |
test_SunoDatabase.cpp |
tests/unit/suno/test_SunoDatabase.cpp |
test_LyricsFetchQueue.cpp |
tests/unit/suno/test_LyricsFetchQueue.cpp |
test_DownloadEntitlement.cpp |
tests/unit/suno/test_DownloadEntitlement.cpp |
test_SunoWorkspace.cpp |
tests/unit/suno/test_SunoWorkspace.cpp |
test_PresetBridge.cpp |
tests/unit/visualizer/test_PresetBridge.cpp |
The aggregate runner invokes 44 suites, one per source in the tables below. tests/unit/test_main.cpp is the list and the only place that decides which run; this page deliberately does not repeat it, because an enumeration that lives in two files drifts in one of them (an earlier revision of this page named two suites that no longer exist).
Every one of the 44 suites above is invoked, and every unit source is declared. There are no compiled-but-unrun translation units in the aggregate target, and there are no static-initialization test hooks. Verified by set difference: the 45 aggregate sources (44 suites plus test_main.cpp) plus the 11 standalone sources are exactly the 56 .cpp files under tests/unit/. That exactness is new — visualizer/test_PresetManager.cpp was declared nowhere and never built until T0306 wired it, together with the visualizer audio tap suite added for T0322. That was not always true, and the failure mode is the one this repository keeps re-learning:
test_PresetScanner.cppwas inCMakeLists.txtfor a long time with norunTest*entry point in the runner, so its assertions never executed.runTestPresetScanneris now declared attests/unit/test_main.cpp:23and called inmain().test_SunoEndpoints.cppran from a static initializer andstd::abort()ed on failure, which masked the failure behind a process kill instead of the aggregate exit code.runTestSunoEndpointsis now declared attests/unit/test_main.cpp:27and called inmain()like every other suite.
Treat both as closed, and do not reintroduce either shape. A new aggregate source needs two edits: the add_executable source list and a runTest* declaration plus a status |= call in test_main.cpp.
Two directories under tests/ are empty and named by no CMakeLists: tests/unit/projectm/ and tests/manual/. Nothing is lost by ignoring them, but nothing runs from them either, so do not read either as a home for a test you are about to add without declaring the source.
Eleven standalone targets build as their own executables and register with CTest individually, because each carries its own main() or needs a different application object than the aggregate's QCoreApplication:
| Target | Source | Focus |
|---|
Both entries run the same integration_tests binary, built from tests/integration/CMakeLists.txt:1-4 with two sources: test_main.cpp and test_ProjectMFramebuffer.cpp. They differ only in environment and in which test functions are selected. When a test function is named on the command line, the runner runs only the GL suite (tests/integration/test_main.cpp:55-58) — replaying those names against the QML class would fail the run with "Function not found".
CTest registers it with QT_QPA_PLATFORM=offscreen (tests/integration/CMakeLists.txt:31-34), so it needs no display. tests/integration/test_main.cpp is a real test case, not an empty harness: mainQmlLoadsAndRenders() (tests/integration/test_main.cpp:18-43)
- Constructs a real
vc::AudioEngineand requiresinit()to succeed. - Registers the real QML bridges into a fresh
QQmlApplicationEngine. - Loads
qrc:/qt/qml/ChadVis/src/qml/main.qml— the same document the app loads. - Requires a non-empty root-object list, a
QQuickWindowroot,isVisible(), andisExposed()within a 3-second retry window. - Grabs the window and requires a non-null frame with non-zero width and height.
This entry also runs the GL suite, and on macOS every one of the 7 GL tests skips with a stated reason rather than passing: the offscreen QPA plugin cannot create an OpenGL context at all (This plugin does not support createPlatformOpenGLContext!), which the probe at tests/integration/test_ProjectMFramebuffer.cpp:53-64 detects. So what a green integration_tests proves is that the QML module loads, instantiates, and produces a frame. It is not evidence about the visualizer's OpenGL context, and it is not evidence about the recorder.
The last registered entry, added at tests/integration/CMakeLists.txt:36-48, runs the same binary under the platform's native QPA plugin — cocoa on macOS, whatever the session has elsewhere — with no QT_QPA_PLATFORM override and a 120-second timeout, selecting exactly the 7 GL test functions:
| Test | What it pins |
|---|---|
engineDrawsIntoDefaultFramebuffer |
After 30 rendered frames the default framebuffer holds a picture — a non-black byte count, not a !frame.isNull() check that solid black would pass. |
engineIgnoresAnFboBoundAsDrawTarget |
With an FBO bound as the draw target every frame, the FBO reads back 0 non-zero bytes and the default framebuffer reads back a picture, so an offscreen FBO would have stayed empty. |
rendererRecordingPathDeliversPixels |
The renderer's own capture path emits a full-size frame with non-zero bytes, not the FBO's own glClearColor. |
capturedFramesAreFlippedIntoTopDownRows |
The capture's row 0 is the raw glReadPixels bottom row — the frame is top-down, not upside down. |
encodedFileContainsTheVisualizerPicture |
45 frames through the real recorder produce an H.264 MP4 that decodes to a 256x256 stream whose brightest frame has lit pixels. Every test above stops at the readback, so this is the one that would catch an encoder fed an empty buffer. |
recordingAtADifferentResolutionIsScaled |
A recording resolution that differs from the window's is scaled on the readback, so the encoder is never fed a wrongly sized buffer. |
liveWindowCapturesFrames |
The real VisualizerWindow — own GL context, expose event, render timer, buffer swap — captures a full-size non-black frame. The other six drive the renderer directly, so nothing here would catch a capture that only works while the window is not presenting. |
A skip is not a pass. These 7 are the only automated proof that the recorder's framebuffer path works; a run that reports them as skipped has proven nothing about it. When you touch the recorder, the visualizer renderer, or projectM, run this entry:
ctest --test-dir build/tests -R integration_gl_tests --output-on-failureIf it reports skips, read the reason. "QOpenGLContext failed to create; this QPA plugin cannot provide a GL 3.3 core context" means you are on a headless session with no usable platform plugin; the tests need a real drawable, because QOffscreenSurface is unusable here — on macOS Qt hands it a 1x1 drawable, so any larger readback is out of bounds and returns zeros (tests/integration/test_ProjectMFramebuffer.cpp:66-70). Video-page behavior, window embedding, and persistence still need the real GUI session in manual-qa.md.
Fourteen entries: twelve in tests/unit/CMakeLists.txt, two in tests/integration/CMakeLists.txt.
| Suite | Kind | Environment |
|---|---|---|
unit_tests |
Aggregate, 44 invoked suites from 45 sources | console |
test_HttpPolicy |
Standalone unit (own main()) |
console |
test_CapturedHosts |
Standalone unit, host registry | console |
test_FeatureFlags |
Standalone unit | console |
test_OffscreenRenderSpike |
Standalone unit (QGuiApplication, real GL drawable) |
console, 180 s timeout |
test_RenderExecutor |
Standalone unit (QGuiApplication) |
QT_QPA_PLATFORM=offscreen, 180 s timeout |
test_SunoAudioUploadService |
Standalone unit | console |
test_SunoRequestEpoch |
Standalone unit | console |
test_SunoExploreService |
Standalone unit | console |
test_SunoNotificationService |
Standalone unit | console |
test_ClerkAuthClient |
Standalone unit | console |
integration_tests |
Offscreen QML startup and render; GL tests skip under offscreen |
QT_QPA_PLATFORM=offscreen |
integration_gl_tests |
7 native-OpenGL framebuffer tests, same binary | native plugin (no override), 120 s timeout |
test_ThemeQml |
Standalone unit; loads the ChadVis QML module and the Theme singleton |
QT_QPA_PLATFORM=offscreen |
Both are run by hand and both need to be run by hand, because ctest is not a place either of them belongs.
| Check | Command | Why it is not a CTest entry |
|---|---|---|
| QML lint | ./scripts/lint-qml.sh |
Runs the build's own project_lib_qmllint target, so it needs a configured build directory. Invoking qmllint directly cannot resolve this tree — main.qml uses relative imports. |
| Theme contrast | python3 scripts/check-theme-contrast.py |
Needs neither Qt nor a build: it re-implements Theme.qml's ramp and WCAG relative luminance in Python and reads the tokens straight out of src/qml/styles/ThemeDefinitions.qml. Exits non-zero when a gated pair fails, so it can gate CI; --suggest prints measured replacements instead of guesses. It answers "is this text legible", not "does this look right" — the latter is MT-001-G. |
./build.sh
ctest --test-dir build/tests -R unit_tests --output-on-failure
./build/tests/unit/unit_tests # aggregate, all 44 suites
./build/tests/unit/test_HttpPolicy # single standalone target
./build/tests/unit/test_RenderExecutor # needs QT_QPA_PLATFORM=offscreen
QT_QPA_PLATFORM=offscreen ./build/tests/integration/integration_tests
ctest --test-dir build/tests -R integration_gl_tests --output-on-failure # the GL proofsAGENTS.md says the lanes exist and that they run before merging recorder or session work. This section says how to actually run them.
./build.sh --tsan --tests && ./build.sh --tsan --safe # data races
./build.sh --asan --tests # memory errors; --safe misses the gl surfaces
./build.sh --ubsan --testsEach profile has its own build directory, so --fast and --asan builds coexist. Two traps worth knowing before you spend a day on one:
detect_leaks is not supported on macOS. Setting ASAN_OPTIONS=detect_leaks=1 on this box makes ASan abort at initialization with a single "detect_leaks is not supported on this platform" line and kills every test in the suite, which reads as N failures and is one. Omit the flag to run ASan here. LeakSanitizer does run on Linux, so .github/workflows/asan.yml still carries it and CI keeps that coverage.
--safe does not reach the surfaces that matter for ASan. SAFE_TESTS in build.sh lists the ten headless-safe entries; integration_tests and integration_gl_tests are deliberately excluded per AGENTS.md ("a green run is not evidence for GL, burn-in, or audio"). The ASan lane needs them regardless: ctest --test-dir build-asan/tests --output-on-failure -j2 runs all thirteen.
Three-outcome classification, per the workflow. exit 0 is clean. exit 66 (TSan, the configured exitcode) and exit 67 (ASan) mean findings worth reading. Anything else means the lane itself broke — a sanitizer runtime init failure, or SIGABRT — and must never be read as "clean", which is why the workflows carry the summary line saying which happened.
- There is no Qt Widgets menu bar and no separate full-window projectM stage in this interface. The projectM surface is the native window embedded in the Video page; see ARCHITECTURE.md and ../integration/PROJECTM.md.
- Create has no
B-Side Chattab.src/qml/views/CreateView.qmldeclares no tab strip. The B-Side surface is unavailable because its Modal/Orpheus routes remain[LEAD]and unverified, matching ../user/USAGE.md. Earlier revisions of this document claimed otherwise; that was wrong. Do not reintroduce the claim, and do not ship the tab, until a direct capture promotes those routes. - Generation is intentionally disabled in the Create UI until a supported CAPTCHA token flow exists, so no automated test may assert a successful generation round-trip.
- Any new test must be added to
tests/unit/CMakeLists.txtwith anadd_test()call. Adding a source file alone produces a binary that CTest never runs, which is the same class of false green as the wrong--test-dir. The aggregate target has the sharper version of the same trap: a source inadd_executablewith norunTest*declaration andstatus |=call in test_main.cpp compiles, links, and never asserts anything.