228 lines
7.7 KiB
Go
228 lines
7.7 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package spellcheck
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type TestDictionary struct {
|
|
calls []string
|
|
gate <-chan struct{}
|
|
}
|
|
|
|
func (d *TestDictionary) Check(word string) (bool, error) {
|
|
if d.gate != nil {
|
|
<-d.gate
|
|
}
|
|
d.calls = append(d.calls, word)
|
|
return word == "hello", nil
|
|
}
|
|
func (*TestDictionary) Suggest(string) ([]string, error) {
|
|
return []string{"hello", "help", "held"}, nil
|
|
}
|
|
func (*TestDictionary) Close() {}
|
|
|
|
func receive_result(t *testing.T, service *Service, request Request) Result {
|
|
t.Helper()
|
|
if !service.Submit(request) {
|
|
t.Fatal("request not accepted")
|
|
}
|
|
select {
|
|
case result := <-service.Results():
|
|
return result
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("worker timed out")
|
|
return Result{}
|
|
}
|
|
}
|
|
|
|
func TestSessionLanguagePrecedenceAndNoInventedFallback(t *testing.T) {
|
|
for _, name := range []string{"LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"} {
|
|
t.Setenv(name, "")
|
|
}
|
|
t.Setenv("LANG", "en-GB.UTF-8")
|
|
t.Setenv("LANGUAGE", "fr:de")
|
|
if Language() != "en_GB" {
|
|
t.Fatal(Language())
|
|
}
|
|
t.Setenv("LC_MESSAGES", "de_DE.UTF-8@euro")
|
|
if Language() != "de_DE" {
|
|
t.Fatal(Language())
|
|
}
|
|
t.Setenv("LC_ALL", "fr_FR.UTF-8")
|
|
if Language() != "fr_FR" {
|
|
t.Fatal(Language())
|
|
}
|
|
for _, language := range []string{"C", "C.UTF-8", "POSIX", "../../en_US", "en_US:fr"} {
|
|
t.Setenv("LC_ALL", language)
|
|
if Language() != "" {
|
|
t.Fatal(language, Language())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWordExtractionUnicodeContractionsAndTerminalTokens(t *testing.T) {
|
|
for input, want := range map[string]string{"hello,": "hello", "(café)": "café", "don't": "don't", "l’été": "l’été", "naïve": "naïve", "co-operate": "co-operate", "猫": "猫", "123": "", "123": "", "foo42": "", "-foo": "", "./hello": "", "/tmp/hello": "", "name@example.com": "", "https://example.com": "", "some_identifier": "", "foo.bar": "", "--option": "", "@name": "", "#topic": "", "VAR=value": "", "\x00": "", "": ""} {
|
|
if got := Word(input); got != want {
|
|
t.Errorf("%q: %q, want %q", input, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGlobalExceptionsPersistenceRemoveAndFailedWriteIsolation(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "spelling", "exceptions.json")
|
|
dict := &TestDictionary{}
|
|
options := Options{Language: "en_US", ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return dict, nil }}
|
|
service := New_service(context.Background(), options)
|
|
defer service.Close()
|
|
if got := receive_result(t, service, Request{Word: "helo"}); got.Correct || !reflect.DeepEqual(got.Suggestions, []string{"hello", "help", "held"}) {
|
|
t.Fatal(got)
|
|
}
|
|
for _, word := range []string{"Fenrir", "Café"} {
|
|
if got := receive_result(t, service, Request{Operation: Add, Word: word}); got.Err != nil {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
for _, operation := range []Operation{Inspect, Automatic} {
|
|
if got := receive_result(t, service, Request{Operation: operation, Word: "FENRIR"}); !got.Correct || len(got.Suggestions) != 0 {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
if len(dict.calls) != 1 {
|
|
t.Fatal("exception reached dictionary", dict.calls)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil || info.Mode().Perm() != 0600 {
|
|
t.Fatal(info, err)
|
|
}
|
|
service.Close()
|
|
options.Language = "de_DE"
|
|
service = New_service(context.Background(), options)
|
|
defer service.Close()
|
|
if got := receive_result(t, service, Request{Word: "café"}); !got.Correct {
|
|
t.Fatal("exception was language-specific", got)
|
|
}
|
|
if got := receive_result(t, service, Request{Operation: Remove, Word: "Fenrir"}); got.Err != nil || !reflect.DeepEqual(got.Exceptions, []string{"Café"}) {
|
|
t.Fatal(got)
|
|
}
|
|
if got := receive_result(t, service, Request{Word: "Fenrir"}); got.Correct {
|
|
t.Fatal("removed exception accepted", got)
|
|
}
|
|
// Replace the parent with a file so failure is deterministic even as root.
|
|
if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+"-saved"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Dir(path), []byte("blocked"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := receive_result(t, service, Request{Operation: Add, Word: "wrongword"}); got.Err == nil {
|
|
t.Fatal("failed write announced success", got)
|
|
}
|
|
if got := receive_result(t, service, Request{Word: "wrongword"}); got.Correct {
|
|
t.Fatal("failed addition changed memory", got)
|
|
}
|
|
if got := receive_result(t, service, Request{Operation: Remove, Word: "Café"}); got.Err == nil {
|
|
t.Fatal(got)
|
|
}
|
|
if got := receive_result(t, service, Request{Word: "café"}); !got.Correct {
|
|
t.Fatal("failed removal changed memory", got)
|
|
}
|
|
}
|
|
|
|
func TestPATHCommandsBuiltinsAndNeverExecute(t *testing.T) {
|
|
root := t.TempDir()
|
|
t.Setenv("PATH", root)
|
|
marker := filepath.Join(root, "executed")
|
|
if err := os.WriteFile(filepath.Join(root, "tac"), []byte("#!/bin/sh\necho bad > '"+marker+"'\n"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(root, "notexecutable"), []byte("data"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(filepath.Join(root, "directory"), 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dict := &TestDictionary{}
|
|
service := New_service(context.Background(), Options{ExceptionsPath: filepath.Join(root, "exceptions.json"), OpenDictionary: func(string) (Dictionary, error) { return dict, nil }})
|
|
defer service.Close()
|
|
for _, word := range []string{"tac", "cd", "readarray", "ulimit"} {
|
|
if got := receive_result(t, service, Request{Operation: Automatic, Word: word}); !got.Correct {
|
|
t.Fatal(word, got)
|
|
}
|
|
}
|
|
if len(dict.calls) != 0 {
|
|
t.Fatal(dict.calls)
|
|
}
|
|
for _, word := range []string{"notexecutable", "directory", "typo"} {
|
|
if got := receive_result(t, service, Request{Operation: Automatic, Word: word}); got.Correct {
|
|
t.Fatal(word, got)
|
|
}
|
|
}
|
|
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("command executed", err)
|
|
}
|
|
if got := receive_result(t, service, Request{Word: "tac"}); got.Correct {
|
|
t.Fatal("manual lexical check skipped", got)
|
|
}
|
|
}
|
|
|
|
func TestMissingDictionaryStillAllowsExceptionManagement(t *testing.T) {
|
|
service := New_service(context.Background(), Options{ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (Dictionary, error) { return nil, errors.New("missing dictionary") }})
|
|
defer service.Close()
|
|
if got := receive_result(t, service, Request{Word: "hello"}); got.Available || got.DictionaryError == nil || got.Correct {
|
|
t.Fatal(got)
|
|
}
|
|
if got := receive_result(t, service, Request{Operation: Add, Word: "Fenrir"}); got.Err != nil {
|
|
t.Fatal(got)
|
|
}
|
|
if got := receive_result(t, service, Request{Word: "fenrir"}); !got.Correct {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
|
|
func TestSlowDictionaryDoesNotBlockInputSubmissionOrShutdown(t *testing.T) {
|
|
gate := make(chan struct{})
|
|
service := New_service(context.Background(), Options{ExceptionsPath: filepath.Join(t.TempDir(), "exceptions.json"), OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{gate: gate}, nil }})
|
|
started := time.Now()
|
|
for range 100 {
|
|
service.Submit(Request{Operation: Automatic, Word: "helo"})
|
|
}
|
|
if time.Since(started) > 100*time.Millisecond {
|
|
t.Fatal("input blocked")
|
|
}
|
|
close(gate)
|
|
service.Close()
|
|
if service.Submit(Request{}) {
|
|
t.Fatal("closed worker accepted request")
|
|
}
|
|
}
|
|
|
|
func TestActualEnchantDictionaryAndRankedSuggestions(t *testing.T) {
|
|
dict, err := open_dictionary("en_US")
|
|
if err != nil {
|
|
t.Skip(err)
|
|
}
|
|
defer dict.Close()
|
|
if correct, err := dict.Check("hello"); err != nil || !correct {
|
|
t.Fatal(correct, err)
|
|
}
|
|
if correct, err := dict.Check("helo"); err != nil || correct {
|
|
t.Fatal(correct, err)
|
|
}
|
|
if suggestions, err := dict.Suggest("helo"); err != nil || len(suggestions) == 0 {
|
|
t.Fatal(suggestions, err)
|
|
}
|
|
if dict, err := open_dictionary("zz_ZZ"); err == nil {
|
|
dict.Close()
|
|
t.Fatal("missing dictionary accepted")
|
|
}
|
|
}
|