diff --git a/internal/auth/pairlang_test.go b/internal/auth/pairlang_test.go index 2e66229..39f3c8a 100644 --- a/internal/auth/pairlang_test.go +++ b/internal/auth/pairlang_test.go @@ -180,14 +180,17 @@ func TestDirectionRoundTrip(t *testing.T) { } } -// The refusal this axis exists to make: a pair with no word list cannot be -// learned toward, however good its langpack is. fr, es and pt-PT all have copy, -// voices and spelling dictionaries — and nothing that could segment a sentence -// or read from that language into English, which is what a learner needs. +// The refusal this axis exists to make: a pair with no learner-side data cannot +// be learned toward, however good its langpack is. fr and es have copy, voices +// and spelling dictionaries, and no `learner` block in their packs to offer the +// choice with — so the server keeps saying no until one is written. +// +// pt-PT is deliberately no longer in this list; see TestLearnerDirectionForPtPT +// below and the argument in `learnerPairs`. func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) { _, users, _ := newStores(t) - for _, lang := range []string{"pt-PT", "fr", "es"} { + for _, lang := range []string{"fr", "es"} { if err := users.SetPair("bob", lang, DirectionLearningEn); err != nil { t.Fatalf("set %s: %v", lang, err) } @@ -201,6 +204,37 @@ func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) { } } +// The other direction of that same rule, and the one a native English speaker +// writing Portuguese depends on. +// +// This is not only a settings toggle: `direction` is what decides which language +// Petal *explains* in (see suggestions.targetFor), so an account that cannot +// reach learning_pair gets its Portuguese annotated in Portuguese with no way to +// ask for English. Pinned in both directions — the move must take, and it must +// still be there when the account is read back. +func TestLearnerDirectionForPtPT(t *testing.T) { + _, users, _ := newStores(t) + + if err := users.SetPair("bob", "pt-PT", DirectionLearningEn); err != nil { + t.Fatalf("set pt-PT: %v", err) + } + if w := patchMe(t, users, "bob", `{"direction":"learning_pair"}`); w.Code != http.StatusOK { + t.Fatalf("status = %d (%s), want 200", w.Code, w.Body.String()) + } + u, _ := users.Get("bob") + if u.Direction != DirectionLearningPair || u.PairLang != "pt-PT" { + t.Fatalf("account = %+v, want pt-PT learning_pair", u) + } + + // And it can be turned back, the same as zh. + if w := patchMe(t, users, "bob", `{"direction":"learning_en"}`); w.Code != http.StatusOK { + t.Fatalf("turn back: status = %d (%s)", w.Code, w.Body.String()) + } + if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn { + t.Fatalf("direction = %q after turning back", u.Direction) + } +} + // The two-field combination the handler validates as one decision. An account // already learning Chinese that asks only to change pair is asking for a state // neither field names on its own — French with segmentation — and it must not diff --git a/internal/auth/users.go b/internal/auth/users.go index afa4078..955d8ab 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -104,19 +104,34 @@ const ( // The pairs whose *learner* direction Petal can actually serve, which is a // narrower thing than a shipped pair and narrower again than a langpack. // -// Turning a pair around needs data no langpack carries: a word list to segment -// with, and a dictionary that reads from the pair language into English. Chinese -// has both as of Phase 26 (CC-CEDICT + jieba); French, Spanish and Portuguese -// have neither yet, and — unlike a missing pack, which leaves a writer looking -// at copy she cannot read — a missing word list would leave her looking at an -// editor that silently does nothing when she hovers. Both are bad; only one is -// legible as a bug. So the server refuses, for the same reason and by the same -// mechanism as `shippedPairs`. +// Turning a pair around needs data no langpack carries: a way to find word +// boundaries, and a dictionary that reads from the pair language into English. A +// pair missing either would leave a writer looking at an editor that silently +// does nothing when she hovers — worse than a missing pack, which at least reads +// as a bug rather than as an absence. So the server refuses, for the same reason +// and by the same mechanism as `shippedPairs`. // -// This list is expected to grow one pair at a time and never to be inferred: -// segmentation is a property of a writing system, and there is no rule that -// derives "has a word list" from a language code. -var learnerPairs = []string{"zh"} +// Chinese has both as of Phase 26 (CC-CEDICT + jieba). Portuguese turns out to +// have both as well, and the original note here — "French, Spanish and +// Portuguese have neither" — was written one phase too early to see it: +// +// - Word boundaries are spaces. The megabyte word list jieba needs is a +// property of a writing system that doesn't use them, not a debt every pair +// owes; a Latin-script pair needs nothing loaded to be segmented. +// - The dictionary arrived with dict.db, which reads pt→en as readily as +// en→pt (see lexicon.dreamProvider.reverse). The reverse lookup the hover +// and the word card need is already there and already answering. +// +// So the pair a native English speaker learning Portuguese needs is real, and +// what was actually blocking it was this list. French and Spanish clear the same +// two bars through the same dict.db; they are held back only by their packs +// carrying no `learner` copy yet (see Pack.learner), which is a translation +// question rather than a data one. +// +// This list is still expected to grow one pair at a time and never to be +// inferred: segmentation is a property of a writing system, and there is no rule +// that derives "has a word list" from a language code. +var learnerPairs = []string{"zh", "pt-PT"} // SupportsLearnerDirection reports whether a pair can be turned around. func SupportsLearnerDirection(lang string) bool { diff --git a/internal/lexicon/handlers.go b/internal/lexicon/handlers.go index 837ade1..5b7960b 100644 --- a/internal/lexicon/handlers.go +++ b/internal/lexicon/handlers.go @@ -49,10 +49,19 @@ func (h *Handler) GlossRoutes() chi.Router { // It does not go through [Handler.providerFor], and that is not an oversight. // providerFor picks a dictionary by the writer's *pair*, to answer "what does // this English word mean in her language" — a question whose answer differs per -// pair. This endpoint asks the opposite question of exactly one language, and -// [auth.SupportsLearnerDirection] already guarantees that language is Chinese. -// Routing it through the pair would add a database read per hover to choose -// between one option and itself. +// pair. This endpoint asks the opposite question of exactly one language: it +// reads hanzi, and hanzi are Chinese whoever is looking them up. Routing it +// through the pair would add a database read per hover to choose between one +// option and itself. +// +// What no longer holds is the reason this used to give — that +// [auth.SupportsLearnerDirection] guarantees the caller is on the zh pair. Since +// Portuguese joined `learnerPairs` a learning_pair account may be Portuguese, so +// the guarantee now comes from the *caller*: the client only ever asks this +// route about a token its Chinese segmenter found, and that segmenter is loaded +// only for the zh pair (see useSegmenter in App.tsx). A stray lookup is still +// answered safely — a word the Chinese dictionary has never heard of is a 200 +// with empty lists, exactly like any other miss. func (h *Handler) HanziRoutes() chi.Router { r := chi.NewRouter() r.Get("/{word}", h.hanzi) diff --git a/internal/suggestions/doclang_test.go b/internal/suggestions/doclang_test.go index 3c3349c..2dc752b 100644 --- a/internal/suggestions/doclang_test.go +++ b/internal/suggestions/doclang_test.go @@ -119,8 +119,36 @@ func TestDocumentLangNeedsCorroboration(t *testing.T) { } } -// The two language decisions are genuinely independent, and only the zh pair can -// prove it today — it is the one pair that can be travelled in both directions. +// The Portuguese half of the same rule, and the bug it was reported as: "the +// Portuguese option isn't translating the advice in English — it's just +// reprinting Portuguese." +// +// Nothing was wrong with targetFor when that was reported. It was reading a +// direction the account could not leave: `learnerPairs` held only zh, so every +// pt-PT writer was learning_en by force and this function correctly explained a +// Portuguese document in Portuguese. Pinned here rather than only in the auth +// package because this is where the consequence actually lands — the language +// the writer reads her advice in. +func TestTargetExplainsPortugueseInEnglishForALearner(t *testing.T) { + learner := targetFor("pt-PT", auth.DirectionLearningPair, docLangPair) + if learner.Correct.Code != "pt-PT" { + t.Fatalf("corrected in %s, want the document's own Portuguese", learner.Correct.Code) + } + if learner.Explain.Code != "en" { + t.Fatalf("explained in %s, want English", learner.Explain.Code) + } + + // And the native Portuguese speaker practising English is untouched: her + // Portuguese is still explained in Portuguese. + native := targetFor("pt-PT", auth.DirectionLearningEn, docLangPair) + if native.Correct.Code != "pt-PT" || native.Explain.Code != "pt-PT" { + t.Fatalf("learning_en on a Portuguese document: correct=%s explain=%s", native.Correct.Code, native.Explain.Code) + } +} + +// The two language decisions are genuinely independent, and zh was the first +// pair that could prove it — the first that could be travelled in both +// directions. // // A Mandarin native practising English who writes Chinese wants Chinese // corrections explained in Chinese. An English native learning Chinese who writes diff --git a/web/src/App.tsx b/web/src/App.tsx index 9326d38..15f961d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -118,7 +118,13 @@ export default function App() { // Mandarin native drafting English quotes Chinese constantly, and none of that // is what segmentation is for. Declared above the checkpoint because the // offline 错别字 pass reads it. - const segmenter = useSegmenter(me?.direction === 'learning_pair') + // + // Both halves of the gate matter now that Chinese is not the only pair with a + // learner direction. `learning_pair` alone used to imply zh; a writer learning + // Portuguese is also learning_pair and has no use for a megabyte of Chinese + // word list — nor for the hanzi hover it turns on, which would ask /api/hanzi + // about Portuguese words. + const segmenter = useSegmenter(me?.direction === 'learning_pair' && me?.pair_lang === 'zh') const { suggestions, diff --git a/web/src/i18n/packs/pt-PT.ts b/web/src/i18n/packs/pt-PT.ts index 3400422..f3465c7 100644 --- a/web/src/i18n/packs/pt-PT.ts +++ b/web/src/i18n/packs/pt-PT.ts @@ -42,6 +42,25 @@ export const ptPT: Pack = { nativeName: 'Português', locale: 'pt-PT', + // pt-PT is the second pair Petal can be *learned* toward: spaces do the + // segmenting a Latin script needs, and dict.db already reads Portuguese into + // English (see auth.learnerPairs for both halves of that argument). + // + // Each label is written for whoever would pick it, which is why they are not + // in the same language as each other. A native Portuguese speaker practising + // English reads the first; an English speaker learning Portuguese reads the + // second, and would not be helped by being told "Português" in Portuguese. + // + // This is also the switch that decides which language Petal *explains* in, so + // it is the difference between a Portuguese document annotated in Portuguese + // and the same document annotated in English. + learner: { + label: 'Estou a aprender · I am learning', + toEn: 'inglês', + toPair: 'Portuguese', + failed: 'Não foi possível mudar · Couldn’t switch — nothing changed', + }, + app: { duplicateTitle: (title) => `${title} (cópia)`, garden: 'Jardim de palavras',