Files
multica/server/internal/handler/user_language_test.go
5f52380439 feat(i18n): add French locale (#7141)
* feat(i18n): add French locale

Adds `fr` as a supported UI locale for web and desktop.

- 25 translated resource files under `packages/views/locales/fr/`, at full
  parity with the English source (`locales/parity.test.ts` passes).
- Registers the locale in `SUPPORTED_LOCALES`, the views resource bundle, the
  settings language picker, and the `HTML_LANG` maps for web and desktop.
- Translates the celestial workspace name list. Proper nouns with no
  established French form are left unchanged.
- Landing page: adds the `FR` label but leaves the public `locales` array at
  four, since the marketing copy is not translated.
- Onboarding templates map `fr` to English content, matching the existing
  fallback for locales without authored templates.
- `pick-locale.test.ts` used `fr` as its example of an unsupported locale;
  those assertions now use `es`, plus a new case asserting `fr-FR` resolves
  to `fr`.

Terminology follows the glossary in
`apps/docs/content/docs/developers/conventions.mdx`. The UI uses the formal
register (vouvoiement) consistently.

* fix(i18n): translate the backlog status label in French

The French locale translated six of the seven issue statuses and left
`backlog` as "Backlog". The other locales translate all seven — `待规划`,
`백로그`, `バックログ` — so the odd one out was an oversight, not a choice.

`conventions.mdx` does say role and status enums stay in lowercase English,
but that rule is about surfacing the identifier inside prose ("switched to
`in_progress`"), not about the `status` display block, which every shipped
locale translates.

"À planifier" reads as a pair with "À faire" for `todo`, and matches what
`待规划` conveys: the issue exists but is not scheduled yet.

* fix(i18n): realign the French locale with main

Rebasing on main surfaced two drifts that the parity test caught:

- `settings.json` gained `shortcuts.fixed.open_settings` upstream; added
  the French string.
- `search.json` lost its `pages.*` block in #7054, which now generates the
  command palette's Pages group from the nav registry. Dropped the eight
  French keys that no longer have an English counterpart.

* fix(i18n): resync the French locale with main

157 English keys had no French translation and 97 French keys no longer had an
English counterpart. The parity suite caught all of them.

Added: custom issue statuses (76 keys), the Telegram integration (55), issue
revision conflicts and the status catalogue (20), plugin panels and hook
activity (24), revoked agent access in chat (4), autopilot run quota and the
worktree execution mode (2).

Removed: 97 keys upstream has dropped or renamed, most of them under plugins.*.

Two shape changes worth naming, because a key count does not show them:

- plugins.install used to be the string "Install" and is now an object with
  title/description/placeholder/review. A locale that keeps the old shape does
  not have a missing key, it has a key of the wrong TYPE, and parity alone
  stays silent about it — only writing to it fails.
- telegram_bind mirrors slack_bind key for key, so it is derived from the
  existing translation with the service name substituted rather than written
  again. Two texts that say the same thing drift when they are typed twice.

Register: vouvoiement throughout, consistent with the rest of the locale.

* fix(i18n): complete French locale integration and refresh translations

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 02:52:49 +08:00

179 lines
5.2 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/testutil"
)
func TestUpdateMeFrenchLanguageRoundTrip(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
userID := dbfx.User(t, "French user", "[email protected]", testutil.Cols{"language": "en"})
var updated UserResponse
testutil.Call(t, testHandler.UpdateMe, newPatchMeRequest(userID, `{"language":"fr"}`)).Want(http.StatusOK).JSON(&updated)
if updated.Language == nil || *updated.Language != "fr" {
t.Fatalf("updated language = %v, want fr", updated.Language)
}
// A fresh request after reload must restore French, not the old preference.
get := newRequest(http.MethodGet, "/api/me", nil)
get.Header.Set("X-User-ID", userID)
var reloaded UserResponse
testutil.Call(t, testHandler.GetMe, get).Want(http.StatusOK).JSON(&reloaded)
if reloaded.Language == nil || *reloaded.Language != "fr" {
t.Fatalf("reloaded language = %v, want fr", reloaded.Language)
}
}
func newLanguageTestUser(t *testing.T, email string) string {
t.Helper()
ctx := context.Background()
var userID string
if err := testPool.QueryRow(ctx,
`INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id`,
"Language Test", email,
).Scan(&userID); err != nil {
t.Fatalf("insert test user: %v", err)
}
t.Cleanup(func() {
testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, userID)
})
return userID
}
func newPatchMeRequest(userID, body string) *http.Request {
req := httptest.NewRequest("PATCH", "/api/me", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", userID)
return req
}
func TestUpdateMeAcceptsLanguage(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"language":"zh-Hans"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var lang *string
if err := testPool.QueryRow(context.Background(),
`SELECT language FROM "user" WHERE id = $1`, userID,
).Scan(&lang); err != nil {
t.Fatalf("lookup user: %v", err)
}
if lang == nil || *lang != "zh-Hans" {
t.Fatalf("expected language=zh-Hans, got %v", lang)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if got, _ := resp["language"].(string); got != "zh-Hans" {
t.Fatalf("expected response language=zh-Hans, got %v", resp["language"])
}
}
func TestUpdateMeAcceptsKoreanLanguage(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"language":"ko"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if got, _ := resp["language"].(string); got != "ko" {
t.Fatalf("expected response language=ko, got %v", resp["language"])
}
}
func TestUpdateMeAcceptsJapaneseLanguage(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"language":"ja"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if got, _ := resp["language"].(string); got != "ja" {
t.Fatalf("expected response language=ja, got %v", resp["language"])
}
}
func TestUpdateMeRejectsUnsupportedLanguage(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"language":"<script>"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var lang *string
if err := testPool.QueryRow(context.Background(),
`SELECT language FROM "user" WHERE id = $1`, userID,
).Scan(&lang); err != nil {
t.Fatalf("lookup user: %v", err)
}
if lang != nil {
t.Fatalf("expected language unchanged (NULL), got %v", *lang)
}
}
// COALESCE semantics: omitting language must NOT clear an existing value.
func TestUpdateMePreservesLanguageWhenNotProvided(t *testing.T) {
userID := newLanguageTestUser(t, "[email protected]")
if _, err := testPool.Exec(context.Background(),
`UPDATE "user" SET language = 'en' WHERE id = $1`, userID,
); err != nil {
t.Fatalf("preset language: %v", err)
}
w := httptest.NewRecorder()
req := newPatchMeRequest(userID, `{"name":"Updated Name"}`)
testHandler.UpdateMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var lang *string
if err := testPool.QueryRow(context.Background(),
`SELECT language FROM "user" WHERE id = $1`, userID,
).Scan(&lang); err != nil {
t.Fatalf("lookup user: %v", err)
}
if lang == nil || *lang != "en" {
t.Fatalf("expected language=en preserved, got %v", lang)
}
}