1package png
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "image"
8 "image/color"
9 stdpng "image/png"
10 "math/rand/v2"
11 "os"
12 "path/filepath"
13 "testing"
14 "time"
15
16 "github.com/bnema/nefercap/internal/ports"
17)
18
19func decode(t *testing.T, path string) image.Image {
20 t.Helper()
21 data, err := os.ReadFile(path)
22 if err != nil {
23 t.Fatal(err)
24 }
25 img, err := stdpng.Decode(bytes.NewReader(data))
26 if err != nil {
27 t.Fatal(err)
28 }
29 return img
30}
31
32func TestSaveConvertsBGRAWithStride(t *testing.T) {
33 // 2x2 pixels, stride 12 (4 padding bytes per row).
34 // Pixel bytes are B,G,R,A; alpha values must be ignored.
35 pix := []byte{
36 // top row
37 3, 2, 1, 255, 6, 5, 4, 0, 0xEE, 0xEE, 0xEE, 0xEE,
38 // bottom row
39 30, 20, 10, 0, 60, 50, 40, 7, 0xEE, 0xEE, 0xEE, 0xEE,
40 }
41 for _, format := range []ports.PixelFormat{ports.ARGB8888, ports.XRGB8888} {
42 f := ports.Frame{Pixels: pix, Width: 2, Height: 2, Stride: 12, Format: format}
43 path := filepath.Join(t.TempDir(), "shot.png")
44 if err := New().Save(context.Background(), f, path); err != nil {
45 t.Fatal(err)
46 }
47 img := decode(t, path)
48 want := [2][2]color.RGBA{
49 {{1, 2, 3, 255}, {4, 5, 6, 255}},
50 {{10, 20, 30, 255}, {40, 50, 60, 255}},
51 }
52 for y := 0; y < 2; y++ {
53 for x := 0; x < 2; x++ {
54 if got := color.RGBAModel.Convert(img.At(x, y)); got != want[y][x] {
55 t.Fatalf("format %d pixel (%d,%d): got %v want %v", format, x, y, got, want[y][x])
56 }
57 }
58 }
59 if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 {
60 t.Fatalf("mode = %v, err = %v; want 0600", info.Mode(), err)
61 }
62 raw, err := os.ReadFile(path)
63 if err != nil {
64 t.Fatal(err)
65 }
66 if raw[25] != 2 { // IHDR colour type 2 is truecolour without alpha
67 t.Fatalf("colour type %d, want 2 (opaque RGB)", raw[25])
68 }
69 }
70}
71
72func TestSaveDoesNotModifyFrame(t *testing.T) {
73 pix := []byte{1, 2, 3, 4, 5, 6, 7, 8}
74 orig := bytes.Clone(pix)
75 f := ports.Frame{Pixels: pix, Width: 2, Height: 1, Stride: 8, Format: ARGB}
76 if err := New().Save(context.Background(), f, filepath.Join(t.TempDir(), "a.png")); err != nil {
77 t.Fatal(err)
78 }
79 if !bytes.Equal(pix, orig) {
80 t.Fatal("frame storage was modified")
81 }
82}
83
84const ARGB = ports.ARGB8888
85
86func TestSaveNeverOverwrites(t *testing.T) {
87 path := filepath.Join(t.TempDir(), "exists.png")
88 if err := os.WriteFile(path, []byte("keep"), 0o600); err != nil {
89 t.Fatal(err)
90 }
91 f := ports.Frame{Pixels: make([]byte, 4), Width: 1, Height: 1, Stride: 4, Format: ARGB}
92 err := New().Save(context.Background(), f, path)
93 if !errors.Is(err, ports.ErrPathExists) || !errors.Is(err, os.ErrExist) {
94 t.Fatalf("got %v, want ErrPathExists and fs.ErrExist", err)
95 }
96 if got, _ := os.ReadFile(path); string(got) != "keep" {
97 t.Fatalf("existing file changed: %q", got)
98 }
99}
100
101func TestSaveDoesNotFollowSymlink(t *testing.T) {
102 dir := t.TempDir()
103 target := filepath.Join(dir, "target")
104 link := filepath.Join(dir, "link.png")
105 if err := os.Symlink(target, link); err != nil {
106 t.Skip(err)
107 }
108 f := ports.Frame{Pixels: make([]byte, 4), Width: 1, Height: 1, Stride: 4, Format: ARGB}
109 if err := New().Save(context.Background(), f, link); !errors.Is(err, ports.ErrPathExists) {
110 t.Fatalf("got %v, want ErrPathExists", err)
111 }
112 if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
113 t.Fatalf("symlink target was created: %v", err)
114 }
115}
116
117func TestSaveInvalidInputCreatesNothing(t *testing.T) {
118 dir := t.TempDir()
119 path := filepath.Join(dir, "bad.png")
120 bad := ports.Frame{Pixels: make([]byte, 3), Width: 1, Height: 1, Stride: 4, Format: ARGB}
121 if err := New().Save(context.Background(), bad, path); err == nil {
122 t.Fatal("accepted short frame")
123 }
124 ctx, cancel := context.WithCancel(context.Background())
125 cancel()
126 ok := ports.Frame{Pixels: make([]byte, 4), Width: 1, Height: 1, Stride: 4, Format: ARGB}
127 if err := New().Save(ctx, ok, path); !errors.Is(err, context.Canceled) {
128 t.Fatalf("got %v, want context.Canceled", err)
129 }
130 if entries, _ := os.ReadDir(dir); len(entries) != 0 {
131 t.Fatalf("unexpected files: %v", entries)
132 }
133}
134
135func TestSaveCancelDuringEncodeRemovesPartialOutput(t *testing.T) {
136 const side = 2048
137 pix := make([]byte, side*side*4)
138 rng := rand.New(rand.NewPCG(1, 2))
139 for i := range pix {
140 pix[i] = byte(rng.Uint32())
141 }
142 f := ports.Frame{Pixels: pix, Width: side, Height: side, Stride: side * 4, Format: ports.XRGB8888}
143 path := filepath.Join(t.TempDir(), "partial.png")
144 ctx, cancel := context.WithCancel(context.Background())
145 defer cancel()
146 go func() {
147 for ctx.Err() == nil {
148 if _, err := os.Stat(path); err == nil {
149 cancel()
150 return
151 }
152 time.Sleep(50 * time.Microsecond)
153 }
154 }()
155 if err := New().Save(ctx, f, path); !errors.Is(err, context.Canceled) {
156 t.Fatalf("got %v, want context.Canceled", err)
157 }
158 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
159 t.Fatalf("partial output remains: %v", err)
160 }
161}
162
163func BenchmarkSave(b *testing.B) {
164 for _, size := range []struct {
165 name string
166 w, h int
167 }{{"1080p", 1920, 1080}, {"4K", 3840, 2160}} {
168 b.Run(size.name, func(b *testing.B) {
169 f := ports.Frame{Pixels: make([]byte, size.w*size.h*4), Width: size.w, Height: size.h, Stride: size.w * 4, Format: ports.XRGB8888}
170 dir := b.TempDir()
171 w := New()
172 b.ReportAllocs()
173 b.ResetTimer()
174 for i := 0; b.Loop(); i++ {
175 b.StopTimer()
176 path := filepath.Join(dir, "b.png")
177 _ = os.Remove(path)
178 b.StartTimer()
179 if err := w.Save(context.Background(), f, path); err != nil {
180 b.Fatal(err)
181 }
182 }
183 })
184 }
185}
186
187func TestCancelCleanupSparesReplacementFile(t *testing.T) {
188 const side = 2048
189 pix := make([]byte, side*side*4)
190 rng := rand.New(rand.NewPCG(3, 4))
191 for i := range pix {
192 pix[i] = byte(rng.Uint32())
193 }
194 f := ports.Frame{Pixels: pix, Width: side, Height: side, Stride: side * 4, Format: ports.XRGB8888}
195 dir := t.TempDir()
196 path := filepath.Join(dir, "shot.png")
197 moved := filepath.Join(dir, "moved.png")
198 ctx, cancel := context.WithCancel(context.Background())
199 defer cancel()
200 go func() { // the user moves the file away and puts their own at the path
201 for ctx.Err() == nil {
202 if _, err := os.Stat(path); err == nil {
203 if os.Rename(path, moved) == nil && os.WriteFile(path, []byte("user data"), 0o600) == nil {
204 cancel()
205 return
206 }
207 }
208 time.Sleep(50 * time.Microsecond)
209 }
210 }()
211 if err := New().Save(ctx, f, path); !errors.Is(err, context.Canceled) {
212 t.Fatalf("got %v, want context.Canceled", err)
213 }
214 if got, err := os.ReadFile(path); err != nil || string(got) != "user data" {
215 t.Fatalf("replacement was removed or changed: %q, %v", got, err)
216 }
217}