Files
plotzilla/internal/translate/translate_test.go
T
2026-10-01 10:15:28 +02:00

308 lines
10 KiB
Go

package translate
import (
"context"
"errors"
"reflect"
"slices"
"sync"
"testing"
"plotzilla/internal/screenplay"
)
// fakeMT translates from a fixed table; a sentence it does not know comes
// back in angle brackets, so the test shows what was asked.
type fakeMT struct {
mu sync.Mutex
dict map[string]string
calls []string
err error
}
func (f *fakeMT) Translate(ctx context.Context, text string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, text)
if f.err != nil {
return "", f.err
}
if out, ok := f.dict[text]; ok {
return out, nil
}
return "<" + text + ">", nil
}
type fakeLexicon map[string][2]bool
func (l fakeLexicon) Knows(w string) (bool, bool) { return l[w][0], l[w][1] }
var lexicon = fakeLexicon{
"attends": {true, true}, "cuisine": {true, true}, "laughing": {false, true}, "phone": {false, true},
"rings": {false, true}, "nobody": {false, true}, "moves": {false, true}, "kitchen": {false, true},
"street": {false, true}, "pleut": {true, false}, "entre": {true, false}, "lettre": {true, false}, "octobre": {true, false},
}
var dict = map[string]string{
"Le cuisine de Zorbin": "Zorbin's kitchen",
"Le rue": "The street",
"Zorbin ?": "Zorbin?",
"Tu es là ?": "Are you there?",
"Zorbin décroche.": "Zorbin picks up.",
"Le policier": "The policeman",
"à Zorbin": "To Zorbin",
"Attends !": "Wait!",
"Elle entre.": "She comes in.",
"Il pleut.": "It's raining.",
"Vérifier la date": "Check the date.",
"la lettre.": "the letter.",
"Je l'ai lue.": "I read it.",
"1er octobre 2026": "1 October 2026",
"Il pleut. Elle entre.": "unused",
"Bonjour, Pierre.": "Hello, Pierre.",
"Bonjour, « Zorbin » !": "Hello, « Zorbin » !",
"Ça marche, Zorbin.": "That works, Zorbin.",
"Zorbin et Dravok sortent.": "Zorbin and Dravok go out.",
}
func el(t screenplay.ElementType, text string) screenplay.Element {
return screenplay.NewElement(t, text)
}
func translateDoc(t *testing.T, doc *screenplay.Document, opts Options) (Result, *fakeMT) {
t.Helper()
mt := &fakeMT{dict: dict}
res, err := Document(context.Background(), doc, mt, lexicon, opts)
if err != nil {
t.Fatal(err)
}
return res, mt
}
// texts applies the changes and returns every element's text.
func texts(doc *screenplay.Document, res Result) []string {
out := make([]string, len(doc.Elements))
for i, e := range doc.Elements {
out[i] = e.Text()
}
for _, c := range res.Changes {
out[c.Index] = c.Element.Text()
}
return out
}
func TestScreenplayElements(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Scene, "INT. CUISINE DE MARIE - NUIT"),
el(screenplay.Character, "PIERRE (VOIX OFF)"),
el(screenplay.Dialogue, "Marie ? Tu es là ?"),
el(screenplay.Character, "MARIE"),
el(screenplay.Parenthetical, "(un temps)"),
el(screenplay.Dialogue, "Attends !"),
el(screenplay.Parenthetical, "(à Marie)"),
el(screenplay.Character, "LE POLICIER"),
el(screenplay.Character, "MARIE (SUITE)"),
el(screenplay.Transition, "FONDU ENCHAÎNÉ :"),
el(screenplay.Scene, "EXTÉRIEUR RUE - AUBE (FLASH-BACK)"),
el(screenplay.Action, "The phone rings. Marie décroche."),
el(screenplay.Centered, "FIN"),
}}
res, _ := translateDoc(t, doc, Options{})
want := []string{
"INT. MARIE'S KITCHEN - NIGHT",
"PIERRE (V.O.)",
"Marie? Are you there?",
"MARIE",
"(beat)",
"Wait!",
"(to Marie)",
"THE POLICEMAN",
"MARIE (CONT'D)",
"DISSOLVE TO:",
"EXT. STREET - DAWN (FLASHBACK)",
"The phone rings. Marie picks up.",
"THE END",
}
if got := texts(doc, res); !reflect.DeepEqual(got, want) {
for i := range want {
if got[i] != want[i] {
t.Errorf("element %d = %q, want %q", i, got[i], want[i])
}
}
}
if slices.ContainsFunc(res.Changes, func(c Change) bool { return c.Index == 3 }) {
t.Error("a character name was changed")
}
}
func TestEnglishLeftAlone(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Scene, "INT. KITCHEN - DAY"),
el(screenplay.Action, "Nobody moves. The phone rings."),
el(screenplay.Character, "PIERRE (O.S.)"),
el(screenplay.Parenthetical, "(laughing)"),
el(screenplay.Dialogue, "I told you, I'm not leaving."),
el(screenplay.Transition, "CUT TO:"),
el(screenplay.Shot, "CLOSE ON THE LETTER"),
el(screenplay.Action, "OK."),
}}
res, mt := translateDoc(t, doc, Options{TitlePage: true})
if len(res.Changes) != 0 || res.TitlePage != nil || len(mt.calls) != 0 {
t.Errorf("English script changed: %+v, model asked %q", res, mt.calls)
}
}
func TestActionRetyped(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Action, "FONDU EN OUVERTURE :"),
el(screenplay.Action, "GROS PLAN SUR la lettre."),
el(screenplay.Action, "COUPE SUR :"),
el(screenplay.Action, "Il pleut."),
}}
res, _ := translateDoc(t, doc, Options{})
want := map[int]screenplay.Element{
0: el(screenplay.Transition, "FADE IN:"),
1: el(screenplay.Shot, "CLOSE ON the letter."),
2: el(screenplay.Transition, "CUT TO:"),
3: el(screenplay.Action, "It's raining."),
}
for _, c := range res.Changes {
if w := want[c.Index]; c.Element.Type != w.Type || c.Element.Text() != w.Text() {
t.Errorf("element %d = %s %q, want %s %q", c.Index, c.Element.Type, c.Element.Text(), w.Type, w.Text())
}
}
if len(res.Changes) != len(want) {
t.Errorf("%d changes, want %d", len(res.Changes), len(want))
}
}
func TestNotesStylesAndLocks(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{{
Type: screenplay.Action,
Runs: []screenplay.Run{
{Text: "Elle entre. ", Bold: true},
{Text: "Vérifier la date", Note: true},
{Text: " Il pleut.", Rev: 1},
},
Locked: []screenplay.PageLock{{Offset: 0, Page: "2"}, {Offset: 28, Page: "3"}},
}}}
res, _ := translateDoc(t, doc, Options{})
if len(res.Changes) != 1 {
t.Fatalf("changes: %+v", res.Changes)
}
got := res.Changes[0].Element
want := []screenplay.Run{
{Text: "She comes in. ", Bold: true},
// No full stop: the note had none.
{Text: "Check the date", Note: true},
{Text: " It's raining.", Rev: 1},
}
if !reflect.DeepEqual(got.Runs, want) {
t.Errorf("runs = %+v, want %+v", got.Runs, want)
}
// 28 of 38 runes → the same proportion of the 42 translated ones.
if want := []screenplay.PageLock{{Offset: 0, Page: "2"}, {Offset: 31, Page: "3"}}; !reflect.DeepEqual(got.Locked, want) {
t.Errorf("locks = %+v, want %+v", got.Locked, want)
}
}
func TestSelectionAndTitlePage(t *testing.T) {
doc := &screenplay.Document{
TitlePage: screenplay.TitlePage{Title: "La Dernière Bobine", Credit: "Écrit par", DraftDate: "1er octobre 2026"},
Elements: []screenplay.Element{
el(screenplay.Action, "Il pleut."),
el(screenplay.Action, "Elle entre."),
},
}
res, _ := translateDoc(t, doc, Options{Elements: []int{1}})
if len(res.Changes) != 1 || res.Changes[0].Index != 1 || res.TitlePage != nil {
t.Errorf("selection: %+v", res)
}
res, _ = translateDoc(t, doc, Options{TitlePage: true})
want := screenplay.TitlePage{Title: "La Dernière Bobine", Credit: "Written by", DraftDate: "1 October 2026"}
if res.TitlePage == nil || *res.TitlePage != want {
t.Errorf("title page = %+v, want %+v", res.TitlePage, want)
}
}
func TestNamesAndTypography(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Character, "MARIE"),
el(screenplay.Character, "PIERRE"),
el(screenplay.Dialogue, "Bonjour, « Marie » !"),
el(screenplay.Action, "Ça marche, PIERRE."),
el(screenplay.Action, "Marie et Pierre sortent."),
}}
res, mt := translateDoc(t, doc, Options{})
got := texts(doc, res)
want := []string{"MARIE", "PIERRE", `Hello, "Marie"!`, "That works, PIERRE.", "Marie and Pierre go out."}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %q, want %q (model asked %q)", got, want, mt.calls)
}
}
func TestIdempotent(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{
el(screenplay.Scene, "INT. CUISINE DE MARIE - NUIT"),
el(screenplay.Character, "MARIE (SUITE)"),
el(screenplay.Dialogue, "Je l'ai lue."),
}}
res, _ := translateDoc(t, doc, Options{})
for _, c := range res.Changes {
doc.Elements[c.Index] = c.Element
}
res, mt := translateDoc(t, doc, Options{})
if len(res.Changes) != 0 || len(mt.calls) != 0 {
t.Errorf("second translation changed %+v, asked %q", res.Changes, mt.calls)
}
}
func TestModelError(t *testing.T) {
doc := &screenplay.Document{Elements: []screenplay.Element{el(screenplay.Action, "Il pleut.")}}
boom := errors.New("boom")
if _, err := Document(context.Background(), doc, &fakeMT{err: boom}, nil, Options{Workers: 4}); !errors.Is(err, boom) {
t.Errorf("err = %v, want boom", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Document(ctx, doc, &fakeMT{dict: dict}, nil, Options{}); !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestSentences(t *testing.T) {
tests := map[string][]string{
"Il pleut. Elle entre.": {"Il pleut. ", "Elle entre."},
"Quoi ? Non !": {"Quoi ? ", "Non !"},
"M. Dupont entre. J. Doe aussi.": {"M. Dupont entre. ", "J. Doe aussi."},
"Attends... il revient.": {"Attends... il revient."},
"« Non. » Il sort.": {"« Non. » ", "Il sort."},
}
for in, want := range tests {
if got := sentences(in); !reflect.DeepEqual(got, want) {
t.Errorf("sentences(%q) = %q, want %q", in, got, want)
}
}
}
func TestDetection(t *testing.T) {
tests := []struct {
text string
french bool
}{
{"Il pleut sur la ville.", true},
{"It's raining on the city.", false},
{"Qu'est-ce que tu fais ?", true},
{"Allô ?", true},
{"OK.", false},
{"Don't move.", false},
{"Marie", false},
}
s := &session{lex: lexicon, nameWords: map[string]bool{"marie": true}}
for _, tt := range tests {
if got := s.french(tt.text); got != tt.french {
t.Errorf("french(%q) = %v, want %v", tt.text, got, tt.french)
}
}
}