Files

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)
}
}