// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "reflect" "sync" "testing" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/sound" ) // Hold the startup playback at the backend while application settings commit. // No audio device or timing sleep is needed to observe synchronous cancellation. type startupSoundPlayer struct { started chan context.Context release chan struct{} once sync.Once } func (player *startupSoundPlayer) Play(ctx context.Context, _ []byte, _, _ float64) error { first := false player.once.Do(func() { first = true }) if first { player.started <- ctx select { case <-ctx.Done(): return ctx.Err() case <-player.release: } } return nil } func TestApplicationSettingsPreserveStartupSoundUnlessSoundPolicyChanges(t *testing.T) { for _, test := range []struct { name string change func(*config.Settings) cancel bool }{ {name: "same settings", change: func(*config.Settings) {}}, {name: "title reading override", change: func(settings *config.Settings) { settings.Reading.AutomaticallyReadTitleChanges = false }}, {name: "volume override", cancel: true, change: func(settings *config.Settings) { settings.SoundVolume = 0.8 }}, {name: "disabled sound", cancel: true, change: func(settings *config.Settings) { settings.EnableSound = false }}, {name: "theme override", cancel: true, change: func(settings *config.Settings) { settings.SoundTheme = "none" }}, {name: "sink override", cancel: true, change: func(settings *config.Settings) { settings.SoundSink = "pulse" }}, } { t.Run(test.name, func(t *testing.T) { settings := config.DefaultSettings() logger := slog.New(slog.DiscardHandler) player := &startupSoundPlayer{started: make(chan context.Context, 1), release: make(chan struct{})} service := sound.New(logger, player, settings.SoundTheme, settings.SoundVolume) service.Configure(soundConfiguration(settings)) if err := service.Start(context.Background()); err != nil { t.Fatal(err) } t.Cleanup(func() { close(player.release) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := service.Stop(ctx); err != nil { t.Error(err) } }) if !service.Enqueue(sound.Cue{Name: "start"}) { t.Fatal("startup cue rejected") } var playback context.Context select { case playback = <-player.started: case <-time.After(time.Second): t.Fatal("startup cue did not reach backend") } worker := newPreferencesWorker(logger, settings, nil) worker.sound = service next := settings test.change(&next) worker.appSettings["Example Application"] = next worker.switchApplication(context.Background(), "Example Application") if worker.activeApp != "Example Application" || !reflect.DeepEqual(worker.settings, next) { t.Fatal("application settings were not applied") } if got := playback.Err() == context.Canceled; got != test.cancel { t.Fatalf("startup playback cancelled = %t, want %t", got, test.cancel) } }) } }