189 lines
5.8 KiB
Go
189 lines
5.8 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package spellcheck
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestExceptionEditsPreserveOtherReadersChanges(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "exceptions.json")
|
|
options := Options{ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{}, nil }}
|
|
first, second := New_service(context.Background(), options), New_service(context.Background(), options)
|
|
defer first.Close()
|
|
defer second.Close()
|
|
// Both readers load the original list, then the user switches between them.
|
|
receive_result(t, first, Request{Operation: Inspect})
|
|
receive_result(t, second, Request{Operation: Inspect})
|
|
for _, edit := range []struct {
|
|
service *Service
|
|
request Request
|
|
want []string
|
|
}{
|
|
{first, Request{Operation: Add, Word: "Alpha"}, []string{"Alpha"}},
|
|
{second, Request{Operation: Add, Word: "Beta"}, []string{"Alpha", "Beta"}},
|
|
{first, Request{Operation: Remove, Word: "ALPHA"}, []string{"Beta"}},
|
|
{second, Request{Operation: Add, Word: "Gamma"}, []string{"Beta", "Gamma"}},
|
|
{first, Request{Operation: Add, Word: "Alpha"}, []string{"Alpha", "Beta", "Gamma"}},
|
|
} {
|
|
result := receive_result(t, edit.service, edit.request)
|
|
if result.Err != nil || !reflect.DeepEqual(result.Exceptions, edit.want) {
|
|
t.Fatalf("edit %v: result=%+v, want=%v", edit.request, result, edit.want)
|
|
}
|
|
words, err := load_exceptions(path)
|
|
if err != nil || !reflect.DeepEqual(exception_list(words), edit.want) {
|
|
t.Fatalf("saved exceptions=%v, error=%v, want=%v", words, err, edit.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Separate processes verify that the transaction lock works across readers.
|
|
func TestExceptionWriterProcess(t *testing.T) {
|
|
path := os.Getenv("FENRIR_EXCEPTION_TEST_PATH")
|
|
if path == "" {
|
|
return
|
|
}
|
|
service := New_service(context.Background(), Options{ExceptionsPath: path, OpenDictionary: func(string) (Dictionary, error) { return &TestDictionary{}, nil }})
|
|
defer service.Close()
|
|
encoder, decoder := json.NewEncoder(os.Stdout), json.NewDecoder(os.Stdin)
|
|
result := receive_result(t, service, Request{Operation: Inspect})
|
|
if result.Err != nil {
|
|
t.Fatal(result.Err)
|
|
}
|
|
if err := encoder.Encode(result.Exceptions); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for {
|
|
var request Request
|
|
if err := decoder.Decode(&request); err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
return
|
|
}
|
|
t.Fatal(err)
|
|
}
|
|
result := receive_result(t, service, request)
|
|
if result.Err != nil {
|
|
t.Fatal(result.Err)
|
|
}
|
|
if err := encoder.Encode(result.Exceptions); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConcurrentExceptionProcessesKeepBothEdits(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "exceptions.json")
|
|
type Writer struct {
|
|
encoder *json.Encoder
|
|
decoder *json.Decoder
|
|
}
|
|
start_writer := func() Writer {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestExceptionWriterProcess$")
|
|
cmd.Env = append(os.Environ(), "FENRIR_EXCEPTION_TEST_PATH="+path)
|
|
cmd.Stderr = os.Stderr
|
|
input, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
output, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
input.Close()
|
|
err := cmd.Wait()
|
|
cancel()
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
writer := Writer{json.NewEncoder(input), json.NewDecoder(output)}
|
|
var ready []string
|
|
if err := writer.decoder.Decode(&ready); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return writer
|
|
}
|
|
first, second := start_writer(), start_writer()
|
|
for _, edit := range []struct {
|
|
writer Writer
|
|
word string
|
|
}{{first, "Alpha"}, {second, "Beta"}} {
|
|
if err := edit.writer.encoder.Encode(Request{Operation: Add, Word: edit.word}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, writer := range []Writer{first, second} {
|
|
var words []string
|
|
if err := writer.decoder.Decode(&words); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
words, err := load_exceptions(path)
|
|
if err != nil || !reflect.DeepEqual(exception_list(words), []string{"Alpha", "Beta"}) {
|
|
t.Fatal("lost concurrent addition", words, err)
|
|
}
|
|
info, err := os.Stat(path + ".lock")
|
|
if err != nil || info.Mode().Perm() != 0600 {
|
|
t.Fatal("lock permissions", info, err)
|
|
}
|
|
}
|
|
|
|
func TestExceptionLockCancellationAndInvalidStorePreserveData(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "exceptions.json")
|
|
if err := save_exceptions(path, map[string]string{"alpha": "Alpha"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lock, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer lock.Close()
|
|
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
|
defer cancel()
|
|
if _, err := update_exceptions(ctx, path, Add, "Beta"); !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatal("lock wait ignored cancellation", err)
|
|
}
|
|
words, err := load_exceptions(path)
|
|
if err != nil || !reflect.DeepEqual(exception_list(words), []string{"Alpha"}) {
|
|
t.Fatal("cancelled edit changed data", words, err)
|
|
}
|
|
if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_UN); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := update_exceptions(context.Background(), path, Add, "Beta"); err != nil {
|
|
t.Fatal("edit failed after lock release", err)
|
|
}
|
|
if err := os.WriteFile(path, []byte("invalid JSON"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := update_exceptions(context.Background(), path, Add, "Gamma"); err == nil {
|
|
t.Fatal("edit overwrote invalid shared file")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || string(data) != "invalid JSON" {
|
|
t.Fatal("invalid file was replaced", string(data), err)
|
|
}
|
|
}
|