Files

204 lines
6.9 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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) != 19 {
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)
}
}
}