feat/freeze-key 753c87f

png_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close