New endpoints: /antonyms, /backing, /pronunciation, /etymology with difficulty scoring on /random. Cross-language synset backing links French/Portuguese words to English equivalents via WordNet 3.0 synset IDs (matching WOLF and OMW offsets). New loaders: Hunspell affix expansion (fr, pt-PT), English programmatic inflector, CMU Pronouncing Dictionary, SUBTLEX-US frequency, CETEMPúblico frequency, Open Multilingual Wordnet (Portuguese), and SQL-based difficulty scoring. Schema v2 adds tables: antonyms, synsets, word_synsets, pronunciations, etymology with migration support for existing databases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
227 lines
6.6 KiB
Go
227 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"dreamdict/internal/dictionary"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
func mustExec(t *testing.T, db *sql.DB, query string, args ...any) {
|
|
t.Helper()
|
|
if _, err := db.Exec(query, args...); err != nil {
|
|
t.Fatalf("mustExec: %s: %v", query, err)
|
|
}
|
|
}
|
|
|
|
func setupTestServer(t *testing.T) (*dictionary.Dictionary, *http.ServeMux) {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite", ":memory:?_pragma=foreign_keys(on)")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
if err := dictionary.BootstrapSchema(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Seed data
|
|
mustExec(t, db, "INSERT INTO meta (key, value) VALUES ('schema_version', '1')")
|
|
mustExec(t, db, "INSERT INTO meta (key, value) VALUES ('imported_at', '2025-01-01T00:00:00Z')")
|
|
mustExec(t, db, "INSERT INTO words (id, word, lang, pos, frequency) VALUES (1, 'happy', 'en', 'adjective', 0)")
|
|
mustExec(t, db, "INSERT INTO words (id, word, lang, pos, frequency) VALUES (2, 'cat', 'en', 'noun', 0)")
|
|
mustExec(t, db, "INSERT INTO words (id, word, lang, pos, frequency) VALUES (3, 'chat', 'fr', 'noun', 5000)")
|
|
mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (1, 'adjective', 'feeling pleasure', 'wordnet', 10)")
|
|
mustExec(t, db, "INSERT INTO definitions (word_id, pos, gloss, source, priority) VALUES (1, 'adjective', 'feeling joy', 'wiktionary', 20)")
|
|
mustExec(t, db, "INSERT INTO synonyms (word_id, synonym, source) VALUES (1, 'glad', 'wordnet')")
|
|
mustExec(t, db, "INSERT INTO translations (word_id, translation, target_lang, source) VALUES (2, 'chat', 'fr', 'wiktionary')")
|
|
|
|
dict := dictionary.NewFromDB(db)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /valid", handleValid(dict))
|
|
mux.HandleFunc("GET /random", handleRandom(dict))
|
|
mux.HandleFunc("GET /define", handleDefine(dict))
|
|
mux.HandleFunc("GET /synonyms", handleSynonyms(dict))
|
|
mux.HandleFunc("GET /translate", handleTranslate(dict))
|
|
mux.HandleFunc("GET /health", handleHealth(dict, ":memory:"))
|
|
|
|
t.Cleanup(func() { db.Close() })
|
|
return dict, mux
|
|
}
|
|
|
|
func TestValidEndpoint(t *testing.T) {
|
|
_, mux := setupTestServer(t)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
// Valid word
|
|
resp, _ := http.Get(srv.URL + "/valid?word=happy&lang=en")
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var result map[string]bool
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if !result["valid"] {
|
|
t.Error("expected valid=true")
|
|
}
|
|
|
|
// Invalid word
|
|
resp, _ = http.Get(srv.URL + "/valid?word=zzzzz&lang=en")
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if result["valid"] {
|
|
t.Error("expected valid=false")
|
|
}
|
|
|
|
// Missing params
|
|
resp, _ = http.Get(srv.URL + "/valid?word=happy")
|
|
if resp.StatusCode != 400 {
|
|
t.Errorf("missing lang: status = %d, want 400", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestRandomEndpoint(t *testing.T) {
|
|
_, mux := setupTestServer(t)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, _ := http.Get(srv.URL + "/random?lang=en")
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var result map[string]string
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if result["word"] == "" {
|
|
t.Error("expected non-empty word")
|
|
}
|
|
|
|
// No match
|
|
resp, _ = http.Get(srv.URL + "/random?lang=en&min=100")
|
|
if resp.StatusCode != 404 {
|
|
t.Errorf("no match: status = %d, want 404", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestDefineEndpoint(t *testing.T) {
|
|
_, mux := setupTestServer(t)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, _ := http.Get(srv.URL + "/define?word=happy&lang=en")
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var result struct {
|
|
Word string `json:"word"`
|
|
Lang string `json:"lang"`
|
|
Definitions []dictionary.Definition `json:"definitions"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if len(result.Definitions) != 2 {
|
|
t.Errorf("got %d definitions, want 2", len(result.Definitions))
|
|
}
|
|
if result.Definitions[0].Priority > result.Definitions[1].Priority {
|
|
t.Error("definitions not ordered by priority")
|
|
}
|
|
|
|
// Word with no definitions
|
|
resp, _ = http.Get(srv.URL + "/define?word=cat&lang=en")
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("no defs: status = %d, want 200", resp.StatusCode)
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if len(result.Definitions) != 0 {
|
|
t.Errorf("got %d definitions, want 0", len(result.Definitions))
|
|
}
|
|
}
|
|
|
|
func TestSynonymsEndpoint(t *testing.T) {
|
|
_, mux := setupTestServer(t)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, _ := http.Get(srv.URL + "/synonyms?word=happy&lang=en")
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var result struct {
|
|
Synonyms []string `json:"synonyms"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if len(result.Synonyms) != 1 {
|
|
t.Errorf("got %d synonyms, want 1", len(result.Synonyms))
|
|
}
|
|
|
|
// No synonyms
|
|
resp, _ = http.Get(srv.URL + "/synonyms?word=cat&lang=en")
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if len(result.Synonyms) != 0 {
|
|
t.Errorf("got %d synonyms, want 0", len(result.Synonyms))
|
|
}
|
|
}
|
|
|
|
func TestTranslateEndpoint(t *testing.T) {
|
|
_, mux := setupTestServer(t)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, _ := http.Get(srv.URL + "/translate?word=cat&from=en&to=fr")
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var result struct {
|
|
Translations []string `json:"translations"`
|
|
}
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if len(result.Translations) != 1 || result.Translations[0] != "chat" {
|
|
t.Errorf("got %v, want [chat]", result.Translations)
|
|
}
|
|
|
|
// No translations
|
|
resp, _ = http.Get(srv.URL + "/translate?word=happy&from=en&to=fr")
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if len(result.Translations) != 0 {
|
|
t.Errorf("got %d, want 0", len(result.Translations))
|
|
}
|
|
}
|
|
|
|
func TestHealthEndpoint(t *testing.T) {
|
|
_, mux := setupTestServer(t)
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
resp, _ := http.Get(srv.URL + "/health")
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var result map[string]any
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
resp.Body.Close()
|
|
if result["status"] != "ok" {
|
|
t.Errorf("status = %v, want ok", result["status"])
|
|
}
|
|
counts, ok := result["word_counts"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("word_counts not a map")
|
|
}
|
|
if counts["en"].(float64) != 2 {
|
|
t.Errorf("en word count = %v, want 2", counts["en"])
|
|
}
|
|
}
|