// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package punctuation import ( "os" "path/filepath" "strings" "testing" ) func TestShippedProfilesAndLevels(t *testing.T) { paths, err := filepath.Glob("../../config/punctuation/*.toml") if err != nil || len(paths) != 7 { t.Fatal(paths, err) } for _, path := range paths { processor, _, err := Load([]string{path}) if err != nil { t.Fatal(path, err) } for _, level := range Levels() { if _, exists := processor.levels[level]; !exists { t.Fatal(path, level) } } if processor.Character('.') == "." { t.Fatal("missing character name", path) } } } func TestTextLevelsPreservePausesAndDoNotReprocessNames(t *testing.T) { processor, _, err := Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) } for _, testCase := range []struct{ level, input, want string }{ {"none", "don't 3.14 [X]mute a_b", "don't 3.14 X mute a b"}, {"some", "a/b#c!", "a slash b hash c!"}, {"most", "a,b!", "a comma b bang! "}, {"all", "?", " question?? "}, {"all", "a b", "a space b"}, {"some", "é→Ω", "é→Ω"}, } { if got := processor.Text(testCase.input, testCase.level); got != testCase.want { t.Errorf("%s %q: %q, want %q", testCase.level, testCase.input, got, testCase.want) } } if got := processor.Character('?'); got != "question?" { t.Fatal(got) } if got := processor.Character('E'); got != "E" { t.Fatal(got) } } func TestEmbeddedPunctuation(t *testing.T) { processor, _, err := Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) } for _, trial := range []struct{ input, want string }{ {"https://stormux.org", "https colon slash slash stormux dot org"}, {"Visit https://example.invalid/path?q=a&n=2.", "Visit https colon slash slash example dot invalid slash path question? q equals a and n equals 2."}, {"user@example.invalid", "user at example dot invalid"}, {"file_name.txt", "file line name dot txt"}, {"你好.世界 café:menu", "你好 dot 世界 café colon menu"}, {"cafe\u0301.txt", "cafe\u0301 dot txt"}, {"don't can't", "don't can't"}, {"3.14 1,000 12:30", "3.14 1,000 12:30"}, {"Hello, world! End. Next: word?", "Hello, world! End. Next: word?"}, {"(word) [word]", "word word"}, {"a...b", "a dot dot dot b"}, } { if got := strings.Join(strings.Fields(processor.Text(trial.input, "some")), " "); got != trial.want { t.Errorf("%q: %q, want %q", trial.input, got, trial.want) } } for _, trial := range []struct{ level, want string }{ {"none", "https: stormux.org"}, {"some", "https colon slash slash stormux dot org"}, {"most", "https colon slash slash stormux dot org"}, {"all", "https colon slash slash stormux dot org"}, } { if got := processor.Text("https://stormux.org", trial.level); got != trial.want { t.Errorf("%s: %q, want %q", trial.level, got, trial.want) } } french, _, err := Load([]string{"../../config/punctuation/fr.toml"}) if err != nil { t.Fatal(err) } if got := french.Text("example.invalid", "some"); got != "example point invalid" { t.Fatal("embedded punctuation lost profile name", got) } } func TestDottedNumericSequences(t *testing.T) { processor, _, err := Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) } for _, trial := range []struct{ input, want string }{ {"2026.10.31", "2026 dot 10 dot 31"}, {"(2026.10.31).", "2026 dot 10 dot 31 ."}, {"v1.2.3 127.0.0.1", "v1 dot 2 dot 3 127 dot 0 dot 0 dot 1"}, {"2026.10.31", "2026 dot 10 dot 31"}, {"3.14 2026.10.31 2.5", "3.14 2026 dot 10 dot 31 2.5"}, {"3.14. 2.5", "3.14. 2.5"}, {"1,234.56 1.234,56 1,000,000 12:30:45", "1,234.56 1.234,56 1,000,000 12:30:45"}, } { got := strings.Join(strings.Fields(processor.Text(trial.input, "some")), " ") if got != trial.want { t.Errorf("%q: got %q, want %q", trial.input, got, trial.want) } } for _, trial := range []struct{ level, want string }{ {"some", "2026 dot 10 dot 31."}, {"most", "2026 dot 10 dot 31 dot. "}, {"all", "2026 dot 10 dot 31 dot. "}, } { if got := processor.Text("2026.10.31.", trial.level); got != trial.want { t.Errorf("%s: incorrect embedded or sentence pause: %q", trial.level, got) } } if got := processor.Text("2026.10.31", "none"); got != "2026.10.31" { t.Fatal("none level changed", got) } } func TestNumericPercentSpeech(t *testing.T) { processor, _, err := Load([]string{"../../config/punctuation/default.toml"}) if err != nil { t.Fatal(err) } for _, trial := range []struct{ text, want string }{ {"100%", "100 percent"}, {"0%", "0 percent"}, {"12.5%", "12.5 percent"}, {"-5%", "dash 5 percent"}, {"(100%)", "100 percent"}, {"100%, done", "100 percent , done"}, {"100%", "100 percent"}, {"%", ""}, {"text%", "text"}, {"100 %", "100"}, {"don't 3.14 1,000 12:30", "don't 3.14 1,000 12:30"}, } { for _, options := range []TextOptions{{}, {Emoticons: true, RepeatedSymbols: true, RepeatedSymbolsMin: 4}} { if got := strings.Join(strings.Fields(processor.Process_text(trial.text, "some", options)), " "); got != trial.want { t.Errorf("%q, options %+v: got %q, want %q", trial.text, options, got, trial.want) } } } for _, level := range Levels() { want := "100 percent" if level == "none" { want = "100" } if got := strings.Join(strings.Fields(processor.Text("100%", level)), " "); got != want { t.Errorf("level %s: got %q, want %q", level, got, want) } } data, err := os.ReadFile("../../config/punctuation/default.toml") if err != nil { t.Fatal(err) } custom, err := Decode([]byte(strings.Replace(string(data), `"%" = "percent"`, `"%" = "per/cent"`, 1))) if err != nil { t.Fatal(err) } if got := custom.Text("100%", "some"); got != "100 per/cent " { t.Fatal("percent suffix lost or reprocessed profile name", got) } } func TestCustomNamesAndInvalidProfiles(t *testing.T) { data, err := os.ReadFile("../../config/punctuation/default.toml") if err != nil { t.Fatal(err) } custom := strings.Replace(string(data), `"/" = "slash"`, `"/" = "path/separator"`, 1) processor, err := Decode([]byte(custom)) if err != nil { t.Fatal(err) } if got := processor.Text("a/b", "some"); got != "a path/separator b" { t.Fatal("recursive replacement", got) } for _, invalid := range []string{ strings.Replace(string(data), "version = 1", "version = 99", 1), strings.Replace(string(data), `"none" = ""`, `"none" = "/"`, 1), strings.Replace(string(data), `"/" = "slash"`, `"//" = "slash"`, 1), strings.Replace(string(data), `"/" = "slash"`, `"/" = ""`, 1), string(data) + "\n[unknown]\nkey = 1\n", } { if _, err := Decode([]byte(invalid)); err == nil { t.Fatal("accepted malformed profile", invalid) } } } func TestProfileSelectionAndInvalidShadow(t *testing.T) { home := t.TempDir() paths, err := Paths("default", home, 1000, "bundle") if err != nil || len(paths) != 3 || !strings.HasPrefix(paths[0], home) || paths[2] != filepath.Join("bundle", "default.toml") { t.Fatal(paths, err) } paths, err = Paths("default.toml", home, 0, "bundle") if err != nil || len(paths) != 2 || strings.HasPrefix(paths[0], home) { t.Fatal(paths, err) } for _, name := range []string{"", ".", "..", "old.conf", "dir/profile", "\x00"} { if _, err := Paths(name, home, 0, "bundle"); err == nil { t.Fatal("accepted invalid selection", name) } } shadow := filepath.Join(home, "bad.toml") if err := os.WriteFile(shadow, []byte("not TOML"), 0600); err != nil { t.Fatal(err) } if _, selected, err := Load([]string{shadow, "../../config/punctuation/default.toml"}); err == nil || selected != shadow { t.Fatal("invalid shadow was skipped", selected, err) } if _, _, err := Load([]string{filepath.Join(home, "missing.toml"), "../../config/punctuation/default.toml"}); err != nil { t.Fatal(err) } }