// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package sound import ( "bytes" "encoding/binary" "math" "os" "path/filepath" "slices" "testing" "git.stormux.org/storm/fenrir/internal/presentation" ) func TestBundledNormalizedPackDecodesAndContainsEveryCue(t *testing.T) { root := "../../config/sound/default" clips, err := load_theme([]string{root}) if err != nil || len(clips) != 20 { t.Fatal(len(clips), err) } paths, err := filepath.Glob(filepath.Join(root, "*.wav")) if err != nil || len(paths) != 28 { t.Fatal(paths, err) } for _, path := range paths { item, err := decode_file(path) if err != nil || item == nil || len(item.samples) == 0 { t.Fatal(path, err) } for _, sample := range item.samples { if math.IsNaN(float64(sample)) || math.Abs(float64(sample)) > 0.81 { t.Fatal("invalid or excessive peak", path, sample) } } } } func test_wav(rate uint32) []byte { var data bytes.Buffer data.WriteString("RIFF") binary.Write(&data, binary.LittleEndian, uint32(40)) data.WriteString("WAVEfmt ") binary.Write(&data, binary.LittleEndian, uint32(16)) for _, value := range []uint16{1, 1} { binary.Write(&data, binary.LittleEndian, value) } binary.Write(&data, binary.LittleEndian, rate) binary.Write(&data, binary.LittleEndian, rate*2) for _, value := range []uint16{2, 16} { binary.Write(&data, binary.LittleEndian, value) } data.WriteString("data") binary.Write(&data, binary.LittleEndian, uint32(4)) binary.Write(&data, binary.LittleEndian, []int16{8192, -8192}) return data.Bytes() } func TestWAVDecodeAndResample(t *testing.T) { item, err := decode_wav(bytes.NewReader(test_wav(48000))) if err != nil || len(item.samples) != 4 || item.samples[0] != 0.25 || item.samples[1] != 0.25 || item.samples[2] != -0.25 { t.Fatalf("mono decoding: %+v, %v", item, err) } item, err = decode_wav(bytes.NewReader(test_wav(24000))) if err != nil || len(item.samples) != 8 { t.Fatalf("resampling: %+v, %v", item, err) } for _, data := range [][]byte{[]byte("invalid"), test_wav(1), test_wav(48000)[:45]} { if _, err := decode_wav(bytes.NewReader(data)); err == nil { t.Fatal("accepted invalid or truncated WAV") } } base := test_wav(48000) extended := append([]byte(nil), base[:36]...) extended = append(extended, 22, 0, 16, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xaa, 0, 0x38, 0x9b, 0x71) extended = append(extended, base[36:]...) binary.LittleEndian.PutUint32(extended[4:], uint32(len(extended)-8)) binary.LittleEndian.PutUint32(extended[16:], 40) binary.LittleEndian.PutUint16(extended[20:], 0xfffe) item, err = decode_wav(bytes.NewReader(extended)) if err != nil || len(item.samples) != 4 || item.samples[0] != 0.25 { t.Fatalf("extended PCM: %+v %v", item, err) } } func TestThemeTOMLAndPartialAvailability(t *testing.T) { root := t.TempDir() write := func(name string, data []byte) { t.Helper() if err := os.WriteFile(filepath.Join(root, name), data, 0600); err != nil { t.Fatal(err) } } write("cue.wav", test_wav(48000)) write("soundicons.toml", []byte("[icons]\nready = 'cue.wav'\nblank = 'missing.wav'\n")) clips, err := load_theme([]string{root}) if err == nil || len(clips) != 1 || clips[presentation.Ready] == nil { t.Fatal("partial theme disabled good cue", clips, err) } for _, data := range []string{"[icons]\nready = '../other.wav'", "[icons]\nunknown = 'cue.wav'", "driver = 'external'", "[icons"} { write("soundicons.toml", []byte(data)) if _, err := load_theme([]string{root}); err == nil { t.Fatal("accepted invalid theme", data) } } if _, err := load_theme([]string{filepath.Join(root, "missing")}); err == nil { t.Fatal("missing sound pack was not reported") } } func TestInstalledThemeWhenRequested(t *testing.T) { root := os.Getenv("FENRIR_TEST_SOUND_THEME") if root == "" { t.Skip("set FENRIR_TEST_SOUND_THEME to decode an installed pack without playing it") } clips, err := load_theme([]string{root}) if err != nil || len(clips) != 16 { t.Fatalf("installed cues=%d, %v", len(clips), err) } } func FuzzWAVDecode(f *testing.F) { f.Add(test_wav(48000)) f.Add(test_wav(24000)) f.Add([]byte("RIFF")) f.Fuzz(func(t *testing.T, data []byte) { item, err := decode_wav(bytes.NewReader(data)) if err == nil && (len(item.samples) == 0 || len(item.samples) > sampleRate*2*10) { t.Fatal("invalid decoded size", len(item.samples)) } }) } func TestSelectedThemesNeverMergeOrSubstitutePacks(t *testing.T) { first, second := t.TempDir(), t.TempDir() for _, root := range []string{first, second} { if err := os.WriteFile(filepath.Join(root, "cue.wav"), test_wav(48000), 0600); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(first, "soundicons.toml"), []byte("[icons]\nready='absent.wav'\n"), 0600); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(second, "soundicons.toml"), []byte("[icons]\nready='cue.wav'\n"), 0600); err != nil { t.Fatal(err) } clips, err := load_theme([]string{first, second}) if err == nil || clips[presentation.Ready] != nil { t.Fatal("missing cue substituted from a lower priority pack", err) } clips, err = load_theme([]string{filepath.Join(first, "missing"), second}) if err != nil || clips[presentation.Ready] == nil { t.Fatal("named search did not find later installation", err) } } func TestThemeNamesPathsAndValidation(t *testing.T) { home := t.TempDir() root := filepath.Join(home, ".local/stormux/fenrir/sounds") for _, name := range []string{"zeta", "alpha"} { if err := os.MkdirAll(filepath.Join(root, name), 0700); err != nil { t.Fatal(err) } } names := Theme_names(home) if names[0] != "default" || !slices.Contains(names, "alpha") || !slices.Contains(names, "zeta") { t.Fatal(names) } paths := Theme_paths(home, "alpha") if paths[0] != filepath.Join(root, "alpha") { t.Fatal(paths) } explicit := filepath.Join(home, "custom") if got := Theme_paths(home, explicit); !slices.Equal(got, []string{explicit}) { t.Fatal(got) } for _, name := range []string{"default", "alpha", explicit} { if err := Validate_theme(name); err != nil { t.Fatal(name, err) } } for _, name := range []string{"", " ", "..", ".", "../other", "sub/pack", "bad\x00name", "bad\nname"} { if Validate_theme(name) == nil { t.Fatal("invalid theme accepted", name) } } }