From bbb3b58db8493e9cc600b236d9cb5e5acca268ac Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:21:21 +0200 Subject: [PATCH] test(contracts): Phase 0.a contract snapshots for audio surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot the three public surfaces the audio-engine decomposition must not change (docs/audio-engine-tlc.md Part IV §4): addon export table, audio-bridge IPC channels, preload audio/audioEffects API keys. contract-check.test.js diffs regenerated surfaces against the committed snapshots. result-shapes.json (golden result key/type shapes) is deferred until the engine_units harness can run the addon against a null device. Co-Authored-By: Claude Fable 5 --- tests/contract-check.test.js | 34 ++++++++ tests/contracts/addon-exports.json | 103 +++++++++++++++++++++++ tests/contracts/extract.js | 86 +++++++++++++++++++ tests/contracts/ipc-channels.json | 107 +++++++++++++++++++++++ tests/contracts/preload-audio-api.json | 112 +++++++++++++++++++++++++ 5 files changed, 442 insertions(+) create mode 100644 tests/contract-check.test.js create mode 100644 tests/contracts/addon-exports.json create mode 100644 tests/contracts/extract.js create mode 100644 tests/contracts/ipc-channels.json create mode 100644 tests/contracts/preload-audio-api.json diff --git a/tests/contract-check.test.js b/tests/contract-check.test.js new file mode 100644 index 0000000..aed2f44 --- /dev/null +++ b/tests/contract-check.test.js @@ -0,0 +1,34 @@ +// Phase 0.a gate (docs/audio-engine-tlc.md §4): the public audio surface — +// addon exports, IPC channels, preload API keys — must not change during the +// decomposition phases. Removals/renames fail here; deliberate additions +// require regenerating the snapshots in the same commit: +// node tests/contracts/extract.js +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); + +const { extractAddonExports, extractIpcChannels, extractPreloadApi } = require('./contracts/extract.js'); + +function loadSnapshot(name) { + return JSON.parse(fs.readFileSync(path.join(__dirname, 'contracts', name), 'utf8')); +} + +test('addon export table matches snapshot', (t) => { + const current = extractAddonExports(); + if (!current) { + t.skip('slopsmith_audio.node not built'); + return; + } + assert.deepStrictEqual(current, loadSnapshot('addon-exports.json')); +}); + +test('audio-bridge IPC channels match snapshot', () => { + assert.deepStrictEqual(extractIpcChannels(), loadSnapshot('ipc-channels.json')); +}); + +test('preload audio/audioEffects API keys match snapshot', () => { + assert.deepStrictEqual(extractPreloadApi(), loadSnapshot('preload-audio-api.json')); +}); diff --git a/tests/contracts/addon-exports.json b/tests/contracts/addon-exports.json new file mode 100644 index 0000000..c1a6da0 --- /dev/null +++ b/tests/contracts/addon-exports.json @@ -0,0 +1,103 @@ +[ + "addSource", + "bindInputDevice", + "clearChain", + "clearStreamOutput", + "closePluginEditor", + "detectNotes", + "enableFileLogging", + "getBackingDuration", + "getBackingLevel", + "getBackingPosition", + "getBufferSizes", + "getChainState", + "getCurrentDevice", + "getDeviceMetrics", + "getDeviceTypes", + "getKnownPlugins", + "getLevels", + "getNoteVerdicts", + "getParameters", + "getPitchDetection", + "getRawAudioFrame", + "getRawPitchDetection", + "getRendererBusMetrics", + "getSampleRate", + "getSampleRates", + "getSourceLevels", + "getSourceNoteVerdicts", + "getSourcePitchDetection", + "getSourceRawAudioFrame", + "getSourceRawPitchDetection", + "getStreamOverflowCount", + "getStreamSinkLevel", + "getStreamUnderflowCount", + "init", + "isAudioRunning", + "isBackingPlaying", + "isMlNoteDetection", + "isMonitorMuted", + "isStreamOutputActive", + "listInputDevices", + "listSources", + "loadBackingTrack", + "loadIR", + "loadNAMModel", + "loadNoteModel", + "loadPluginList", + "loadPreset", + "loadVST", + "moveProcessor", + "openPluginEditor", + "probeDeviceOptions", + "pushRendererAudio", + "removeProcessor", + "removeSource", + "replaceIR", + "resetPeaks", + "savePluginList", + "savePreset", + "scanPlugins", + "scoreChord", + "scoreSourceChord", + "seekBacking", + "sendMidiToSlot", + "setBackingSpeed", + "setBranch", + "setBranchSrc", + "setBypass", + "setChart", + "setCrashedPlugins", + "setDevice", + "setDeviceType", + "setGain", + "setInputChannel", + "setInputDeviceType", + "setMonitorKill", + "setMonitorMute", + "setMonitorMuteSuppressed", + "setMultiBypass", + "setNoiseGate", + "setNoteDetectionEnabled", + "setOutputDeviceType", + "setPan", + "setParameter", + "setPostGain", + "setRendererBus", + "setSlotState", + "setSourceChart", + "setSourceInputChannel", + "setSourceMonitorMute", + "setSourceVerifierOffset", + "setStreamBus", + "setStreamBusGain", + "setStreamOutputDevice", + "setTonePolish", + "setVstCrashSentinelPath", + "shutdown", + "startAudio", + "startBacking", + "stopAudio", + "stopBacking", + "unbindInputDevice" +] diff --git a/tests/contracts/extract.js b/tests/contracts/extract.js new file mode 100644 index 0000000..b4ada84 --- /dev/null +++ b/tests/contracts/extract.js @@ -0,0 +1,86 @@ +// Contract-surface extraction for the audio engine TLC refactor (Phase 0.a, +// docs/audio-engine-tlc.md §4). Each extractor returns a sorted, stable JSON +// snapshot of one public surface. contract-check.test.js diffs these against +// the committed snapshots so a decomposition phase cannot silently change the +// public API. Regenerate deliberately with: node tests/contracts/extract.js +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '..', '..'); + +// Export table of slopsmith_audio.node. Loads the real binary; returns null +// when it hasn't been built (contract-check skips with a warning then). +function extractAddonExports() { + const addonPath = path.join(repoRoot, 'build', 'Release', 'slopsmith_audio.node'); + if (!fs.existsSync(addonPath)) return null; + const addon = require(addonPath); + return Object.keys(addon).sort(); +} + +// Every ipcMain.handle / ipcMain.on channel registered in audio-bridge.ts. +function extractIpcChannels() { + const src = fs.readFileSync(path.join(repoRoot, 'src', 'main', 'audio-bridge.ts'), 'utf8'); + const channels = new Set(); + const re = /ipcMain\.(?:handle|on)\(\s*'([^']+)'/g; + let m; + while ((m = re.exec(src)) !== null) channels.add(m[1]); + return [...channels].sort(); +} + +// Top-level method keys of the `audio:` and `audioEffects:` object literals in +// preload.ts — the surface the renderer (and every plugin) programs against. +// Brace-depth walk: record keys only at depth 1 inside the target literal. +function extractPreloadKeys(objectName, src) { + const start = src.indexOf(`${objectName}: {`); + if (start === -1) throw new Error(`preload.ts: '${objectName}: {' not found`); + let i = src.indexOf('{', start); + let depth = 0; + let parenDepth = 0; // multi-line parameter lists must not yield keys + const keys = []; + let lineStart = i; + for (; i < src.length; i++) { + const c = src[i]; + if (c === '{') depth++; + else if (c === '}') { + depth--; + if (depth === 0) break; + } else if (c === '(') parenDepth++; + else if (c === ')') parenDepth--; + else if (c === '\n') { + lineStart = i + 1; + } else if (depth === 1 && parenDepth === 0) { + // At a key position: line begins (after whitespace) with `name:` + if (i === lineStart) { + const line = src.slice(lineStart, src.indexOf('\n', lineStart)); + const km = line.match(/^\s*([A-Za-z_$][\w$]*)\s*:/); + if (km) keys.push(km[1]); + } + } + } + return keys.sort(); +} + +function extractPreloadApi() { + const src = fs.readFileSync(path.join(repoRoot, 'src', 'main', 'preload.ts'), 'utf8'); + return { + audio: extractPreloadKeys('audio', src), + audioEffects: extractPreloadKeys('audioEffects', src), + }; +} + +function writeSnapshot(name, data) { + fs.writeFileSync(path.join(__dirname, name), JSON.stringify(data, null, 2) + '\n'); +} + +module.exports = { extractAddonExports, extractIpcChannels, extractPreloadApi }; + +if (require.main === module) { + const addonExports = extractAddonExports(); + if (addonExports) writeSnapshot('addon-exports.json', addonExports); + else console.warn('addon not built — skipping addon-exports.json'); + writeSnapshot('ipc-channels.json', extractIpcChannels()); + writeSnapshot('preload-audio-api.json', extractPreloadApi()); + console.log('contract snapshots written to tests/contracts/'); +} diff --git a/tests/contracts/ipc-channels.json b/tests/contracts/ipc-channels.json new file mode 100644 index 0000000..9b9dee0 --- /dev/null +++ b/tests/contracts/ipc-channels.json @@ -0,0 +1,107 @@ +[ + "audio-effects:activateSegment", + "audio-effects:inspectRoute", + "audio-effects:loadChainPlan", + "audio-effects:releaseRoute", + "audio-effects:setRouteGain", + "audio-effects:setStageBypass", + "audio-effects:setStageParameter", + "audio:addSource", + "audio:bindInputDevice", + "audio:clearChain", + "audio:clearStreamOutput", + "audio:closePluginEditor", + "audio:detectNotes", + "audio:getBackingDuration", + "audio:getBackingLevel", + "audio:getBackingPosition", + "audio:getBufferSizes", + "audio:getChainState", + "audio:getCurrentDevice", + "audio:getDeviceMetrics", + "audio:getDeviceTypes", + "audio:getKnownPlugins", + "audio:getLevels", + "audio:getNoteVerdicts", + "audio:getParameters", + "audio:getPitchDetection", + "audio:getRawAudioFrame", + "audio:getRawPitch", + "audio:getRendererBusMetrics", + "audio:getSampleRate", + "audio:getSampleRates", + "audio:getSourceLevels", + "audio:getSourceNoteVerdicts", + "audio:getSourcePitchDetection", + "audio:getSourceRawAudioFrame", + "audio:getSourceRawPitch", + "audio:getStreamOverflowCount", + "audio:getStreamSinkLevel", + "audio:getStreamUnderflowCount", + "audio:isAudioRunning", + "audio:isAvailable", + "audio:isBackingPlaying", + "audio:isMlNoteDetection", + "audio:isMonitorMuted", + "audio:isStreamOutputActive", + "audio:listInputDevices", + "audio:listSources", + "audio:loadBackingTrack", + "audio:loadDeviceSettings", + "audio:loadIR", + "audio:loadNAMModel", + "audio:loadPluginList", + "audio:loadPreset", + "audio:loadVST", + "audio:moveProcessor", + "audio:openPluginEditor", + "audio:probeDeviceOptions", + "audio:pushRendererAudio", + "audio:removeProcessor", + "audio:removeSource", + "audio:replaceIR", + "audio:resetPeaks", + "audio:saveDeviceSettings", + "audio:savePluginList", + "audio:savePreset", + "audio:scanPlugins", + "audio:scoreChord", + "audio:scoreSourceChord", + "audio:seekBacking", + "audio:sendMidiToSlot", + "audio:setBackingSpeed", + "audio:setBranch", + "audio:setBranchSrc", + "audio:setBypass", + "audio:setChart", + "audio:setDevice", + "audio:setDeviceType", + "audio:setGain", + "audio:setInputChannel", + "audio:setMonitorKill", + "audio:setMonitorMute", + "audio:setMonitorMuteSuppressed", + "audio:setMultiBypass", + "audio:setNoiseGate", + "audio:setNoteDetectionEnabled", + "audio:setOutputDeviceType", + "audio:setPan", + "audio:setParameter", + "audio:setPostGain", + "audio:setRendererBus", + "audio:setSlotState", + "audio:setSourceChart", + "audio:setSourceInputChannel", + "audio:setSourceMonitorMute", + "audio:setSourceVerifierOffset", + "audio:setStreamBus", + "audio:setStreamBusGain", + "audio:setStreamOutputDevice", + "audio:setTonePolish", + "audio:startAudio", + "audio:startBacking", + "audio:stopAudio", + "audio:stopBacking", + "audio:unbindInputDevice", + "debug:isEnabled" +] diff --git a/tests/contracts/preload-audio-api.json b/tests/contracts/preload-audio-api.json new file mode 100644 index 0000000..50e124c --- /dev/null +++ b/tests/contracts/preload-audio-api.json @@ -0,0 +1,112 @@ +{ + "audio": [ + "addSource", + "bindInputDevice", + "clearChain", + "clearStreamOutput", + "closePluginEditor", + "debugEnabled", + "detectNotes", + "getBackingDuration", + "getBackingLevel", + "getBackingPosition", + "getBufferSizes", + "getChainState", + "getCurrentDevice", + "getDeviceMetrics", + "getDeviceTypes", + "getKnownPlugins", + "getLevels", + "getNoteVerdicts", + "getParameters", + "getPitchDetection", + "getRawAudioFrame", + "getRawPitch", + "getRendererBusMetrics", + "getSampleRate", + "getSampleRates", + "getSourceLevels", + "getSourceNoteVerdicts", + "getSourcePitchDetection", + "getSourceRawAudioFrame", + "getSourceRawPitch", + "getStreamOverflowCount", + "getStreamSinkLevel", + "getStreamUnderflowCount", + "isAudioRunning", + "isAvailable", + "isBackingPlaying", + "isMlNoteDetection", + "isMonitorMuted", + "isStreamOutputActive", + "listInputDevices", + "listSources", + "loadBackingTrack", + "loadDeviceSettings", + "loadIR", + "loadNAMModel", + "loadPluginList", + "loadPreset", + "loadVST", + "moveProcessor", + "openPluginEditor", + "probeDeviceOptions", + "pushRendererAudio", + "removeProcessor", + "removeSource", + "replaceIR", + "resetPeaks", + "saveDeviceSettings", + "savePluginList", + "savePreset", + "scanPlugins", + "scoreChord", + "scoreSourceChord", + "seekBacking", + "sendMidiToSlot", + "setBackingSpeed", + "setBranch", + "setBranchSrc", + "setBypass", + "setChart", + "setDevice", + "setDeviceType", + "setGain", + "setInputChannel", + "setMonitorKill", + "setMonitorMute", + "setMonitorMuteSuppressed", + "setMultiBypass", + "setNoiseGate", + "setNoteDetectionEnabled", + "setOutputDeviceType", + "setPageMuted", + "setPan", + "setParameter", + "setPostGain", + "setRendererBus", + "setSlotState", + "setSourceChart", + "setSourceInputChannel", + "setSourceMonitorMute", + "setSourceVerifierOffset", + "setStreamBus", + "setStreamBusGain", + "setStreamOutputDevice", + "setTonePolish", + "startAudio", + "startBacking", + "stopAudio", + "stopBacking", + "unbindInputDevice" + ], + "audioEffects": [ + "activateSegment", + "inspectRoute", + "loadChainPlan", + "releaseRoute", + "setRouteGain", + "setStageBypass", + "setStageParameter" + ] +}