From 357072b0186d92ae1003931e6d76a48d2b1b1fc8 Mon Sep 17 00:00:00 2001 From: Goos Kim Date: Tue, 5 May 2026 18:22:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli/tui):=20P4=209=20golden=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=E2=80=94=20sessionmenu=20+=20i18n=208=20g?= =?UTF-8?q?olden=20(SPEC-GOOSE-CLI-TUI-003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - snapshot_sessionmenu_test.go: TestSnapshot_SessionMenuOpen → session_menu_open.golden - snapshot_i18n_test.go: 8개 i18n 테스트 (ko×4 + en×4) - testdata/snapshots/: 9 신규 golden 파일 생성 - AC-CLITUI3-004/009/010 RED→GREEN - 전체 10 AC GREEN (SPEC 완료) ko golden 검증: 세션:, 대화 명령어, 이 도구 호출을 허용, 최근 세션 en golden 검증: Session:, Conversation commands, Allow this tool call, Recent sessions SPEC: SPEC-GOOSE-CLI-TUI-003 REQ: REQ-CLITUI3-001, -002, -003 AC: AC-CLITUI3-004, AC-CLITUI3-009, AC-CLITUI3-010 Closes #112 🗿 MoAI --- internal/cli/tui/snapshot_i18n_test.go | 131 ++++++++++++++++++ internal/cli/tui/snapshot_sessionmenu_test.go | 37 +++++ .../snapshots/permission_modal_en.golden | 36 +++++ .../snapshots/permission_modal_ko.golden | 36 +++++ .../snapshots/session_menu_open.golden | 30 ++++ .../snapshots/session_menu_open_en.golden | 30 ++++ .../snapshots/session_menu_open_ko.golden | 30 ++++ .../testdata/snapshots/slash_help_en.golden | 23 +++ .../testdata/snapshots/slash_help_ko.golden | 23 +++ .../snapshots/statusbar_idle_en.golden | 23 +++ .../snapshots/statusbar_idle_ko.golden | 23 +++ 11 files changed, 422 insertions(+) create mode 100644 internal/cli/tui/snapshot_i18n_test.go create mode 100644 internal/cli/tui/snapshot_sessionmenu_test.go create mode 100644 internal/cli/tui/testdata/snapshots/permission_modal_en.golden create mode 100644 internal/cli/tui/testdata/snapshots/permission_modal_ko.golden create mode 100644 internal/cli/tui/testdata/snapshots/session_menu_open.golden create mode 100644 internal/cli/tui/testdata/snapshots/session_menu_open_en.golden create mode 100644 internal/cli/tui/testdata/snapshots/session_menu_open_ko.golden create mode 100644 internal/cli/tui/testdata/snapshots/slash_help_en.golden create mode 100644 internal/cli/tui/testdata/snapshots/slash_help_ko.golden create mode 100644 internal/cli/tui/testdata/snapshots/statusbar_idle_en.golden create mode 100644 internal/cli/tui/testdata/snapshots/statusbar_idle_ko.golden diff --git a/internal/cli/tui/snapshot_i18n_test.go b/internal/cli/tui/snapshot_i18n_test.go new file mode 100644 index 0000000..3074e04 --- /dev/null +++ b/internal/cli/tui/snapshot_i18n_test.go @@ -0,0 +1,131 @@ +// Package tui provides i18n snapshot tests for 4 TUI surfaces in en and ko. +// AC-CLITUI3-009 (ko), AC-CLITUI3-010 (en) +package tui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/modu-ai/goose/internal/cli/tui/i18n" + "github.com/modu-ai/goose/internal/cli/tui/permission" + "github.com/modu-ai/goose/internal/cli/tui/sessionmenu" + "github.com/modu-ai/goose/internal/cli/tui/snapshots" +) + +// newI18NModel creates a model pre-configured for i18n snapshot tests. +// noColor=true ensures ASCII-only output for deterministic snapshots. +func newI18NModel(lang string) *Model { + m := NewModel(nil, "test", true /* noColor */) + m.width = 80 + m.height = 24 + m.viewport.Width = 80 + m.viewport.Height = 21 + m.clock = snapshots.FixedClock(time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)) + m.catalog = i18n.Catalogs[lang] + return m +} + +// --- Surface 1: Statusbar idle --- + +// TestSnapshot_I18N_StatusbarIdle_Ko verifies the statusbar idle string in Korean. +// AC-CLITUI3-009 +func TestSnapshot_I18N_StatusbarIdle_Ko(t *testing.T) { + snapshots.SetupAsciiTermenv() + m := newI18NModel("ko") + snapshots.RequireSnapshot(t, "statusbar_idle_ko", []byte(m.View())) +} + +// TestSnapshot_I18N_StatusbarIdle_En verifies the statusbar idle string in English. +// AC-CLITUI3-010 +func TestSnapshot_I18N_StatusbarIdle_En(t *testing.T) { + snapshots.SetupAsciiTermenv() + m := newI18NModel("en") + snapshots.RequireSnapshot(t, "statusbar_idle_en", []byte(m.View())) +} + +// --- Surface 2: /help response --- + +// TestSnapshot_I18N_SlashHelp_Ko verifies the /help output header in Korean. +// AC-CLITUI3-009 +func TestSnapshot_I18N_SlashHelp_Ko(t *testing.T) { + snapshots.SetupAsciiTermenv() + m := newI18NModel("ko") + + // Execute /help via Update so slash handling uses the Korean catalog. + m.input.SetValue("/help") + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(*Model) + + snapshots.RequireSnapshot(t, "slash_help_ko", []byte(m.View())) +} + +// TestSnapshot_I18N_SlashHelp_En verifies the /help output header in English. +// AC-CLITUI3-010 +func TestSnapshot_I18N_SlashHelp_En(t *testing.T) { + snapshots.SetupAsciiTermenv() + m := newI18NModel("en") + + // Execute /help via Update so slash handling uses the English catalog. + m.input.SetValue("/help") + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(*Model) + + snapshots.RequireSnapshot(t, "slash_help_en", []byte(m.View())) +} + +// --- Surface 3: Permission modal --- + +// TestSnapshot_I18N_PermissionModal_Ko verifies the permission modal in Korean. +// AC-CLITUI3-009 +func TestSnapshot_I18N_PermissionModal_Ko(t *testing.T) { + snapshots.SetupAsciiTermenv() + m := newI18NModel("ko") + m.permissionState = permission.PermissionModel{ + Active: true, + ToolName: "Bash", + ToolUseID: "t1", + } + snapshots.RequireSnapshot(t, "permission_modal_ko", []byte(m.View())) +} + +// TestSnapshot_I18N_PermissionModal_En verifies the permission modal in English. +// AC-CLITUI3-010 +func TestSnapshot_I18N_PermissionModal_En(t *testing.T) { + snapshots.SetupAsciiTermenv() + m := newI18NModel("en") + m.permissionState = permission.PermissionModel{ + Active: true, + ToolName: "Bash", + ToolUseID: "t1", + } + snapshots.RequireSnapshot(t, "permission_modal_en", []byte(m.View())) +} + +// --- Surface 4: Session menu open --- + +// TestSnapshot_I18N_SessionMenuOpen_Ko verifies the sessionmenu overlay header in Korean. +// AC-CLITUI3-009 +func TestSnapshot_I18N_SessionMenuOpen_Ko(t *testing.T) { + snapshots.SetupAsciiTermenv() + m := newI18NModel("ko") + m.sessionMenuState = sessionmenu.Open([]sessionmenu.Entry{ + {Name: "session-c"}, + {Name: "session-b"}, + {Name: "session-a"}, + }) + snapshots.RequireSnapshot(t, "session_menu_open_ko", []byte(m.View())) +} + +// TestSnapshot_I18N_SessionMenuOpen_En verifies the sessionmenu overlay header in English. +// AC-CLITUI3-010 +func TestSnapshot_I18N_SessionMenuOpen_En(t *testing.T) { + snapshots.SetupAsciiTermenv() + m := newI18NModel("en") + m.sessionMenuState = sessionmenu.Open([]sessionmenu.Entry{ + {Name: "session-c"}, + {Name: "session-b"}, + {Name: "session-a"}, + }) + snapshots.RequireSnapshot(t, "session_menu_open_en", []byte(m.View())) +} diff --git a/internal/cli/tui/snapshot_sessionmenu_test.go b/internal/cli/tui/snapshot_sessionmenu_test.go new file mode 100644 index 0000000..b6b4a95 --- /dev/null +++ b/internal/cli/tui/snapshot_sessionmenu_test.go @@ -0,0 +1,37 @@ +// Package tui provides snapshot tests for the sessionmenu overlay. +// AC-CLITUI3-004 +package tui + +import ( + "testing" + "time" + + "github.com/modu-ai/goose/internal/cli/tui/sessionmenu" + "github.com/modu-ai/goose/internal/cli/tui/snapshots" +) + +// TestSnapshot_SessionMenuOpen verifies that the session menu overlay renders +// correctly with 3 mock entries in mtime desc order. +// AC-CLITUI3-004 +func TestSnapshot_SessionMenuOpen(t *testing.T) { + // Force ASCII profile for deterministic snapshot output. + snapshots.SetupAsciiTermenv() + + m := NewModel(nil, "test-session", true /* noColor */) + m.clock = snapshots.FixedClock(time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC)) + m.width = 80 + m.height = 24 + m.viewport.Width = 80 + m.viewport.Height = 21 + + // Open sessionmenu with 3 mock entries already in mtime desc order. + entries := []sessionmenu.Entry{ + {Name: "session-c"}, + {Name: "session-b"}, + {Name: "session-a"}, + } + m.sessionMenuState = sessionmenu.Open(entries) + + output := []byte(m.View()) + snapshots.RequireSnapshot(t, "session_menu_open", output) +} diff --git a/internal/cli/tui/testdata/snapshots/permission_modal_en.golden b/internal/cli/tui/testdata/snapshots/permission_modal_en.golden new file mode 100644 index 0000000..8dd547f --- /dev/null +++ b/internal/cli/tui/testdata/snapshots/permission_modal_en.golden @@ -0,0 +1,36 @@ +Session: test | Daemon: | Messages: 0 + + + + + + + + + + + + + + + + + + + + + +> > Type a message... ++----------------------------------+ +| Permission Request: Bash | ++----------------------------------+ +| | +| Allow this tool call? | +| > Allow once | +| Allow always (this tool) | +| Deny once | +| Deny always (this tool) | +| | +| [Up/Down] navigate [Enter] select| +| [Esc] deny once | ++----------------------------------+ diff --git a/internal/cli/tui/testdata/snapshots/permission_modal_ko.golden b/internal/cli/tui/testdata/snapshots/permission_modal_ko.golden new file mode 100644 index 0000000..20cd209 --- /dev/null +++ b/internal/cli/tui/testdata/snapshots/permission_modal_ko.golden @@ -0,0 +1,36 @@ +세션: test | 데몬: | 메시지: 0 + + + + + + + + + + + + + + + + + + + + + +> > Type a message... ++----------------------------------+ +| Permission Request: Bash | ++----------------------------------+ +| | +| 이 도구 호출을 허용... | +| > 이번만 허용 | +| 항상 허용 (이 도구) | +| 이번만 거부 | +| 항상 거부 (이 도구) | +| | +| [Up/Down] navigate [Enter] select| +| [Esc] deny once | ++----------------------------------+ diff --git a/internal/cli/tui/testdata/snapshots/session_menu_open.golden b/internal/cli/tui/testdata/snapshots/session_menu_open.golden new file mode 100644 index 0000000..4126323 --- /dev/null +++ b/internal/cli/tui/testdata/snapshots/session_menu_open.golden @@ -0,0 +1,30 @@ +Session: test-session | Daemon: | Messages: 0 + + + + + + + + + + + + + + + + + + + + + +> > Type a message... ++----------------------------------+ +| Recent sessions | ++----------------------------------+ +| > session-c | +| session-b | +| session-a | ++----------------------------------+ diff --git a/internal/cli/tui/testdata/snapshots/session_menu_open_en.golden b/internal/cli/tui/testdata/snapshots/session_menu_open_en.golden new file mode 100644 index 0000000..bf46d7c --- /dev/null +++ b/internal/cli/tui/testdata/snapshots/session_menu_open_en.golden @@ -0,0 +1,30 @@ +Session: test | Daemon: | Messages: 0 + + + + + + + + + + + + + + + + + + + + + +> > Type a message... ++----------------------------------+ +| Recent sessions | ++----------------------------------+ +| > session-c | +| session-b | +| session-a | ++----------------------------------+ diff --git a/internal/cli/tui/testdata/snapshots/session_menu_open_ko.golden b/internal/cli/tui/testdata/snapshots/session_menu_open_ko.golden new file mode 100644 index 0000000..0055f2a --- /dev/null +++ b/internal/cli/tui/testdata/snapshots/session_menu_open_ko.golden @@ -0,0 +1,30 @@ +세션: test | 데몬: | 메시지: 0 + + + + + + + + + + + + + + + + + + + + + +> > Type a message... ++----------------------------------+ +| 최근 세션 | ++----------------------------------+ +| > session-c | +| session-b | +| session-a | ++----------------------------------+ diff --git a/internal/cli/tui/testdata/snapshots/slash_help_en.golden b/internal/cli/tui/testdata/snapshots/slash_help_en.golden new file mode 100644 index 0000000..37f1c5c --- /dev/null +++ b/internal/cli/tui/testdata/snapshots/slash_help_en.golden @@ -0,0 +1,23 @@ +Session: test | Daemon: | Messages: 1 +system: Conversation commands + /help Show this help + /save Save session + /load Load session + /clear Clear chat history + /quit Exit TUI + /session Show current session name + + + + + + + + + + + + + + +> > Type a message... \ No newline at end of file diff --git a/internal/cli/tui/testdata/snapshots/slash_help_ko.golden b/internal/cli/tui/testdata/snapshots/slash_help_ko.golden new file mode 100644 index 0000000..095f05c --- /dev/null +++ b/internal/cli/tui/testdata/snapshots/slash_help_ko.golden @@ -0,0 +1,23 @@ +세션: test | 데몬: | 메시지: 1 +system: 대화 명령어 + /help Show this help + /save Save session + /load Load session + /clear Clear chat history + /quit Exit TUI + /session Show current session name + + + + + + + + + + + + + + +> > Type a message... \ No newline at end of file diff --git a/internal/cli/tui/testdata/snapshots/statusbar_idle_en.golden b/internal/cli/tui/testdata/snapshots/statusbar_idle_en.golden new file mode 100644 index 0000000..72947f4 --- /dev/null +++ b/internal/cli/tui/testdata/snapshots/statusbar_idle_en.golden @@ -0,0 +1,23 @@ +Session: test | Daemon: | Messages: 0 + + + + + + + + + + + + + + + + + + + + + +> > Type a message... \ No newline at end of file diff --git a/internal/cli/tui/testdata/snapshots/statusbar_idle_ko.golden b/internal/cli/tui/testdata/snapshots/statusbar_idle_ko.golden new file mode 100644 index 0000000..357b011 --- /dev/null +++ b/internal/cli/tui/testdata/snapshots/statusbar_idle_ko.golden @@ -0,0 +1,23 @@ +세션: test | 데몬: | 메시지: 0 + + + + + + + + + + + + + + + + + + + + + +> > Type a message... \ No newline at end of file From 2f29f42a931502891a1abcce07451036b8a29532 Mon Sep 17 00:00:00 2001 From: Goos Kim Date: Tue, 5 May 2026 18:28:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(cli/tui/i18n):=20LoadFrom()=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20+=20CI=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loader.go: LoadFrom(yamlPath) 함수 추가 — CWD 변경 없이 특정 YAML 파일에서 직접 로드 - loader_test.go: os.Chdir 대신 LoadFrom 사용 → t.Parallel() 안전 + CI 격리 보장 이전: os.Chdir(tmpDir) → git rev-parse race 취약 이후: LoadFrom(yamlPath) → CWD 무관, 병렬 안전 SPEC: SPEC-GOOSE-CLI-TUI-003 🗿 MoAI --- internal/cli/tui/i18n/loader.go | 14 +++++ internal/cli/tui/i18n/loader_test.go | 94 +++++++++------------------- 2 files changed, 42 insertions(+), 66 deletions(-) diff --git a/internal/cli/tui/i18n/loader.go b/internal/cli/tui/i18n/loader.go index 224f13b..5e63129 100644 --- a/internal/cli/tui/i18n/loader.go +++ b/internal/cli/tui/i18n/loader.go @@ -45,6 +45,20 @@ func Load() Catalog { return Default() } +// LoadFrom reads the Catalog from a specific YAML file path. +// Avoids CWD manipulation in tests — pass the YAML file path directly. +// Returns Default() (en) when the file is absent, unreadable, or specifies an unknown language. +func LoadFrom(yamlPath string) Catalog { + lang := readLangFromFile(yamlPath) + if lang == "" { + return Default() + } + if cat, ok := Catalogs[lang]; ok { + return cat + } + return Default() +} + // loadLang resolves the conversation_language string from the YAML config. // Returns empty string on any error or missing key. func loadLang() string { diff --git a/internal/cli/tui/i18n/loader_test.go b/internal/cli/tui/i18n/loader_test.go index 5a4c8eb..1cfe315 100644 --- a/internal/cli/tui/i18n/loader_test.go +++ b/internal/cli/tui/i18n/loader_test.go @@ -7,99 +7,61 @@ import ( "testing" ) -// TestI18N_Loader_LoadsKoFromYaml verifies that Load() reads ko catalog when -// language.yaml specifies conversation_language: ko. REQ-CLITUI3-001 -func TestI18N_Loader_LoadsKoFromYaml(t *testing.T) { - t.Parallel() - - // Create a temp dir with a language.yaml file. - dir := t.TempDir() +// writeYAML writes a minimal language.yaml to dir/.moai/config/sections/ and returns the path. +func writeYAML(t *testing.T, dir, lang string) string { + t.Helper() configDir := filepath.Join(dir, ".moai", "config", "sections") if err := os.MkdirAll(configDir, 0o755); err != nil { - t.Fatalf("failed to create config dir: %v", err) + t.Fatalf("MkdirAll: %v", err) } - yamlContent := "language:\n conversation_language: ko\n" yamlPath := filepath.Join(configDir, "language.yaml") - if err := os.WriteFile(yamlPath, []byte(yamlContent), 0o644); err != nil { - t.Fatalf("failed to write language.yaml: %v", err) + content := "language:\n conversation_language: " + lang + "\n" + if err := os.WriteFile(yamlPath, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) } + return yamlPath +} - // Set CWD to the temp dir so Load() finds the yaml. - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get cwd: %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(origDir) - }) - if err := os.Chdir(dir); err != nil { - t.Fatalf("failed to chdir: %v", err) - } +// TestI18N_Loader_LoadsKoFromYaml verifies that LoadFrom() reads ko catalog when +// language.yaml specifies conversation_language: ko. REQ-CLITUI3-001 +func TestI18N_Loader_LoadsKoFromYaml(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + yamlPath := writeYAML(t, dir, "ko") - cat := Load() + cat := LoadFrom(yamlPath) if cat.Lang != "ko" { - t.Errorf("Load().Lang = %q, want %q", cat.Lang, "ko") + t.Errorf("LoadFrom().Lang = %q, want %q", cat.Lang, "ko") } } -// TestI18N_Loader_DefaultsToEnglish verifies that Load() returns en catalog -// when no language.yaml is found. REQ-CLITUI3-001 +// TestI18N_Loader_DefaultsToEnglish verifies that LoadFrom() returns en catalog +// when the YAML file does not exist. REQ-CLITUI3-001 func TestI18N_Loader_DefaultsToEnglish(t *testing.T) { t.Parallel() - // Use a temp dir with no yaml file. - dir := t.TempDir() - - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get cwd: %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(origDir) - }) - if err := os.Chdir(dir); err != nil { - t.Fatalf("failed to chdir: %v", err) - } + absent := filepath.Join(t.TempDir(), "nonexistent.yaml") - cat := Load() + cat := LoadFrom(absent) if cat.Lang != "en" { - t.Errorf("Load().Lang = %q, want %q (default fallback)", cat.Lang, "en") + t.Errorf("LoadFrom(absent).Lang = %q, want %q (default)", cat.Lang, "en") } if cat != Catalogs["en"] { - t.Error("Load() must return en catalog when yaml is absent") + t.Error("LoadFrom(absent) must return en catalog") } } -// TestI18N_Loader_UnknownLangFallback verifies that Load() returns en catalog +// TestI18N_Loader_UnknownLangFallback verifies that LoadFrom() returns en catalog // when the yaml specifies an unknown language code. REQ-CLITUI3-001 func TestI18N_Loader_UnknownLangFallback(t *testing.T) { t.Parallel() dir := t.TempDir() - configDir := filepath.Join(dir, ".moai", "config", "sections") - if err := os.MkdirAll(configDir, 0o755); err != nil { - t.Fatalf("failed to create config dir: %v", err) - } - // "fr" is not in Catalogs. - yamlContent := "language:\n conversation_language: fr\n" - yamlPath := filepath.Join(configDir, "language.yaml") - if err := os.WriteFile(yamlPath, []byte(yamlContent), 0o644); err != nil { - t.Fatalf("failed to write language.yaml: %v", err) - } - - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("failed to get cwd: %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(origDir) - }) - if err := os.Chdir(dir); err != nil { - t.Fatalf("failed to chdir: %v", err) - } + yamlPath := writeYAML(t, dir, "fr") // "fr" is not in Catalogs - cat := Load() + cat := LoadFrom(yamlPath) if cat.Lang != "en" { - t.Errorf("Load().Lang = %q, want %q (fallback for unknown lang)", cat.Lang, "en") + t.Errorf("LoadFrom(fr).Lang = %q, want %q (fallback)", cat.Lang, "en") } }