i18n TDD Test Template (Cross-Surface)
i18n specs/i18n/test-template.kmd
Template normativo de testes TDD pra validar conformidade com `specs/i18n/contract.kmd` em qualquer módulo Koder, qualquer surface (Flutter mobile/desktop/TV, Web, Server-rendered HTML, CLI, TUI). Define os 6 casos canônicos (T1-T6) com snippets concretos por surface. Material consumido por `/k-test-gen-i18n`.
When this pattern applies
All triggers
- Gerar testes TDD de i18n para módulo / variante UI Koder
- Auditar cobertura de strings hardcoded em UI Koder
- Implementar testes do contrato i18n cross-surface
Specification body
Spec — i18n TDD Test Template (Cross-Surface)
Mapping to specs/i18n/contract.kmd
| Test | Validates |
|---|---|
| T1 | R4 (zero strings hardcoded) — analyzer estático |
| T2 | R5 (fallback chain) — locale resolution |
| T3 | R3 (switcher visibility & position) |
| T4 | R2 (persistência da escolha) |
| T5 | R1 + behaviors §9.4 (sem picker no onboarding) |
| T6 | R1 row "server-rendered" (Accept-Language honoring) |
Toda variante UI de todo módulo Koder DEVE ter T1-T5 (plus T6 quando
aplicável). Output dir per surface segue /k-test-gen-i18n —
canonical paths abaixo.
T1 — Hardcoded-string analyzer
Scan estático do código de UI buscando string literals fora de chamadas
ao loader canônico (KoderL10n.t(...) / t(key) / i18n.T(ctx, key)).
Falha = strings hardcoded existem.
T1 — Flutter (<module>/test/i18n/no_hardcoded_strings_test.dart)
import 'dart:io';
import 'package:test/test.dart';
void main() {
test('no hardcoded user-facing strings in widget code', () {
final widgetDir = Directory('lib');
final violations = <String>[];
for (final f in widgetDir.listSync(recursive: true).whereType<File>()) {
if (!f.path.endsWith('.dart')) continue;
// Tests, generated code, and ARB loaders are exempt.
if (f.path.contains('/test/') ||
f.path.contains('.g.dart') ||
f.path.endsWith('/l10n.dart')) {
continue;
}
final src = f.readAsStringSync();
// Match Text("…"), Tooltip(message: "…"), hintText: "…"
// String literals INSIDE KoderL10n.t(...) are allowed via the
// negative lookahead — keys are technical, not user-facing.
final pattern = RegExp(
r'''(Text|Tooltip|hintText|labelText|tooltip|message)\s*[(:]\s*['"]([^'"\n]{2,})['"]''',
);
for (final m in pattern.allMatches(src)) {
final literal = m.group(2)!;
if (literal.startsWith('koder.') || literal.startsWith('http')) continue;
// Allowed: ASCII-only with 0 spaces (slugs/IDs/codes).
if (RegExp(r'^[A-Z0-9_-]+$').hasMatch(literal)) continue;
violations.add('${f.path}: "$literal"');
}
}
expect(violations, isEmpty,
reason: 'Hardcoded UI strings found:\n${violations.join('\n')}');
});
}
T1 — Web/JS (<module>/__tests__/i18n/no_hardcoded_strings.test.ts)
import { readFileSync } from 'fs';
import { globSync } from 'glob';
test('no hardcoded user-facing strings in component templates', () => {
const violations: string[] = [];
// Adjust globs per project layout (.tsx / .vue / .templ / .html).
const files = globSync('src/**/*.{ts,tsx,vue,html,templ}');
for (const file of files) {
const src = readFileSync(file, 'utf8');
// textContent, innerText, label, placeholder, title, aria-label.
const re = /(textContent|innerText|placeholder|title|aria-label|label)\s*[=:]\s*["']([^"'\n]{2,})["']/g;
let m: RegExpExecArray | null;
while ((m = re.exec(src)) != null) {
const lit = m[2];
if (lit.startsWith('http') || /^[A-Z0-9_-]+$/.test(lit)) continue;
violations.push(`${file}: "${lit}"`);
}
}
expect(violations).toEqual([]);
});
T1 — Go server / CLI / TUI (<module>/i18n_lint_test.go)
//go:build i18n_lint
// +build i18n_lint
package main
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"strings"
"testing"
)
func TestNoHardcodedStrings(t *testing.T) {
violations := []string{}
fset := token.NewFileSet()
_ = filepath.Walk(".", func(p string, _ os.FileInfo, _ error) error {
if !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
return nil
}
f, err := parser.ParseFile(fset, p, nil, 0)
if err != nil { return nil }
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING { return true }
s := strings.Trim(lit.Value, "`\"")
// Strings shorter than 3 chars or all-uppercase IDs are
// not user-facing.
if len(s) < 3 { return true }
if strings.ToUpper(s) == s { return true }
// Allow keys consumed by the i18n loader.
if strings.HasPrefix(s, "koder.") { return true }
// TODO: per-project allow-list of legitimate hard-coded
// strings (URLs, env-var names, etc.) loaded from
// .i18n_lint_allow.txt.
violations = append(violations, fset.Position(lit.Pos()).String()+": "+s)
return true
})
return nil
})
if len(violations) > 0 {
t.Fatalf("hardcoded strings:\n%s", strings.Join(violations, "\n"))
}
}
T2 — Locale resolution
Bootstrap KoderL10n com 2 locales registrados (en-US, pt-BR)
mais um locale não-suportado (de-DE). Verifica que cada chave
renderiza no idioma correto e que o não-suportado faz fallback en-US.
T2 — Flutter (<module>/test/i18n/locale_resolution_test.dart)
import 'package:flutter_test/flutter_test.dart';
import 'package:koder_kit/koder_kit.dart';
void main() {
setUp(() {
KoderL10n.register('en-US', {'hello': 'Hello', 'sign_in': 'Sign in'});
KoderL10n.register('pt-BR', {'hello': 'Olá', 'sign_in': 'Entrar'});
});
test('en-US returns English strings', () {
KoderL10n.setLocale('en-US');
expect(KoderL10n.t('hello'), 'Hello');
expect(KoderL10n.t('sign_in'), 'Sign in');
});
test('pt-BR returns Portuguese strings', () {
KoderL10n.setLocale('pt-BR');
expect(KoderL10n.t('hello'), 'Olá');
expect(KoderL10n.t('sign_in'), 'Entrar');
});
test('unsupported locale falls back to en-US', () {
KoderL10n.setLocale('de-DE');
expect(KoderL10n.t('hello'), 'Hello');
});
test('language prefix matches when full locale missing', () {
KoderL10n.setLocale('pt-PT'); // pt registered as pt-BR
expect(KoderL10n.t('hello'), 'Olá'); // matches via 'pt' prefix
});
}
T2 — Web/JS
test('locale resolution + fallback', () => {
KoderL10n.register('en-US', {hello: 'Hello'});
KoderL10n.register('pt-BR', {hello: 'Olá'});
KoderL10n.setLocale('en-US');
expect(KoderL10n.t('hello')).toBe('Hello');
KoderL10n.setLocale('pt-BR');
expect(KoderL10n.t('hello')).toBe('Olá');
KoderL10n.setLocale('de-DE');
expect(KoderL10n.t('hello')).toBe('Hello'); // fallback en-US
});
T3 — Switcher visibility
Confirma que o KoderLanguagePicker (Flutter) ou
<koder-language-picker> (Web) aparece na posição canônica per R3
quando koder.toml [i18n].locales.length >= 2.
T3 — Flutter
testWidgets('Settings page shows KoderLanguagePicker when locales >= 2',
(tester) async {
KoderL10n.register('en-US', {});
KoderL10n.register('pt-BR', {});
await tester.pumpWidget(
MaterialApp(home: Scaffold(body: SettingsScreen())),
);
expect(find.byType(KoderLanguagePicker), findsOneWidget);
});
T3 — Web (Playwright)
test('landing footer has language picker', async ({page}) => {
await page.goto('/');
const picker = page.locator('footer koder-language-picker');
await expect(picker).toBeVisible();
});
T4 — Persistence
Picker → restart simulado → idioma preservado. Pick "System default" → remove key → device locale aplica.
T4 — Flutter
test('picker selection persists across restart', () async {
SharedPreferences.setMockInitialValues({});
await KoderL10n.persist('pt-BR');
// Simulate restart by re-bootstrapping.
await KoderL10n.bootstrap();
expect(KoderL10n.locale, 'pt-BR');
await KoderL10n.persist(null); // System default
await KoderL10n.bootstrap();
expect(KoderL10n.locale, KoderL10n.detectDeviceLocale());
});
T4 — Web/JS
test('localStorage persists picker selection', () => {
KoderL10n.persist('pt-BR');
expect(localStorage.getItem(KoderL10n.STORAGE_KEY)).toBe('pt-BR');
KoderL10n.bootstrap();
expect(KoderL10n.locale).toBe('pt-BR');
KoderL10n.persist(null);
expect(localStorage.getItem(KoderL10n.STORAGE_KEY)).toBeNull();
});
T5 — No locale picker on onboarding
Cold start não exibe dialog de seleção de idioma. Aplica a apps que têm onboarding flow (não vale pra surfaces sem onboarding como CLI).
T5 — Flutter
testWidgets('cold start does not show locale-picker dialog',
(tester) async {
await tester.pumpWidget(MyApp());
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(find.text('Choose your language'), findsNothing);
expect(find.text('Escolha seu idioma'), findsNothing);
});
T5 — Web (Playwright)
test('first visit does not show language modal', async ({page}) => {
await page.context().clearCookies();
await page.goto('/');
await expect(page.locator('[role="dialog"]:has-text("language")')).toHaveCount(0);
await expect(page.locator('[role="dialog"]:has-text("idioma")')).toHaveCount(0);
});
T6 — Server Accept-Language honoring (server-rendered surfaces only)
GET com Accept-Language: pt-BR retorna HTML em pt-BR. Sem header,
en-US.
T6 — Go (httptest)
func TestAcceptLanguageHonored(t *testing.T) {
srv := httptest.NewServer(buildHandler())
defer srv.Close()
cases := []struct {
header string
expectIn string
}{
{"", "Sign in"},
{"en-US", "Sign in"},
{"pt-BR,pt;q=0.9,en;q=0.8", "Entrar"},
{"de-DE", "Sign in"}, // fallback
}
for _, c := range cases {
req, _ := http.NewRequest("GET", srv.URL+"/login", nil)
if c.header != "" {
req.Header.Set("Accept-Language", c.header)
}
resp, err := http.DefaultClient.Do(req)
if err != nil { t.Fatal(err) }
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), c.expectIn) {
t.Errorf("Accept-Language=%q: want body to contain %q",
c.header, c.expectIn)
}
}
}
CLI / TUI specifics
CLIs/TUIs validam T1, T2, T4 mas usam env vars (LANG/LC_ALL) em
vez de SharedPreferences/localStorage. Persistência live em
~/.config/koder/<slug>/locale. T3 valida flag --locale + subcomando
config set locale. T5 não se aplica.
// T2 — CLI: locale picked from env
func TestLocaleFromEnv(t *testing.T) {
t.Setenv("LANG", "pt_BR.UTF-8")
loc := koderl10n.Detect() // returns "pt-BR"
if loc != "pt-BR" { t.Fatalf("got %q", loc) }
}
Generation via /k-test-gen-i18n
/k-test-gen-i18n <módulo> lê <module>/koder.toml [i18n].locales,
descobre a surface via app/<surface>/ ou [targets], e materializa
T1-T6 (aplicáveis) nos paths canônicos:
| Surface | Output |
|---|---|
| Flutter | <module>/test/i18n/<test>_test.dart |
| Web/JS | <module>/__tests__/i18n/<test>.test.ts |
| Go | <module>/i18n_test.go (with build tag i18n_lint) |
| Server HTML (templ/Go) | <module>/internal/web/i18n_test.go |
Os arquivos gerados começam com o header padrão de
commands/k-test-gen-i18n.md. Falha de qualquer T1-T6 = bloqueia commit
via pre-commit hook (mesmo gate que policies/regression-tests.kmd).
References
specs/i18n/contract.kmdpolicies/regression-tests.kmd