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

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