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