From 17aa8abcf3336a280adadb515771abf04a17423d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 16 Jan 2025 07:34:21 -0500 Subject: [PATCH] feat(ansi): kitty: add graphics tests (#326) --- ansi/graphics_test.go | 278 +++++++++++++++++++++++++++ ansi/kitty/decoder_test.go | 252 ++++++++++++++++++++++++ ansi/kitty/encoder_test.go | 242 +++++++++++++++++++++++ ansi/kitty/options_test.go | 384 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1156 insertions(+) create mode 100644 ansi/graphics_test.go create mode 100644 ansi/kitty/decoder_test.go create mode 100644 ansi/kitty/encoder_test.go create mode 100644 ansi/kitty/options_test.go diff --git a/ansi/graphics_test.go b/ansi/graphics_test.go new file mode 100644 index 00000000..41df20f3 --- /dev/null +++ b/ansi/graphics_test.go @@ -0,0 +1,278 @@ +package ansi + +import ( + "bytes" + "encoding/base64" + "image" + "image/color" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi/kitty" +) + +func TestKittyGraphics(t *testing.T) { + tests := []struct { + name string + payload []byte + opts []string + want string + }{ + { + name: "empty payload no options", + payload: []byte{}, + opts: nil, + want: "\x1b_G\x1b\\", + }, + { + name: "with payload no options", + payload: []byte("test"), + opts: nil, + want: "\x1b_G;test\x1b\\", + }, + { + name: "with payload and options", + payload: []byte("test"), + opts: []string{"a=t", "f=100"}, + want: "\x1b_Ga=t,f=100;test\x1b\\", + }, + { + name: "multiple options no payload", + payload: []byte{}, + opts: []string{"q=2", "C=1", "f=24"}, + want: "\x1b_Gq=2,C=1,f=24\x1b\\", + }, + { + name: "with special characters in payload", + payload: []byte("\x1b_G"), + opts: []string{"a=t"}, + want: "\x1b_Ga=t;\x1b_G\x1b\\", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := KittyGraphics(tt.payload, tt.opts...) + if got != tt.want { + t.Errorf("KittyGraphics() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestWriteKittyGraphics(t *testing.T) { + // Create a test image + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + img.Set(1, 0, color.RGBA{R: 0, G: 255, B: 0, A: 255}) + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(1, 1, color.RGBA{R: 255, G: 255, B: 255, A: 255}) + + // Create large test image (larger than [kitty.MaxChunkSize] 4096 bytes) + imgLarge := image.NewRGBA(image.Rect(0, 0, 100, 100)) + for y := 0; y < 100; y++ { + for x := 0; x < 100; x++ { + imgLarge.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + } + } + + // Create a temporary test file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "test-image") + if err := os.WriteFile(tmpFile, []byte("test image data"), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + img image.Image + opts *kitty.Options + wantError bool + check func(t *testing.T, output string) + }{ + { + name: "direct transmission", + img: img, + opts: &kitty.Options{ + Transmission: kitty.Direct, + Format: kitty.RGB, + }, + wantError: false, + check: func(t *testing.T, output string) { + if !strings.HasPrefix(output, "\x1b_G") { + t.Error("output should start with ESC sequence") + } + if !strings.HasSuffix(output, "\x1b\\") { + t.Error("output should end with ST sequence") + } + if !strings.Contains(output, "f=24") { + t.Error("output should contain format specification") + } + }, + }, + { + name: "chunked transmission", + img: imgLarge, + opts: &kitty.Options{ + Transmission: kitty.Direct, + Format: kitty.RGB, + Chunk: true, + }, + wantError: false, + check: func(t *testing.T, output string) { + chunks := strings.Split(output, "\x1b\\") + if len(chunks) < 2 { + t.Error("output should contain multiple chunks") + } + + chunks = chunks[:len(chunks)-1] // Remove last empty chunk + for i, chunk := range chunks { + if i == len(chunks)-1 { + if !strings.Contains(chunk, "m=0") { + t.Errorf("output should contain chunk end-of-data indicator for chunk %d %q", i, chunk) + } + } else { + if !strings.Contains(chunk, "m=1") { + t.Errorf("output should contain chunk indicator for chunk %d %q", i, chunk) + } + } + } + }, + }, + { + name: "file transmission", + img: img, + opts: &kitty.Options{ + Transmission: kitty.File, + File: tmpFile, + }, + wantError: false, + check: func(t *testing.T, output string) { + if !strings.Contains(output, base64.StdEncoding.EncodeToString([]byte(tmpFile))) { + t.Error("output should contain encoded file path") + } + }, + }, + { + name: "temp file transmission", + img: img, + opts: &kitty.Options{ + Transmission: kitty.TempFile, + }, + wantError: false, + check: func(t *testing.T, output string) { + output = strings.TrimPrefix(output, "\x1b_G") + output = strings.TrimSuffix(output, "\x1b\\") + payload := strings.Split(output, ";")[1] + fn, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + t.Error("output should contain base64 encoded temp file path") + } + if !strings.Contains(string(fn), "tty-graphics-protocol") { + t.Error("output should contain temp file path") + } + if !strings.Contains(output, "t=t") { + t.Error("output should contain transmission specification") + } + }, + }, + { + name: "compression enabled", + img: img, + opts: &kitty.Options{ + Transmission: kitty.Direct, + Compression: kitty.Zlib, + }, + wantError: false, + check: func(t *testing.T, output string) { + if !strings.Contains(output, "o=z") { + t.Error("output should contain compression specification") + } + }, + }, + { + name: "invalid file path", + img: img, + opts: &kitty.Options{ + Transmission: kitty.File, + File: "/nonexistent/file", + }, + wantError: true, + check: nil, + }, + { + name: "nil options", + img: img, + opts: nil, + wantError: false, + check: func(t *testing.T, output string) { + if !strings.HasPrefix(output, "\x1b_G") { + t.Error("output should start with ESC sequence") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := WriteKittyGraphics(&buf, tt.img, tt.opts) + + if (err != nil) != tt.wantError { + t.Errorf("WriteKittyGraphics() error = %v, wantError %v", err, tt.wantError) + return + } + + if !tt.wantError && tt.check != nil { + tt.check(t, buf.String()) + } + }) + } +} + +func TestWriteKittyGraphicsEdgeCases(t *testing.T) { + tests := []struct { + name string + img image.Image + opts *kitty.Options + wantError bool + }{ + { + name: "zero size image", + img: image.NewRGBA(image.Rect(0, 0, 0, 0)), + opts: &kitty.Options{ + Transmission: kitty.Direct, + }, + wantError: false, + }, + { + name: "shared memory transmission", + img: image.NewRGBA(image.Rect(0, 0, 1, 1)), + opts: &kitty.Options{ + Transmission: kitty.SharedMemory, + }, + wantError: true, // Not implemented + }, + { + name: "file transmission without file path", + img: image.NewRGBA(image.Rect(0, 0, 1, 1)), + opts: &kitty.Options{ + Transmission: kitty.File, + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := WriteKittyGraphics(&buf, tt.img, tt.opts) + + if (err != nil) != tt.wantError { + t.Errorf("WriteKittyGraphics() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} diff --git a/ansi/kitty/decoder_test.go b/ansi/kitty/decoder_test.go new file mode 100644 index 00000000..61972179 --- /dev/null +++ b/ansi/kitty/decoder_test.go @@ -0,0 +1,252 @@ +package kitty + +import ( + "bytes" + "compress/zlib" + "image" + "image/color" + "image/png" + "reflect" + "testing" +) + +func TestDecoder_Decode(t *testing.T) { + // Helper function to create compressed data + compress := func(data []byte) []byte { + var buf bytes.Buffer + w := zlib.NewWriter(&buf) + w.Write(data) + w.Close() + return buf.Bytes() + } + + tests := []struct { + name string + decoder Decoder + input []byte + want image.Image + wantErr bool + }{ + { + name: "RGBA format 2x2", + decoder: Decoder{ + Format: RGBA, + Width: 2, + Height: 2, + }, + input: []byte{ + 255, 0, 0, 255, // Red pixel + 0, 0, 255, 255, // Blue pixel + 0, 0, 255, 255, // Blue pixel + 255, 0, 0, 255, // Red pixel + }, + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + { + name: "RGB format 2x2", + decoder: Decoder{ + Format: RGB, + Width: 2, + Height: 2, + }, + input: []byte{ + 255, 0, 0, // Red pixel + 0, 0, 255, // Blue pixel + 0, 0, 255, // Blue pixel + 255, 0, 0, // Red pixel + }, + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + { + name: "RGBA with compression", + decoder: Decoder{ + Format: RGBA, + Width: 2, + Height: 2, + Decompress: true, + }, + input: compress([]byte{ + 255, 0, 0, 255, + 0, 0, 255, 255, + 0, 0, 255, 255, + 255, 0, 0, 255, + }), + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) + img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + { + name: "PNG format", + decoder: Decoder{ + Format: PNG, + // Width and height are embedded and inferred from the PNG data + }, + input: func() []byte { + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + var buf bytes.Buffer + png.Encode(&buf, img) + return buf.Bytes() + }(), + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + { + name: "invalid format", + decoder: Decoder{ + Format: 999, + Width: 2, + Height: 2, + }, + input: []byte{0, 0, 0}, + want: nil, + wantErr: true, + }, + { + name: "incomplete RGBA data", + decoder: Decoder{ + Format: RGBA, + Width: 2, + Height: 2, + }, + input: []byte{255, 0, 0}, // Incomplete pixel data + want: nil, + wantErr: true, + }, + { + name: "invalid compressed data", + decoder: Decoder{ + Format: RGBA, + Width: 2, + Height: 2, + Decompress: true, + }, + input: []byte{1, 2, 3}, // Invalid zlib data + want: nil, + wantErr: true, + }, + { + name: "default format (RGBA)", + decoder: Decoder{ + Width: 1, + Height: 1, + }, + input: []byte{255, 0, 0, 255}, + want: func() image.Image { + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + return img + }(), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.decoder.Decode(bytes.NewReader(tt.input)) + + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Decode() output mismatch") + if bounds := got.Bounds(); bounds != tt.want.Bounds() { + t.Errorf("bounds got %v, want %v", bounds, tt.want.Bounds()) + } + + // Compare pixels + bounds := got.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + gotColor := got.At(x, y) + wantColor := tt.want.At(x, y) + if !reflect.DeepEqual(gotColor, wantColor) { + t.Errorf("pixel at (%d,%d) = %v, want %v", x, y, gotColor, wantColor) + } + } + } + } + }) + } +} + +func TestDecoder_DecodeEdgeCases(t *testing.T) { + tests := []struct { + name string + decoder Decoder + input []byte + wantErr bool + }{ + { + name: "zero dimensions", + decoder: Decoder{ + Format: RGBA, + Width: 0, + Height: 0, + }, + input: []byte{}, + wantErr: false, + }, + { + name: "negative width", + decoder: Decoder{ + Format: RGBA, + Width: -1, + Height: 1, + }, + input: []byte{255, 0, 0, 255}, + wantErr: false, // The image package handles this gracefully + }, + { + name: "very large dimensions", + decoder: Decoder{ + Format: RGBA, + Width: 1, + Height: 1000000, // Very large height + }, + input: []byte{255, 0, 0, 255}, // Not enough data + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := tt.decoder.Decode(bytes.NewReader(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("Decode() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/ansi/kitty/encoder_test.go b/ansi/kitty/encoder_test.go new file mode 100644 index 00000000..09154719 --- /dev/null +++ b/ansi/kitty/encoder_test.go @@ -0,0 +1,242 @@ +package kitty + +import ( + "bytes" + "compress/zlib" + "image" + "image/color" + "io" + "testing" +) + +// taken from "image/png" package +const pngHeader = "\x89PNG\r\n\x1a\n" + +// testImage creates a simple test image with a red and blue pattern +func testImage() *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + img.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) // Red + img.Set(1, 0, color.RGBA{R: 0, G: 0, B: 255, A: 255}) // Blue + img.Set(0, 1, color.RGBA{R: 0, G: 0, B: 255, A: 255}) // Blue + img.Set(1, 1, color.RGBA{R: 255, G: 0, B: 0, A: 255}) // Red + return img +} + +func TestEncoder_Encode(t *testing.T) { + tests := []struct { + name string + encoder Encoder + img image.Image + wantErr bool + verify func([]byte) error + }{ + { + name: "nil image", + encoder: Encoder{ + Format: RGBA, + }, + img: nil, + wantErr: false, + verify: func(got []byte) error { + if len(got) != 0 { + t.Errorf("expected empty output for nil image, got %d bytes", len(got)) + } + return nil + }, + }, + { + name: "RGBA format", + encoder: Encoder{ + Format: RGBA, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + expected := []byte{ + 255, 0, 0, 255, // Red pixel + 0, 0, 255, 255, // Blue pixel + 0, 0, 255, 255, // Blue pixel + 255, 0, 0, 255, // Red pixel + } + if !bytes.Equal(got, expected) { + t.Errorf("unexpected RGBA output\ngot: %v\nwant: %v", got, expected) + } + return nil + }, + }, + { + name: "RGB format", + encoder: Encoder{ + Format: RGB, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + expected := []byte{ + 255, 0, 0, // Red pixel + 0, 0, 255, // Blue pixel + 0, 0, 255, // Blue pixel + 255, 0, 0, // Red pixel + } + if !bytes.Equal(got, expected) { + t.Errorf("unexpected RGB output\ngot: %v\nwant: %v", got, expected) + } + return nil + }, + }, + { + name: "PNG format", + encoder: Encoder{ + Format: PNG, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + // Verify PNG header + // if len(got) < 8 || !bytes.Equal(got[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) { + if len(got) < 8 || !bytes.Equal(got[:8], []byte(pngHeader)) { + t.Error("invalid PNG header") + } + return nil + }, + }, + { + name: "invalid format", + encoder: Encoder{ + Format: 999, // Invalid format + }, + img: testImage(), + wantErr: true, + verify: nil, + }, + { + name: "RGBA with compression", + encoder: Encoder{ + Format: RGBA, + Compress: true, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + // Decompress the data + r, err := zlib.NewReader(bytes.NewReader(got)) + if err != nil { + return err + } + defer r.Close() + + decompressed, err := io.ReadAll(r) + if err != nil { + return err + } + + expected := []byte{ + 255, 0, 0, 255, // Red pixel + 0, 0, 255, 255, // Blue pixel + 0, 0, 255, 255, // Blue pixel + 255, 0, 0, 255, // Red pixel + } + if !bytes.Equal(decompressed, expected) { + t.Errorf("unexpected decompressed output\ngot: %v\nwant: %v", decompressed, expected) + } + return nil + }, + }, + { + name: "zero format defaults to RGBA", + encoder: Encoder{ + Format: 0, + }, + img: testImage(), + wantErr: false, + verify: func(got []byte) error { + expected := []byte{ + 255, 0, 0, 255, // Red pixel + 0, 0, 255, 255, // Blue pixel + 0, 0, 255, 255, // Blue pixel + 255, 0, 0, 255, // Red pixel + } + if !bytes.Equal(got, expected) { + t.Errorf("unexpected RGBA output\ngot: %v\nwant: %v", got, expected) + } + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := tt.encoder.Encode(&buf, tt.img) + + if (err != nil) != tt.wantErr { + t.Errorf("Encode() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && tt.verify != nil { + if err := tt.verify(buf.Bytes()); err != nil { + t.Errorf("verification failed: %v", err) + } + } + }) + } +} + +func TestEncoder_EncodeWithDifferentImageTypes(t *testing.T) { + // Create different image types for testing + rgba := image.NewRGBA(image.Rect(0, 0, 1, 1)) + rgba.Set(0, 0, color.RGBA{R: 255, G: 0, B: 0, A: 255}) + + gray := image.NewGray(image.Rect(0, 0, 1, 1)) + gray.Set(0, 0, color.Gray{Y: 128}) + + tests := []struct { + name string + img image.Image + format int + wantLen int + }{ + { + name: "RGBA image to RGBA format", + img: rgba, + format: RGBA, + wantLen: 4, // 4 bytes per pixel + }, + { + name: "Gray image to RGBA format", + img: gray, + format: RGBA, + wantLen: 4, // 4 bytes per pixel + }, + { + name: "RGBA image to RGB format", + img: rgba, + format: RGB, + wantLen: 3, // 3 bytes per pixel + }, + { + name: "Gray image to RGB format", + img: gray, + format: RGB, + wantLen: 3, // 3 bytes per pixel + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + enc := Encoder{Format: tt.format} + + err := enc.Encode(&buf, tt.img) + if err != nil { + t.Errorf("Encode() error = %v", err) + return + } + + if got := buf.Len(); got != tt.wantLen { + t.Errorf("Encode() output length = %v, want %v", got, tt.wantLen) + } + }) + } +} diff --git a/ansi/kitty/options_test.go b/ansi/kitty/options_test.go new file mode 100644 index 00000000..5d172943 --- /dev/null +++ b/ansi/kitty/options_test.go @@ -0,0 +1,384 @@ +package kitty + +import ( + "reflect" + "sort" + "testing" +) + +func TestOptions_Options(t *testing.T) { + tests := []struct { + name string + options Options + expected []string + }{ + { + name: "default options", + options: Options{}, + expected: []string{}, // Default values don't generate options + }, + { + name: "basic transmission options", + options: Options{ + Format: PNG, + ID: 1, + Action: TransmitAndPut, + }, + expected: []string{ + "f=100", + "i=1", + "a=T", + }, + }, + { + name: "display options", + options: Options{ + X: 100, + Y: 200, + Z: 3, + Width: 400, + Height: 300, + }, + expected: []string{ + "x=100", + "y=200", + "z=3", + "w=400", + "h=300", + }, + }, + { + name: "compression and chunking", + options: Options{ + Compression: Zlib, + Chunk: true, + Size: 1024, + }, + expected: []string{ + "S=1024", + "o=z", + }, + }, + { + name: "delete options", + options: Options{ + Delete: DeleteID, + DeleteResources: true, + }, + expected: []string{ + "d=I", // Uppercase due to DeleteResources being true + }, + }, + { + name: "virtual placement", + options: Options{ + VirtualPlacement: true, + ParentID: 5, + ParentPlacementID: 2, + }, + expected: []string{ + "U=1", + "P=5", + "Q=2", + }, + }, + { + name: "cell positioning", + options: Options{ + OffsetX: 10, + OffsetY: 20, + Columns: 80, + Rows: 24, + }, + expected: []string{ + "X=10", + "Y=20", + "c=80", + "r=24", + }, + }, + { + name: "transmission details", + options: Options{ + Transmission: File, + File: "/tmp/image.png", + Offset: 100, + Number: 2, + PlacementID: 3, + }, + expected: []string{ + "p=3", + "I=2", + "t=f", + "O=100", + }, + }, + { + name: "quiet mode and format", + options: Options{ + Quite: 2, + Format: RGB, + }, + expected: []string{ + "f=24", + "q=2", + }, + }, + { + name: "all zero values", + options: Options{ + Format: 0, + Action: 0, + Delete: 0, + }, + expected: []string{}, // Should use defaults and not generate options + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.options.Options() + + // Sort both slices to ensure consistent comparison + sortStrings(got) + sortStrings(tt.expected) + + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("Options.Options() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestOptions_Validation(t *testing.T) { + tests := []struct { + name string + options Options + check func([]string) bool + }{ + { + name: "format validation", + options: Options{ + Format: 999, // Invalid format + }, + check: func(opts []string) bool { + // Should still output the format even if invalid + return containsOption(opts, "f=999") + }, + }, + { + name: "delete with resources", + options: Options{ + Delete: DeleteID, + DeleteResources: true, + }, + check: func(opts []string) bool { + // Should be uppercase when DeleteResources is true + return containsOption(opts, "d=I") + }, + }, + { + name: "transmission with file", + options: Options{ + File: "/tmp/test.png", + }, + check: func(opts []string) bool { + return containsOption(opts, "t=f") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.options.Options() + if !tt.check(got) { + t.Errorf("Options validation failed for %s: %v", tt.name, got) + } + }) + } +} + +func TestOptions_String(t *testing.T) { + tests := []struct { + name string + o Options + want string + }{ + { + name: "empty options", + o: Options{}, + want: "", + }, + { + name: "full options", + o: Options{ + Action: 'A', + Quite: 'Q', + Compression: 'C', + Transmission: 'T', + Delete: 'd', + DeleteResources: true, + ID: 123, + PlacementID: 456, + Number: 789, + Format: 1, + ImageWidth: 800, + ImageHeight: 600, + Size: 1024, + Offset: 10, + Chunk: true, + X: 100, + Y: 200, + Z: 300, + Width: 400, + Height: 500, + OffsetX: 50, + OffsetY: 60, + Columns: 4, + Rows: 3, + VirtualPlacement: true, + ParentID: 999, + ParentPlacementID: 888, + }, + want: "f=1,q=81,i=123,p=456,I=789,s=800,v=600,t=T,S=1024,O=10,U=1,P=999,Q=888,x=100,y=200,z=300,w=400,h=500,X=50,Y=60,c=4,r=3,d=D,a=A", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.o.String(); got != tt.want { + t.Errorf("Options.String() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestOptions_MarshalText(t *testing.T) { + tests := []struct { + name string + o Options + want []byte + wantErr bool + }{ + { + name: "marshal empty options", + o: Options{}, + want: []byte(""), + }, + { + name: "marshal with values", + o: Options{ + Action: 'A', + ID: 123, + Width: 400, + Height: 500, + Quite: 2, + }, + want: []byte("q=2,i=123,w=400,h=500,a=A"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.o.MarshalText() + if (err != nil) != tt.wantErr { + t.Errorf("Options.MarshalText() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Options.MarshalText() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestOptions_UnmarshalText(t *testing.T) { + tests := []struct { + name string + text []byte + want Options + wantErr bool + }{ + { + name: "unmarshal empty", + text: []byte(""), + want: Options{}, + }, + { + name: "unmarshal basic options", + text: []byte("a=A,i=123,w=400,h=500"), + want: Options{ + Action: 'A', + ID: 123, + Width: 400, + Height: 500, + }, + }, + { + name: "unmarshal with invalid number", + text: []byte("i=abc"), + want: Options{}, + }, + { + name: "unmarshal with delete resources", + text: []byte("d=D"), + want: Options{ + Delete: 'd', + DeleteResources: true, + }, + }, + { + name: "unmarshal with boolean chunk", + text: []byte("m=1"), + want: Options{ + Chunk: true, + }, + }, + { + name: "unmarshal with virtual placement", + text: []byte("U=1"), + want: Options{ + VirtualPlacement: true, + }, + }, + { + name: "unmarshal with invalid format", + text: []byte("invalid=format"), + want: Options{}, + }, + { + name: "unmarshal with missing value", + text: []byte("a="), + want: Options{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var o Options + err := o.UnmarshalText(tt.text) + if (err != nil) != tt.wantErr { + t.Errorf("Options.UnmarshalText() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(o, tt.want) { + t.Errorf("Options.UnmarshalText() = %+v, want %+v", o, tt.want) + } + }) + } +} + +// Helper functions + +func sortStrings(s []string) { + sort.Strings(s) +} + +func containsOption(opts []string, target string) bool { + for _, opt := range opts { + if opt == target { + return true + } + } + return false +}