soundpacks
This commit is contained in:
@@ -95,20 +95,39 @@ class FakeAudio {
|
||||
engine = new FakeAudioEngine()
|
||||
createCalls = 0
|
||||
bytesCalls = 0
|
||||
bytesPaths: string[] = []
|
||||
rejectBytes = false
|
||||
rejectPaths = new Set<string>()
|
||||
|
||||
create() {
|
||||
this.createCalls += 1
|
||||
return this.engine
|
||||
}
|
||||
|
||||
async bytes() {
|
||||
async bytes(path: string) {
|
||||
this.bytesCalls += 1
|
||||
if (this.rejectBytes) throw new Error("decode failed")
|
||||
this.bytesPaths.push(path)
|
||||
if (this.rejectBytes || this.rejectPaths.has(path)) throw new Error("decode failed")
|
||||
return new Uint8Array([1, 2, 3])
|
||||
}
|
||||
}
|
||||
|
||||
class FakeKV {
|
||||
store: Record<string, unknown> = {}
|
||||
|
||||
get ready() {
|
||||
return true
|
||||
}
|
||||
|
||||
get<Value = unknown>(key: string, fallback?: Value) {
|
||||
return (this.store[key] ?? fallback) as Value
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
this.store[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function config(attention: Partial<AttentionConfig["attention"]> = {}): AttentionConfig {
|
||||
return {
|
||||
attention: {
|
||||
@@ -116,6 +135,8 @@ function config(attention: Partial<AttentionConfig["attention"]> = {}): Attentio
|
||||
notifications: true,
|
||||
sound: true,
|
||||
volume: 0.4,
|
||||
sound_pack: "opencode.default",
|
||||
sounds: {},
|
||||
...attention,
|
||||
},
|
||||
}
|
||||
@@ -335,6 +356,84 @@ describe("createTuiAttention", () => {
|
||||
expect(startedAudio.engine.playCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("plays named sounds from the active sound pack", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudio()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("blur")
|
||||
|
||||
const dispose = attention.soundboard.registerPack({
|
||||
id: "acme.soft",
|
||||
name: "Soft Alerts",
|
||||
sounds: {
|
||||
question: "/tmp/question.mp3",
|
||||
},
|
||||
})
|
||||
|
||||
expect(attention.soundboard.activate("acme.soft")).toBe(true)
|
||||
expect(attention.soundboard.current()).toBe("acme.soft")
|
||||
expect(attention.soundboard.list()).toContainEqual({
|
||||
id: "acme.soft",
|
||||
name: "Soft Alerts",
|
||||
active: true,
|
||||
builtin: false,
|
||||
})
|
||||
|
||||
expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.bytesPaths).toEqual(["/tmp/question.mp3"])
|
||||
|
||||
dispose()
|
||||
expect(attention.soundboard.current()).toBe("opencode.default")
|
||||
})
|
||||
|
||||
test("uses config sound overrides before active pack sounds and falls back on load failure", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudio()
|
||||
audio.rejectPaths.add("/tmp/bad-question.mp3")
|
||||
const attention = createTuiAttention({
|
||||
renderer,
|
||||
config: config({ sounds: { question: "/tmp/bad-question.mp3" } }),
|
||||
audio,
|
||||
})
|
||||
renderer.emit("blur")
|
||||
|
||||
attention.soundboard.registerPack({
|
||||
id: "acme.soft",
|
||||
sounds: {
|
||||
question: "/tmp/good-question.mp3",
|
||||
},
|
||||
})
|
||||
attention.soundboard.activate("acme.soft")
|
||||
|
||||
expect(await attention.notify({ message: "question", sound: "question" })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.bytesPaths).toEqual(["/tmp/bad-question.mp3", "/tmp/good-question.mp3"])
|
||||
})
|
||||
|
||||
test("persists activated sound pack in KV", () => {
|
||||
const kv = new FakeKV()
|
||||
const renderer = new FakeRenderer()
|
||||
const attention = createTuiAttention({ renderer, config: config(), kv })
|
||||
|
||||
attention.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
|
||||
|
||||
expect(attention.soundboard.activate("missing", { persist: true })).toBe(false)
|
||||
expect(kv.store.attention_sound_pack).toBeUndefined()
|
||||
expect(attention.soundboard.activate("acme.soft", { persist: true })).toBe(true)
|
||||
expect(kv.store.attention_sound_pack).toBe("acme.soft")
|
||||
|
||||
const next = createTuiAttention({ renderer: new FakeRenderer(), config: config(), kv })
|
||||
next.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
|
||||
expect(next.soundboard.current()).toBe("acme.soft")
|
||||
})
|
||||
|
||||
test("does not throw for notification or sound failures", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudio()
|
||||
|
||||
@@ -98,6 +98,8 @@ function config(input?: {
|
||||
notifications: true,
|
||||
sound: true,
|
||||
volume: 0.4,
|
||||
sound_pack: "opencode.default",
|
||||
sounds: {},
|
||||
},
|
||||
diff_style: input?.diff_style,
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
||||
|
||||
@@ -854,6 +854,75 @@ test("plugin keymap proxy preserves real keymap receiver", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("auto-disposes plugin attention sound packs and resolves relative paths", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "attention-soundpack-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.attention.soundpack",
|
||||
tui: async (api) => {
|
||||
api.attention.soundboard.registerPack({
|
||||
id: "demo.pack",
|
||||
sounds: { question: "sounds/question.mp3" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec }
|
||||
},
|
||||
})
|
||||
|
||||
const packs: Array<{ id: string; sounds: Record<string, string> }> = []
|
||||
let dropped = 0
|
||||
const attention = {
|
||||
async notify() {
|
||||
return { ok: false, notification: false, sound: false }
|
||||
},
|
||||
soundboard: {
|
||||
registerPack(pack: { id: string; sounds: Record<string, string> }) {
|
||||
packs.push(pack)
|
||||
return () => {
|
||||
dropped += 1
|
||||
}
|
||||
},
|
||||
activate: () => false,
|
||||
current: () => "opencode.default",
|
||||
list: () => [],
|
||||
},
|
||||
} as NonNullable<Parameters<typeof createTuiPluginApi>[0]>["attention"]
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi({ attention }),
|
||||
config: createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(packs).toEqual([
|
||||
{
|
||||
id: "demo.pack",
|
||||
sounds: { question: path.join(tmp.path, "sounds", "question.mp3") },
|
||||
},
|
||||
])
|
||||
expect(dropped).toBe(0)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
expect(dropped).toBe(1)
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("auto-disposes plugin keymap transformers", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
Reference in New Issue
Block a user