diff --git a/internal/tui/core/app/input_features.go b/internal/tui/core/app/input_features.go index 07b8c16c..8c56f9fe 100644 --- a/internal/tui/core/app/input_features.go +++ b/internal/tui/core/app/input_features.go @@ -36,6 +36,9 @@ const ( ) var workspaceCommandExecutor = defaultWorkspaceCommandExecutor +var readClipboardImage = tuiinfra.ReadClipboardImage +var saveClipboardImageToTempFile = tuiinfra.SaveImageToTempFile +var detectImageMimeType = tuiinfra.DetectImageMimeType func isWorkspaceCommandInput(input string) bool { return strings.HasPrefix(strings.TrimSpace(input), workspaceCommandPrefix) @@ -296,7 +299,7 @@ func (a *App) addImageAttachment(path string) error { return fmt.Errorf("image size exceeds %d MB limit", imageMaxSizeBytes/(1024*1024)) } - mimeType := tuiinfra.DetectImageMimeType(absPath) + mimeType := detectImageMimeType(absPath) if mimeType == "" { return fmt.Errorf("unsupported image format") } @@ -359,7 +362,7 @@ func (a *App) addImageFromClipboard() error { return fmt.Errorf("maximum %d image attachments allowed", maxImageAttachments) } - data, err := tuiinfra.ReadClipboardImage() + data, err := readClipboardImage() if err != nil { return fmt.Errorf("failed to read clipboard image: %w", err) } @@ -372,12 +375,12 @@ func (a *App) addImageFromClipboard() error { return fmt.Errorf("image size exceeds %d MB limit", imageMaxSizeBytes/(1024*1024)) } - tmpPath, err := tuiinfra.SaveImageToTempFile(data, "paste") + tmpPath, err := saveClipboardImageToTempFile(data, "paste") if err != nil { return fmt.Errorf("failed to save clipboard image: %w", err) } - mimeType := tuiinfra.DetectImageMimeType(tmpPath) + mimeType := detectImageMimeType(tmpPath) if mimeType == "" { return fmt.Errorf("unsupported image format from clipboard") } diff --git a/internal/tui/core/app/input_features_test.go b/internal/tui/core/app/input_features_test.go index 4577dcfa..db32b19a 100644 --- a/internal/tui/core/app/input_features_test.go +++ b/internal/tui/core/app/input_features_test.go @@ -1,6 +1,8 @@ package tui import ( + "context" + "errors" "fmt" "os" "path/filepath" @@ -14,6 +16,15 @@ import ( providertypes "neo-code/internal/provider/types" ) +type snapshotErrProviderService struct { + stubProviderService + err error +} + +func (s snapshotErrProviderService) ListModelsSnapshot(ctx context.Context) ([]providertypes.ModelDescriptor, error) { + return nil, s.err +} + func TestTokenAndReferenceParsing(t *testing.T) { start, end, token, ok := tokenRange(" @file/path", tokenSelectorFirst) if !ok || start != 2 || end != len(" @file/path") || token != "@file/path" { @@ -71,6 +82,34 @@ func TestApplyFileReference(t *testing.T) { } } +func TestApplyFileReferenceBranches(t *testing.T) { + app, _ := newTestApp(t) + if err := app.applyFileReference(" "); err == nil { + t.Fatalf("expected empty file path error") + } + + root := t.TempDir() + app.state.CurrentWorkdir = filepath.Join(root, "workdir") + inside := filepath.Join(app.state.CurrentWorkdir, "a.txt") + outside := filepath.Join(root, "outside.txt") + if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil { + t.Fatalf("mkdir inside: %v", err) + } + if err := os.WriteFile(inside, []byte("a"), 0o644); err != nil { + t.Fatalf("write inside: %v", err) + } + if err := os.WriteFile(outside, []byte("b"), 0o644); err != nil { + t.Fatalf("write outside: %v", err) + } + + if err := app.applyFileReference(outside); err != nil { + t.Fatalf("apply outside reference error: %v", err) + } + if !strings.Contains(app.input.Value(), "@") { + t.Fatalf("expected file reference token to be inserted") + } +} + func TestImageAttachmentLifecycle(t *testing.T) { app, _ := newTestApp(t) root := t.TempDir() @@ -176,6 +215,207 @@ func TestComposeMessageWithImageAttachments(t *testing.T) { } } +func TestComposeMessageWithImageAttachmentsNoAttachments(t *testing.T) { + app, _ := newTestApp(t) + got := app.composeMessageWithImageAttachments(" hello ") + if got != "hello" { + t.Fatalf("expected trimmed content without attachment block, got %q", got) + } +} + +func TestApplyImageReference(t *testing.T) { + app, _ := newTestApp(t) + root := t.TempDir() + imagePath := filepath.Join(root, "ok.png") + if err := os.WriteFile(imagePath, []byte("png"), 0o644); err != nil { + t.Fatalf("write image: %v", err) + } + if err := app.applyImageReference("@image:" + imagePath); err != nil { + t.Fatalf("applyImageReference() error = %v", err) + } + if app.getImageAttachmentCount() != 1 { + t.Fatalf("expected one attachment after applyImageReference") + } + if err := app.applyImageReference("not-an-image-reference"); err == nil { + t.Fatalf("expected invalid image reference error") + } +} + +func TestGetAndClearImageAttachments(t *testing.T) { + app, _ := newTestApp(t) + app.pendingImageAttachments = []pendingImageAttachment{ + {Name: "a.png", Path: "/tmp/a.png", MimeType: "image/png", Size: 1}, + } + if len(app.getImageAttachments()) != 1 { + t.Fatalf("expected one attachment from getter") + } + app.clearImageAttachments() + if len(app.getImageAttachments()) != 0 { + t.Fatalf("expected no attachments after clear") + } +} + +func TestLoadImageAttachmentDataInvalidIndex(t *testing.T) { + app, _ := newTestApp(t) + if _, err := app.loadImageAttachmentData(0); err == nil { + t.Fatalf("expected invalid attachment index error") + } +} + +func TestAddImageFromClipboardUnsupported(t *testing.T) { + app, _ := newTestApp(t) + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected unsupported clipboard image error") + } +} + +func TestAddImageFromClipboardSuccess(t *testing.T) { + app, _ := newTestApp(t) + originalRead := readClipboardImage + originalSave := saveClipboardImageToTempFile + originalDetect := detectImageMimeType + readClipboardImage = func() ([]byte, error) { + return []byte("image-bytes"), nil + } + saveClipboardImageToTempFile = func(data []byte, prefix string) (string, error) { + path := filepath.Join(t.TempDir(), "clipboard.png") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write temp clipboard image: %v", err) + } + return path, nil + } + detectImageMimeType = func(path string) string { return "image/png" } + defer func() { + readClipboardImage = originalRead + saveClipboardImageToTempFile = originalSave + detectImageMimeType = originalDetect + }() + + if err := app.addImageFromClipboard(); err != nil { + t.Fatalf("addImageFromClipboard() error = %v", err) + } + if app.getImageAttachmentCount() != 1 { + t.Fatalf("expected one clipboard image attachment") + } +} + +func TestAddImageFromClipboardBranches(t *testing.T) { + app, _ := newTestApp(t) + originalRead := readClipboardImage + originalSave := saveClipboardImageToTempFile + originalDetect := detectImageMimeType + defer func() { + readClipboardImage = originalRead + saveClipboardImageToTempFile = originalSave + detectImageMimeType = originalDetect + }() + + readClipboardImage = func() ([]byte, error) { return nil, nil } + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected no image in clipboard error") + } + + readClipboardImage = func() ([]byte, error) { return make([]byte, imageMaxSizeBytes+1), nil } + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected image size limit error") + } + + readClipboardImage = func() ([]byte, error) { return []byte("x"), nil } + saveClipboardImageToTempFile = func(data []byte, prefix string) (string, error) { + return filepath.Join(t.TempDir(), "clipboard.bin"), nil + } + detectImageMimeType = func(path string) string { return "" } + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected unsupported image format error") + } + + readClipboardImage = func() ([]byte, error) { return []byte("x"), nil } + saveClipboardImageToTempFile = func(data []byte, prefix string) (string, error) { + return "", errors.New("save failed") + } + if err := app.addImageFromClipboard(); err == nil { + t.Fatalf("expected save failure error") + } +} + +func TestCheckModelImageSupportErrorAndModelNotFound(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = snapshotErrProviderService{ + stubProviderService: stubProviderService{}, + err: errors.New("boom"), + } + if app.checkModelImageSupport() { + t.Fatalf("expected false when provider snapshot fails") + } + if !app.currentModelCapabilities.checked { + t.Fatalf("expected capability cache to be marked checked after failure") + } + + app.currentModelCapabilities = modelCapabilityState{} + app.providerSvc = stubProviderService{ + providers: []configstate.ProviderOption{{ID: app.state.CurrentProvider, Name: app.state.CurrentProvider}}, + models: []providertypes.ModelDescriptor{{ + ID: "other-model", + }}, + } + if app.checkModelImageSupport() { + t.Fatalf("expected false when current model is missing from snapshot") + } +} + +func TestExecuteWorkspaceCommand(t *testing.T) { + app, _ := newTestApp(t) + original := workspaceCommandExecutor + workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { + if command != "echo hi" { + t.Fatalf("unexpected command: %q", command) + } + return "ok", nil + } + defer func() { workspaceCommandExecutor = original }() + + command, output, err := executeWorkspaceCommand(context.Background(), app.configManager, app.state.CurrentWorkdir, "& echo hi") + if err != nil { + t.Fatalf("executeWorkspaceCommand() error = %v", err) + } + if command != "echo hi" || output != "ok" { + t.Fatalf("unexpected execute result command=%q output=%q", command, output) + } + + if _, _, err := executeWorkspaceCommand(context.Background(), app.configManager, app.state.CurrentWorkdir, "& "); err == nil { + t.Fatalf("expected invalid workspace command error") + } +} + +func TestDefaultWorkspaceCommandExecutor(t *testing.T) { + cfg := config.Config{Workdir: t.TempDir(), Shell: "bash", ToolTimeoutSec: 1} + if _, err := defaultWorkspaceCommandExecutor(context.Background(), cfg, cfg.Workdir, ""); err == nil { + t.Fatalf("expected empty command to fail") + } +} + +func TestRunWorkspaceCommandCmd(t *testing.T) { + app, _ := newTestApp(t) + original := workspaceCommandExecutor + workspaceCommandExecutor = func(ctx context.Context, cfg config.Config, workdir string, command string) (string, error) { + return "done", nil + } + defer func() { workspaceCommandExecutor = original }() + + cmd := runWorkspaceCommand(app.configManager, app.state.CurrentWorkdir, "& echo hi") + if cmd == nil { + t.Fatalf("expected workspace command cmd") + } + msg := cmd() + result, ok := msg.(workspaceCommandResultMsg) + if !ok { + t.Fatalf("expected workspaceCommandResultMsg, got %T", msg) + } + if result.Command != "echo hi" || result.Output != "done" || result.Err != nil { + t.Fatalf("unexpected workspace result: %+v", result) + } +} + func TestUpdateSendWithImageAttachmentsComposesRuntimeInput(t *testing.T) { app, runtime := newTestApp(t) root := t.TempDir() diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index feae8187..c6daa2dd 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -983,6 +983,190 @@ func TestRuntimeEventRunContextHandler(t *testing.T) { } } +func TestRuntimeEventRunContextHandlerInvalidatesModelCapabilityCache(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentProvider = "provider-a" + app.state.CurrentModel = "model-a" + app.currentModelCapabilities = modelCapabilityState{ + checked: true, + supportsImageInput: true, + } + + payload := tuiservices.RuntimeRunContextPayload{ + Provider: "provider-b", + Model: "model-b", + } + _ = runtimeEventRunContextHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if app.currentModelCapabilities.checked { + t.Fatalf("expected capability cache to be invalidated when provider/model changes") + } +} + +func TestSyncConfigStateInvalidatesModelCapabilityCache(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentProvider = "provider-a" + app.state.CurrentModel = "model-a" + app.currentModelCapabilities = modelCapabilityState{ + checked: true, + supportsImageInput: true, + } + + app.syncConfigState(config.Config{ + SelectedProvider: "provider-b", + CurrentModel: "model-b", + Workdir: app.state.CurrentWorkdir, + }) + if app.currentModelCapabilities.checked { + t.Fatalf("expected capability cache to be invalidated") + } +} + +func TestUpdatePasteImageShortcutFailure(t *testing.T) { + app, _ := newTestApp(t) + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyCtrlV}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if !strings.Contains(strings.ToLower(app.state.StatusText), "clipboard") { + t.Fatalf("expected clipboard failure status, got %q", app.state.StatusText) + } +} + +func TestUpdateEnterSessionOpensSessionPicker(t *testing.T) { + app, runtime := newTestApp(t) + runtime.listSessions = []agentsession.Summary{ + {ID: "s1", Title: "Session 1", UpdatedAt: time.Now()}, + } + app.input.SetValue("/session") + app.state.InputText = "/session" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.state.ActivePicker != pickerSession { + t.Fatalf("expected session picker to open") + } + if app.state.StatusText != statusChooseSession { + t.Fatalf("expected status %q, got %q", statusChooseSession, app.state.StatusText) + } +} + +func TestUpdateEnterImageReferencePath(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("@image:/path/does-not-exist.png") + app.state.InputText = "@image:/path/does-not-exist.png" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.input.Value() != "" { + t.Fatalf("expected input to be reset after image reference handling") + } + if strings.TrimSpace(app.state.StatusText) == "" { + t.Fatalf("expected status text to reflect image reference failure") + } +} + +func TestUpdateSendWithUnsupportedImageInput(t *testing.T) { + app, _ := newTestApp(t) + app.pendingImageAttachments = []pendingImageAttachment{ + {Name: "a.png", MimeType: "image/png", Path: "/tmp/a.png", Size: 1}, + } + app.providerSvc = stubProviderService{ + providers: []configstate.ProviderOption{{ID: app.state.CurrentProvider, Name: app.state.CurrentProvider}}, + models: []providertypes.ModelDescriptor{{ + ID: app.state.CurrentModel, + Name: app.state.CurrentModel, + CapabilityHints: providertypes.ModelCapabilityHints{ + ImageInput: providertypes.ModelCapabilityStateUnsupported, + }, + }}, + } + app.input.SetValue("hello") + app.state.InputText = "hello" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.state.IsAgentRunning { + t.Fatalf("expected send to be blocked for unsupported model image input") + } + if app.hasImageAttachments() { + t.Fatalf("expected pending image attachments to be cleared on unsupported model") + } + if app.state.StatusText != "Model does not support images" { + t.Fatalf("unexpected status text: %q", app.state.StatusText) + } +} + +func TestUpdatePickerSessionEnterActivatesSelectedSession(t *testing.T) { + app, runtime := newTestApp(t) + now := time.Now() + runtime.listSessions = []agentsession.Summary{ + {ID: "s1", Title: "One", UpdatedAt: now.Add(-time.Minute)}, + {ID: "s2", Title: "Two", UpdatedAt: now}, + } + runtime.loadSessions = map[string]agentsession.Session{ + "s2": { + ID: "s2", + Title: "Two", + Workdir: app.state.CurrentWorkdir, + Messages: []providertypes.Message{ + {Role: roleUser, Content: "hello"}, + }, + }, + } + if err := app.refreshSessionPicker(); err != nil { + t.Fatalf("refreshSessionPicker() error = %v", err) + } + app.openPicker(pickerSession, statusChooseSession, &app.sessionPicker, "") + app.sessionPicker.Select(1) + + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd != nil { + _ = cmd() + } + app = model.(App) + if app.state.ActiveSessionID != "s2" || app.state.ActiveSessionTitle != "Two" { + t.Fatalf("expected selected session to be activated, got id=%q title=%q", app.state.ActiveSessionID, app.state.ActiveSessionTitle) + } + if len(app.activeMessages) != 1 { + t.Fatalf("expected messages to refresh from selected session") + } +} + +func TestActivateSessionByIDNotFound(t *testing.T) { + app, _ := newTestApp(t) + app.state.Sessions = []agentsession.Summary{{ID: "s1", Title: "one"}} + if err := app.activateSessionByID("missing"); err == nil { + t.Fatalf("expected session not found error") + } +} + +func TestHandleImmediateSlashCommandSession(t *testing.T) { + app, runtime := newTestApp(t) + runtime.listSessions = []agentsession.Summary{ + {ID: "s1", Title: "Session 1", UpdatedAt: time.Now()}, + } + handled, cmd := app.handleImmediateSlashCommand("/session") + if !handled { + t.Fatalf("expected /session to be handled immediately") + } + if cmd != nil { + _ = cmd() + } + if app.state.ActivePicker != pickerSession { + t.Fatalf("expected session picker opened by immediate slash command") + } +} + func TestRuntimeEventToolStatusHandler(t *testing.T) { app, _ := newTestApp(t) payload := tuiservices.RuntimeToolStatusPayload{ToolCallID: "tool-1", ToolName: "bash", Status: string(tuistate.ToolLifecyclePlanned)} diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 8ff9eeca..0c807d5a 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -50,6 +50,23 @@ func TestRenderPickerSessionMode(t *testing.T) { } } +func TestRenderPickerProviderAndFileMode(t *testing.T) { + app, _ := newTestApp(t) + + app.state.ActivePicker = pickerProvider + app.providerPicker.SetItems([]list.Item{selectionItem{id: "p1", name: "Provider 1"}}) + providerView := app.renderPicker(48, 14) + if !strings.Contains(providerView, providerPickerTitle) { + t.Fatalf("expected provider picker title") + } + + app.state.ActivePicker = pickerFile + fileView := app.renderPicker(48, 14) + if !strings.Contains(fileView, filePickerTitle) { + t.Fatalf("expected file picker title") + } +} + func TestBuildPickerLayoutExpandsPopupSpace(t *testing.T) { app, _ := newTestApp(t) @@ -78,6 +95,18 @@ func TestRenderWaterfallUsesDynamicTranscriptHeight(t *testing.T) { } } +func TestRenderWaterfallThinkingState(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerNone + app.state.IsAgentRunning = true + app.state.StatusText = statusThinking + + view := app.renderWaterfall(80, 24) + if !strings.Contains(view, "Thinking...") { + t.Fatalf("expected thinking hint in waterfall view") + } +} + func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing.T) { app, _ := newTestApp(t) app.width = 100 @@ -169,3 +198,39 @@ func TestRenderUserMessageKeepsTagAndBodyRightAligned(t *testing.T) { t.Fatalf("expected user tag and body right edges to match, got tag=%d body=%d\n%q\n%q", tagRightEdge, bodyRightEdge, tagLine, contentLine) } } + +func TestBuildPickerLayoutClampMin(t *testing.T) { + app, _ := newTestApp(t) + got := app.buildPickerLayout(10, 8) + if got.panelWidth != pickerPanelMinWidth { + t.Fatalf("expected panel width clamp to min %d, got %d", pickerPanelMinWidth, got.panelWidth) + } + if got.panelHeight != pickerPanelMinHeight { + t.Fatalf("expected panel height clamp to min %d, got %d", pickerPanelMinHeight, got.panelHeight) + } +} + +func TestRenderWaterfallWithActivePicker(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerSession + app.sessionPicker.SetItems([]list.Item{ + sessionItem{Summary: agentsession.Summary{ + ID: "session-1", + Title: "Session One", + UpdatedAt: time.Now(), + }}, + }) + + view := app.renderWaterfall(90, 24) + if !strings.Contains(view, sessionPickerTitle) { + t.Fatalf("expected picker waterfall view to include session picker title") + } +} + +func TestRenderBody(t *testing.T) { + app, _ := newTestApp(t) + out := app.renderBody(layout{contentWidth: 90, contentHeight: 24}) + if strings.TrimSpace(out) == "" { + t.Fatalf("expected renderBody output") + } +} diff --git a/internal/tui/infra/clipboard_common.go b/internal/tui/infra/clipboard_common.go index 3a2d4615..3079eb72 100644 --- a/internal/tui/infra/clipboard_common.go +++ b/internal/tui/infra/clipboard_common.go @@ -14,13 +14,8 @@ func SaveImageToTempFile(data []byte, prefix string) (string, error) { return "", err } tmpFile := f.Name() - - if _, err = f.Write(data); err != nil { - _ = f.Close() - _ = os.Remove(tmpFile) - return "", err - } - if err = f.Close(); err != nil { + _ = f.Close() + if err = os.WriteFile(tmpFile, data, 0o600); err != nil { _ = os.Remove(tmpFile) return "", err } diff --git a/internal/tui/infra/infra_test.go b/internal/tui/infra/infra_test.go index 25772a39..9e39bb45 100644 --- a/internal/tui/infra/infra_test.go +++ b/internal/tui/infra/infra_test.go @@ -360,3 +360,168 @@ func workspaceExecutorCommands() (shell string, success string, noOutput string, "echo failed 1>&2; exit 2", "sleep 2" } + +func TestSanitizeTempPrefix(t *testing.T) { + if got := sanitizeTempPrefix(""); got != "" { + t.Fatalf("expected empty prefix to remain empty, got %q", got) + } + if got := sanitizeTempPrefix("p@st/e_1-2"); got != "pste_1-2" { + t.Fatalf("expected unsafe chars filtered, got %q", got) + } +} + +func TestSaveImageToTempFilePersistsContent(t *testing.T) { + data := []byte("image-bytes") + path, err := SaveImageToTempFile(data, "p@st/e") + if err != nil { + t.Fatalf("SaveImageToTempFile() error = %v", err) + } + defer os.Remove(path) + + if !strings.Contains(filepath.Base(path), "pste-") { + t.Fatalf("expected sanitized prefix in temp file name, got %q", filepath.Base(path)) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read temp file: %v", err) + } + if string(got) != string(data) { + t.Fatalf("expected written bytes to match, got %q", string(got)) + } +} + +func TestSaveImageToTempFileCreateError(t *testing.T) { + t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing-dir")) + if _, err := SaveImageToTempFile([]byte("x"), "paste"); err == nil { + t.Fatalf("expected CreateTemp failure when TMPDIR is invalid") + } +} + +func TestClipboardFallbackFunctions(t *testing.T) { + text, err := ReadClipboardText() + if err == nil && strings.TrimSpace(text) == "" { + t.Fatalf("expected clipboard text or an error, got empty success result") + } + data, err := ReadClipboardImage() + if err != errClipboardImageUnsupported { + t.Fatalf("expected unsupported image error, got %v", err) + } + if data != nil { + t.Fatalf("expected nil image data on unsupported platform") + } +} + +func TestImageInfoAndRead(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "sample.jpg") + content := []byte{0xFF, 0xD8, 0xFF, 0x00} + if err := os.WriteFile(path, content, 0o644); err != nil { + t.Fatalf("write image: %v", err) + } + + info, err := GetFileInfo(path) + if err != nil { + t.Fatalf("GetFileInfo() error = %v", err) + } + if info.Size() != int64(len(content)) { + t.Fatalf("expected size %d, got %d", len(content), info.Size()) + } + read, err := ReadImageFile(path) + if err != nil { + t.Fatalf("ReadImageFile() error = %v", err) + } + if string(read) != string(content) { + t.Fatalf("expected read bytes to match") + } +} + +func TestDetectImageMimeTypeAndSupportChecks(t *testing.T) { + root := t.TempDir() + pngPath := filepath.Join(root, "x.png") + if err := os.WriteFile(pngPath, []byte("png"), 0o644); err != nil { + t.Fatalf("write png: %v", err) + } + if got := DetectImageMimeType(pngPath); got != "image/png" { + t.Fatalf("expected png by extension, got %q", got) + } + + jpgPath := filepath.Join(root, "x.JPG") + if err := os.WriteFile(jpgPath, []byte("jpg"), 0o644); err != nil { + t.Fatalf("write jpg: %v", err) + } + if got := DetectImageMimeType(jpgPath); got != "image/jpeg" { + t.Fatalf("expected jpeg by extension, got %q", got) + } + if !IsSupportedImageFormat(jpgPath) { + t.Fatalf("expected jpeg to be supported") + } + + txtPath := filepath.Join(root, "x.txt") + if err := os.WriteFile(txtPath, []byte("text"), 0o644); err != nil { + t.Fatalf("write txt: %v", err) + } + if got := DetectImageMimeType(txtPath); got == "" { + t.Fatalf("expected extension-based mime to be detected for txt") + } + if IsSupportedImageFormat(txtPath) { + t.Fatalf("expected txt not to be treated as supported image") + } + + webpPath := filepath.Join(root, "x.webp") + if err := os.WriteFile(webpPath, []byte("webp"), 0o644); err != nil { + t.Fatalf("write webp: %v", err) + } + if got := DetectImageMimeType(webpPath); got != "image/webp" { + t.Fatalf("expected webp by extension, got %q", got) + } + + gifPath := filepath.Join(root, "x.bin") + gifBytes := []byte("GIF89a........") + if err := os.WriteFile(gifPath, gifBytes, 0o644); err != nil { + t.Fatalf("write gif magic: %v", err) + } + if got := DetectImageMimeType(gifPath); got != "image/gif" { + t.Fatalf("expected gif by magic header, got %q", got) + } + + jpegMagicPath := filepath.Join(root, "jpeg-magic.bin") + if err := os.WriteFile(jpegMagicPath, []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46}, 0o644); err != nil { + t.Fatalf("write jpeg magic: %v", err) + } + if got := DetectImageMimeType(jpegMagicPath); got != "image/jpeg" { + t.Fatalf("expected jpeg by magic header, got %q", got) + } + + webpMagicPath := filepath.Join(root, "webp-magic.bin") + webpMagic := append([]byte("RIFF"), []byte{0, 0, 0, 0}...) + webpMagic = append(webpMagic, []byte("WEBP")...) + if err := os.WriteFile(webpMagicPath, webpMagic, 0o644); err != nil { + t.Fatalf("write webp magic: %v", err) + } + if got := DetectImageMimeType(webpMagicPath); got != "image/webp" { + t.Fatalf("expected webp by magic header, got %q", got) + } + + missingPath := filepath.Join(root, "missing.unknown") + if got := DetectImageMimeType(missingPath); got != "" { + t.Fatalf("expected empty mime for missing unknown file, got %q", got) + } +} + +func TestReadMagicHeaderErrorsAndShortRead(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "short.bin") + if err := os.WriteFile(path, []byte{1, 2, 3}, 0o644); err != nil { + t.Fatalf("write short file: %v", err) + } + buf, err := readMagicHeader(path, 8) + if err != nil { + t.Fatalf("readMagicHeader(short) error = %v", err) + } + if len(buf) != 3 { + t.Fatalf("expected short read length 3, got %d", len(buf)) + } + if _, err := readMagicHeader(filepath.Join(root, "missing.bin"), 8); err == nil { + t.Fatalf("expected missing file error") + } +}