provider/openai: - Fix doubled tool call args (argsComplete flag): Ollama sends complete args in the first streaming chunk then repeats them as delta, causing doubled JSON and 400 errors in elfs - Handle fs: prefix (gemma4 uses fs:grep instead of fs.grep) - Add Reasoning field support for Ollama thinking output cmd/gnoma: - Early TTY detection so logger is created with correct destination before any component gets a reference to it (fixes slog WARN bleed into TUI textarea) permission: - Exempt spawn_elfs and agent tools from safety scanner: elf prompt text may legitimately mention .env/.ssh/credentials patterns and should not be blocked tui/app: - /init retry chain: no-tool-calls → spawn_elfs nudge → write nudge (ask for plain text output) → TUI fallback write from streamBuf - looksLikeAgentsMD + extractMarkdownDoc: validate and clean fallback content before writing (reject refusals, strip narrative preambles) - Collapse thinking output to 3 lines; ctrl+o to expand (live stream and committed messages) - Stream-level filter for model pseudo-tool-call blocks: suppresses <<tool_code>>...</tool_code>> and <<function_call>>...<tool_call|> from entering streamBuf across chunk boundaries - sanitizeAssistantText regex covers both block formats - Reset streamFilterClose at every turn start
602 lines
16 KiB
Go
602 lines
16 KiB
Go
package fs
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// --- Read ---
|
|
|
|
func TestReadTool_Interface(t *testing.T) {
|
|
r := NewReadTool()
|
|
if r.Name() != "fs.read" {
|
|
t.Errorf("Name() = %q", r.Name())
|
|
}
|
|
if !r.IsReadOnly() {
|
|
t.Error("should be read-only")
|
|
}
|
|
if r.IsDestructive() {
|
|
t.Error("should not be destructive")
|
|
}
|
|
}
|
|
|
|
func TestReadTool_SimpleFile(t *testing.T) {
|
|
path := writeTestFile(t, "hello\nworld\n")
|
|
r := NewReadTool()
|
|
|
|
result, err := r.Execute(context.Background(), mustJSON(t, readArgs{Path: path}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "1\thello") {
|
|
t.Errorf("Output should contain line-numbered content, got %q", result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "2\tworld") {
|
|
t.Errorf("Output missing line 2, got %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestReadTool_WithOffset(t *testing.T) {
|
|
path := writeTestFile(t, "line1\nline2\nline3\nline4\nline5\n")
|
|
r := NewReadTool()
|
|
|
|
result, err := r.Execute(context.Background(), mustJSON(t, readArgs{Path: path, Offset: 2}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "3\tline3") {
|
|
t.Errorf("Output should start at line 3, got %q", result.Output)
|
|
}
|
|
if strings.Contains(result.Output, "1\tline1") {
|
|
t.Error("Output should not contain line 1")
|
|
}
|
|
}
|
|
|
|
func TestReadTool_WithLimit(t *testing.T) {
|
|
path := writeTestFile(t, "a\nb\nc\nd\ne\n")
|
|
r := NewReadTool()
|
|
|
|
result, err := r.Execute(context.Background(), mustJSON(t, readArgs{Path: path, Limit: 2}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
lines := strings.Split(result.Output, "\n")
|
|
if len(lines) != 2 {
|
|
t.Errorf("expected 2 lines, got %d: %q", len(lines), result.Output)
|
|
}
|
|
if result.Metadata["truncated"] != true {
|
|
t.Error("should be truncated")
|
|
}
|
|
}
|
|
|
|
func TestReadTool_OffsetPastEnd(t *testing.T) {
|
|
path := writeTestFile(t, "one\ntwo\n")
|
|
r := NewReadTool()
|
|
|
|
result, err := r.Execute(context.Background(), mustJSON(t, readArgs{Path: path, Offset: 100}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "past end") {
|
|
t.Errorf("Output = %q, should mention past end", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestReadTool_FileNotFound(t *testing.T) {
|
|
r := NewReadTool()
|
|
result, err := r.Execute(context.Background(), mustJSON(t, readArgs{Path: "/nonexistent/file.txt"}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "Error") {
|
|
t.Errorf("Output = %q, should contain error", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestReadTool_EmptyPath(t *testing.T) {
|
|
r := NewReadTool()
|
|
_, err := r.Execute(context.Background(), mustJSON(t, readArgs{}))
|
|
if err == nil {
|
|
t.Error("expected error for empty path")
|
|
}
|
|
}
|
|
|
|
// --- Write ---
|
|
|
|
func TestWriteTool_Interface(t *testing.T) {
|
|
w := NewWriteTool()
|
|
if w.Name() != "fs.write" {
|
|
t.Errorf("Name() = %q", w.Name())
|
|
}
|
|
if w.IsReadOnly() {
|
|
t.Error("should not be read-only")
|
|
}
|
|
}
|
|
|
|
func TestWriteTool_CreateFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.txt")
|
|
|
|
w := NewWriteTool()
|
|
result, err := w.Execute(context.Background(), mustJSON(t, writeArgs{Path: path, Content: "hello world"}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "11 bytes") {
|
|
t.Errorf("Output = %q", result.Output)
|
|
}
|
|
|
|
data, _ := os.ReadFile(path)
|
|
if string(data) != "hello world" {
|
|
t.Errorf("file content = %q", string(data))
|
|
}
|
|
}
|
|
|
|
func TestWriteTool_CreatesParentDirs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "a", "b", "c", "test.txt")
|
|
|
|
w := NewWriteTool()
|
|
_, err := w.Execute(context.Background(), mustJSON(t, writeArgs{Path: path, Content: "nested"}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
|
|
data, _ := os.ReadFile(path)
|
|
if string(data) != "nested" {
|
|
t.Errorf("file content = %q", string(data))
|
|
}
|
|
}
|
|
|
|
func TestWriteTool_OverwriteExisting(t *testing.T) {
|
|
path := writeTestFile(t, "old content")
|
|
w := NewWriteTool()
|
|
|
|
_, err := w.Execute(context.Background(), mustJSON(t, writeArgs{Path: path, Content: "new content"}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
|
|
data, _ := os.ReadFile(path)
|
|
if string(data) != "new content" {
|
|
t.Errorf("file content = %q", string(data))
|
|
}
|
|
}
|
|
|
|
// --- Edit ---
|
|
|
|
func TestEditTool_Interface(t *testing.T) {
|
|
e := NewEditTool()
|
|
if e.Name() != "fs.edit" {
|
|
t.Errorf("Name() = %q", e.Name())
|
|
}
|
|
}
|
|
|
|
func TestEditTool_SingleReplace(t *testing.T) {
|
|
path := writeTestFile(t, "hello world")
|
|
e := NewEditTool()
|
|
|
|
result, err := e.Execute(context.Background(), mustJSON(t, editArgs{
|
|
Path: path, OldString: "world", NewString: "gnoma",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "Edit(") && !strings.Contains(result.Output, "Replaced") {
|
|
t.Errorf("Output = %q", result.Output)
|
|
}
|
|
|
|
data, _ := os.ReadFile(path)
|
|
if string(data) != "hello gnoma" {
|
|
t.Errorf("file content = %q", string(data))
|
|
}
|
|
}
|
|
|
|
func TestEditTool_ReplaceAll(t *testing.T) {
|
|
path := writeTestFile(t, "foo bar foo baz foo")
|
|
e := NewEditTool()
|
|
|
|
result, err := e.Execute(context.Background(), mustJSON(t, editArgs{
|
|
Path: path, OldString: "foo", NewString: "qux", ReplaceAll: true,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "Edit(") && !strings.Contains(result.Output, "3 occurrence") {
|
|
t.Errorf("Output = %q", result.Output)
|
|
}
|
|
|
|
data, _ := os.ReadFile(path)
|
|
if string(data) != "qux bar qux baz qux" {
|
|
t.Errorf("file content = %q", string(data))
|
|
}
|
|
}
|
|
|
|
func TestEditTool_NonUniqueWithoutReplaceAll(t *testing.T) {
|
|
path := writeTestFile(t, "foo foo foo")
|
|
e := NewEditTool()
|
|
|
|
result, err := e.Execute(context.Background(), mustJSON(t, editArgs{
|
|
Path: path, OldString: "foo", NewString: "bar",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "3 matches") {
|
|
t.Errorf("Output = %q, should mention multiple matches", result.Output)
|
|
}
|
|
|
|
// File should be unchanged
|
|
data, _ := os.ReadFile(path)
|
|
if string(data) != "foo foo foo" {
|
|
t.Errorf("file should be unchanged, got %q", string(data))
|
|
}
|
|
}
|
|
|
|
func TestEditTool_NotFound(t *testing.T) {
|
|
path := writeTestFile(t, "hello world")
|
|
e := NewEditTool()
|
|
|
|
result, err := e.Execute(context.Background(), mustJSON(t, editArgs{
|
|
Path: path, OldString: "missing", NewString: "replaced",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "not found") {
|
|
t.Errorf("Output = %q, should mention not found", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestEditTool_SameStrings(t *testing.T) {
|
|
e := NewEditTool()
|
|
_, err := e.Execute(context.Background(), mustJSON(t, editArgs{
|
|
Path: "/tmp/x", OldString: "same", NewString: "same",
|
|
}))
|
|
if err == nil {
|
|
t.Error("expected error when old_string == new_string")
|
|
}
|
|
}
|
|
|
|
// --- Glob ---
|
|
|
|
func TestGlobTool_Interface(t *testing.T) {
|
|
g := NewGlobTool()
|
|
if g.Name() != "fs.glob" {
|
|
t.Errorf("Name() = %q", g.Name())
|
|
}
|
|
if !g.IsReadOnly() {
|
|
t.Error("should be read-only")
|
|
}
|
|
}
|
|
|
|
func TestGlobTool_MatchFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "test.go"), []byte("package main"), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# readme"), 0o644)
|
|
|
|
g := NewGlobTool()
|
|
result, err := g.Execute(context.Background(), mustJSON(t, globArgs{Pattern: "*.go", Path: dir}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
|
|
if result.Metadata["count"] != 2 {
|
|
t.Errorf("count = %v, want 2", result.Metadata["count"])
|
|
}
|
|
if !strings.Contains(result.Output, "main.go") {
|
|
t.Errorf("Output missing main.go: %q", result.Output)
|
|
}
|
|
if strings.Contains(result.Output, "readme.md") {
|
|
t.Error("Output should not contain readme.md")
|
|
}
|
|
}
|
|
|
|
func TestGlobTool_NoMatches(t *testing.T) {
|
|
dir := t.TempDir()
|
|
g := NewGlobTool()
|
|
|
|
result, err := g.Execute(context.Background(), mustJSON(t, globArgs{Pattern: "*.xyz", Path: dir}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "no matches") {
|
|
t.Errorf("Output = %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestGlobTool_Doublestar(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.MkdirAll(filepath.Join(dir, "internal", "foo"), 0o755)
|
|
os.MkdirAll(filepath.Join(dir, "cmd", "bar"), 0o755)
|
|
os.WriteFile(filepath.Join(dir, "main.go"), []byte(""), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "internal", "foo", "foo.go"), []byte(""), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "cmd", "bar", "bar.go"), []byte(""), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "cmd", "bar", "bar_test.go"), []byte(""), 0o644)
|
|
|
|
g := NewGlobTool()
|
|
|
|
tests := []struct {
|
|
pattern string
|
|
want int
|
|
}{
|
|
{"**/*.go", 4},
|
|
{"**/*_test.go", 1},
|
|
{"internal/**/*.go", 1},
|
|
{"cmd/**/*.go", 2},
|
|
{"*.go", 1}, // only root-level, no ** — existing behaviour unchanged
|
|
}
|
|
for _, tc := range tests {
|
|
result, err := g.Execute(context.Background(), mustJSON(t, globArgs{Pattern: tc.pattern, Path: dir}))
|
|
if err != nil {
|
|
t.Fatalf("pattern %q: Execute: %v", tc.pattern, err)
|
|
}
|
|
if result.Metadata["count"] != tc.want {
|
|
t.Errorf("pattern %q: count = %v, want %d\noutput:\n%s", tc.pattern, result.Metadata["count"], tc.want, result.Output)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatchGlob_DoublestarEdgeCases(t *testing.T) {
|
|
tests := []struct {
|
|
pattern string
|
|
name string
|
|
want bool
|
|
}{
|
|
{"**/*.go", "main.go", true},
|
|
{"**/*.go", "internal/foo/foo.go", true},
|
|
{"**/*.go", "a/b/c/d.go", true},
|
|
{"**/*.go", "main.ts", false},
|
|
{"internal/**/*.go", "internal/foo/bar.go", true},
|
|
{"internal/**/*.go", "cmd/foo/bar.go", false},
|
|
{"**", "anything/goes", true},
|
|
{"*.go", "main.go", true},
|
|
{"*.go", "sub/main.go", false}, // no ** — single level only
|
|
}
|
|
for _, tc := range tests {
|
|
got := matchGlob(tc.pattern, tc.name)
|
|
if got != tc.want {
|
|
t.Errorf("matchGlob(%q, %q) = %v, want %v", tc.pattern, tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Grep ---
|
|
|
|
func TestGrepTool_Interface(t *testing.T) {
|
|
g := NewGrepTool()
|
|
if g.Name() != "fs.grep" {
|
|
t.Errorf("Name() = %q", g.Name())
|
|
}
|
|
if !g.IsReadOnly() {
|
|
t.Error("should be read-only")
|
|
}
|
|
}
|
|
|
|
func TestGrepTool_SingleFile(t *testing.T) {
|
|
path := writeTestFile(t, "hello world\nfoo bar\nhello again\n")
|
|
g := NewGrepTool()
|
|
|
|
result, err := g.Execute(context.Background(), mustJSON(t, grepArgs{Pattern: "hello", Path: path}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if result.Metadata["count"] != 2 {
|
|
t.Errorf("count = %v, want 2", result.Metadata["count"])
|
|
}
|
|
if !strings.Contains(result.Output, "1:hello world") {
|
|
t.Errorf("Output = %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestGrepTool_Directory(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "a.go"), []byte("func main() {}\nfunc helper() {}"), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "b.go"), []byte("func test() {}"), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "c.txt"), []byte("func ignored() {}"), 0o644)
|
|
|
|
g := NewGrepTool()
|
|
|
|
// Search all files for "func"
|
|
result, err := g.Execute(context.Background(), mustJSON(t, grepArgs{Pattern: "func", Path: dir}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if result.Metadata["count"].(int) < 3 {
|
|
t.Errorf("count = %v, want >= 3", result.Metadata["count"])
|
|
}
|
|
|
|
// With glob filter
|
|
result, err = g.Execute(context.Background(), mustJSON(t, grepArgs{Pattern: "func", Path: dir, Glob: "*.go"}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if strings.Contains(result.Output, "c.txt") {
|
|
t.Error("should not match .txt files with *.go glob")
|
|
}
|
|
}
|
|
|
|
func TestGrepTool_Regex(t *testing.T) {
|
|
path := writeTestFile(t, "error: something failed\nwarning: be careful\nerror: another one\n")
|
|
g := NewGrepTool()
|
|
|
|
result, err := g.Execute(context.Background(), mustJSON(t, grepArgs{Pattern: `^error:`, Path: path}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if result.Metadata["count"] != 2 {
|
|
t.Errorf("count = %v, want 2", result.Metadata["count"])
|
|
}
|
|
}
|
|
|
|
func TestGrepTool_InvalidRegex(t *testing.T) {
|
|
g := NewGrepTool()
|
|
result, err := g.Execute(context.Background(), mustJSON(t, grepArgs{Pattern: "[invalid", Path: "."}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "Invalid regex") {
|
|
t.Errorf("Output = %q, should mention invalid regex", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestGrepTool_NoMatches(t *testing.T) {
|
|
path := writeTestFile(t, "hello world\n")
|
|
g := NewGrepTool()
|
|
|
|
result, err := g.Execute(context.Background(), mustJSON(t, grepArgs{Pattern: "zzzzz", Path: path}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "no matches") {
|
|
t.Errorf("Output = %q", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestGrepTool_MaxResults(t *testing.T) {
|
|
var lines strings.Builder
|
|
for i := 0; i < 100; i++ {
|
|
lines.WriteString("match line\n")
|
|
}
|
|
path := writeTestFile(t, lines.String())
|
|
g := NewGrepTool()
|
|
|
|
result, err := g.Execute(context.Background(), mustJSON(t, grepArgs{Pattern: "match", Path: path, MaxResults: 5}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if result.Metadata["count"] != 5 {
|
|
t.Errorf("count = %v, want 5", result.Metadata["count"])
|
|
}
|
|
if result.Metadata["truncated"] != true {
|
|
t.Error("should be truncated")
|
|
}
|
|
}
|
|
|
|
// --- LS ---
|
|
|
|
func TestLSTool_Interface(t *testing.T) {
|
|
l := NewLSTool()
|
|
if l.Name() != "fs.ls" {
|
|
t.Errorf("Name() = %q", l.Name())
|
|
}
|
|
if !l.IsReadOnly() {
|
|
t.Error("should be read-only")
|
|
}
|
|
}
|
|
|
|
func TestLSTool_ListDirectory(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "hello.go"), []byte("package main"), 0o644)
|
|
os.WriteFile(filepath.Join(dir, "readme.md"), []byte("# readme"), 0o644)
|
|
os.MkdirAll(filepath.Join(dir, "subdir"), 0o755)
|
|
|
|
l := NewLSTool()
|
|
result, err := l.Execute(context.Background(), mustJSON(t, lsArgs{Path: dir}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(result.Output, "hello.go") {
|
|
t.Errorf("Output missing hello.go: %q", result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "readme.md") {
|
|
t.Errorf("Output missing readme.md: %q", result.Output)
|
|
}
|
|
if !strings.Contains(result.Output, "subdir") {
|
|
t.Errorf("Output missing subdir: %q", result.Output)
|
|
}
|
|
|
|
if result.Metadata["files"] != 2 {
|
|
t.Errorf("files = %v, want 2", result.Metadata["files"])
|
|
}
|
|
if result.Metadata["dirs"] != 1 {
|
|
t.Errorf("dirs = %v, want 1", result.Metadata["dirs"])
|
|
}
|
|
}
|
|
|
|
func TestLSTool_EmptyDirectory(t *testing.T) {
|
|
dir := t.TempDir()
|
|
l := NewLSTool()
|
|
|
|
result, err := l.Execute(context.Background(), mustJSON(t, lsArgs{Path: dir}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "empty directory") {
|
|
t.Errorf("Output = %q, should mention empty", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestLSTool_DirectoryNotFound(t *testing.T) {
|
|
l := NewLSTool()
|
|
result, err := l.Execute(context.Background(), mustJSON(t, lsArgs{Path: "/nonexistent/dir"}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
if !strings.Contains(result.Output, "Error") {
|
|
t.Errorf("Output = %q, should contain error", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestLSTool_ShowsSizes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "small.txt"), []byte("hi"), 0o644)
|
|
|
|
l := NewLSTool()
|
|
result, err := l.Execute(context.Background(), mustJSON(t, lsArgs{Path: dir}))
|
|
if err != nil {
|
|
t.Fatalf("Execute: %v", err)
|
|
}
|
|
// Should show "2B" for a 2-byte file
|
|
if !strings.Contains(result.Output, "2B") {
|
|
t.Errorf("Output = %q, should show file size", result.Output)
|
|
}
|
|
}
|
|
|
|
func TestFormatSize(t *testing.T) {
|
|
tests := []struct {
|
|
bytes int64
|
|
want string
|
|
}{
|
|
{0, "0B"},
|
|
{42, "42B"},
|
|
{1024, "1.0K"},
|
|
{1536, "1.5K"},
|
|
{1048576, "1.0M"},
|
|
{1073741824, "1.0G"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := formatSize(tt.bytes)
|
|
if got != tt.want {
|
|
t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func writeTestFile(t *testing.T, content string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.txt")
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("writeTestFile: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func mustJSON(t *testing.T, v any) json.RawMessage {
|
|
t.Helper()
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal: %v", err)
|
|
}
|
|
return data
|
|
}
|