diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..9b500dd --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,116 @@ +// Package i18n exists only to hold this test. +// +// The interface's strings live in the frontend, where they are read; nothing in Go +// needs them. What Go can do is fail the build when the two dictionaries drift, +// which is the failure mode a translation always has and which nothing else here +// would notice: a key present in Danish and missing from English renders as Danish +// inside an English screen, and a key present only in English is a string nobody +// will ever see. +// +// A scanner rather than a parser, the same approach `colors_test.go` and +// `openapi_test.go` take: it needs the keys out of two lists this repository owns, +// and that is worth more than a dependency in a project whose whole shape is "one +// static binary". +package i18n + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +func TestTheTwoDictionariesHaveTheSameKeys(t *testing.T) { + dir := filepath.Join("..", "..", "web", "src", "lib", "locales") + da := readKeys(t, filepath.Join(dir, "da.js")) + en := readKeys(t, filepath.Join(dir, "en.js")) + + if len(da) == 0 { + t.Fatal("no keys found in da.js — the scanner is looking at the wrong shape") + } + + var missing, extra []string + for key := range da { + if !en[key] { + missing = append(missing, key) + } + } + for key := range en { + if !da[key] { + extra = append(extra, key) + } + } + sort.Strings(missing) + sort.Strings(extra) + + if len(missing) > 0 { + t.Errorf("%d keys are in da.js and not in en.js — they render as Danish inside an "+ + "English screen:\n %s", len(missing), strings.Join(missing, "\n ")) + } + if len(extra) > 0 { + t.Errorf("%d keys are in en.js and not in da.js — nothing reads them, because Danish "+ + "is the source:\n %s", len(extra), strings.Join(extra, "\n ")) + } +} + +// The placeholders have to match too. `t()` fills `{name}` from what the caller +// passes, so a translation that spells one differently leaves `{navn}` on the +// screen — which is worse than an untranslated sentence, because it looks like a +// bug in the app rather than a gap in the dictionary. +func TestTheTwoDictionariesUseTheSamePlaceholders(t *testing.T) { + dir := filepath.Join("..", "..", "web", "src", "lib", "locales") + da := readEntries(t, filepath.Join(dir, "da.js")) + en := readEntries(t, filepath.Join(dir, "en.js")) + + for key, danish := range da { + english, ok := en[key] + if !ok { + continue // reported by the test above + } + want := placeholders(danish) + got := placeholders(english) + if strings.Join(want, ",") != strings.Join(got, ",") { + t.Errorf("%s: Danish fills %v, English fills %v", key, want, got) + } + } +} + +var ( + // `'some.key': 'the string',` — single-quoted keys and values, which is what + // prettier writes for these files. + reEntry = regexp.MustCompile(`(?m)^\s*'([a-zA-Z0-9._]+)':\s*(.*?),?\s*$`) + rePlaceholder = regexp.MustCompile(`\{(\w+)\}`) +) + +func readKeys(t *testing.T, path string) map[string]bool { + t.Helper() + keys := map[string]bool{} + for key := range readEntries(t, path) { + keys[key] = true + } + return keys +} + +func readEntries(t *testing.T, path string) map[string]string { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", filepath.Base(path), err) + } + out := map[string]string{} + for _, m := range reEntry.FindAllStringSubmatch(string(body), -1) { + out[m[1]] = m[2] + } + return out +} + +func placeholders(s string) []string { + var found []string + for _, m := range rePlaceholder.FindAllStringSubmatch(s, -1) { + found = append(found, m[1]) + } + sort.Strings(found) + return found +} diff --git a/web/src/lib/components/CommandPalette.svelte b/web/src/lib/components/CommandPalette.svelte index 634126e..bf53261 100644 --- a/web/src/lib/components/CommandPalette.svelte +++ b/web/src/lib/components/CommandPalette.svelte @@ -1,6 +1,7 @@