204 lines
6.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|