1package graphics
2
3import (
4 "errors"
5 "math"
6 "testing"
7)
8
9func testAsset(t *testing.T, data []byte, width, height int64) AssetBlob {
10 t.Helper()
11 return AssetBlob{Encoded: data, Width: width, Height: height}
12}
13
14func TestRectOperationsRejectOverflow(t *testing.T) {
15 if _, err := NewPixelRect(math.MaxInt64, 0, 1, 1); !errors.Is(err, ErrInvalidRect) {
16 t.Fatalf("overflowing pixel rectangle error = %v", err)
17 }
18 if _, err := NewCellRect(0, math.MaxInt64, 1, 1); !errors.Is(err, ErrInvalidRect) {
19 t.Fatalf("overflowing cell rectangle error = %v", err)
20 }
21 valid := PixelRect{X: math.MinInt64, Y: math.MinInt64, Width: math.MaxInt64, Height: math.MaxInt64}
22 if !valid.Valid() {
23 t.Fatal("maximum representable negative-origin rectangle should be valid")
24 }
25 clipped, ok := valid.Intersect(PixelRect{X: -2, Y: -2, Width: 1, Height: 1})
26 if !ok || clipped != (PixelRect{X: -2, Y: -2, Width: 1, Height: 1}) {
27 t.Fatalf("intersection = %#v, %v", clipped, ok)
28 }
29}
30
31// Owned assets keep only accounted memory: a slice with large spare capacity
32// is trimmed, a tight one is kept as is, and a rejected blob is not retained.
33func TestOwnedAssetsRetainOnlyAccountedBytes(t *testing.T) {
34 scene := NewScene(Limits{MaxEncodedBytes: 1 << 20, MaxDecodedPixels: 1 << 20})
35 loose := make([]byte, 4, 1<<16)
36 id, err := scene.AddAssetOwned(testAsset(t, loose, 1, 1))
37 if err != nil {
38 t.Fatal(err)
39 }
40 if got := scene.state.assets[id].encoded; cap(got) != len(got) || &got[0] == &loose[0] {
41 t.Fatalf("loose owned slice retained with cap %d for len %d", cap(got), len(got))
42 }
43 tight := []byte{1, 2, 3, 4}
44 id, err = scene.ReplaceAssetOwned(id, testAsset(t, tight, 1, 1))
45 if err != nil {
46 t.Fatal(err)
47 }
48 if got := scene.state.assets[id].encoded; &got[0] != &tight[0] {
49 t.Fatal("tight owned slice was copied")
50 }
51 before := scene.Snapshot()
52 rejected := make([]byte, 2<<20)
53 if _, err := scene.AddAssetOwned(testAsset(t, rejected, 1, 1)); !errors.Is(err, ErrEncodedBudget) {
54 t.Fatalf("over-budget owned asset error = %v", err)
55 }
56 for _, asset := range scene.state.assets {
57 if len(asset.encoded) > 0 && &asset.encoded[0] == &rejected[0] {
58 t.Fatal("rejected owned blob retained")
59 }
60 }
61 if scene.Snapshot().Usage() != before.Usage() {
62 t.Fatal("rejected owned blob changed usage")
63 }
64}
65
66func TestAssetBlobOwnershipAndSnapshotLifecycle(t *testing.T) {
67 scene := NewScene(Limits{MaxEncodedBytes: 32, MaxDecodedPixels: 64})
68 encoded := []byte{1, 2, 3}
69 assetID, err := scene.AddAsset(testAsset(t, encoded, 4, 4))
70 if err != nil {
71 t.Fatal(err)
72 }
73 encoded[0] = 99
74
75 before := scene.Snapshot()
76 asset, ok := before.Asset(assetID)
77 if !ok {
78 t.Fatal("asset missing from snapshot")
79 }
80 got := asset.Encoded()
81 got[1] = 88
82 if got := asset.Encoded(); got[0] != 1 || got[1] != 2 || asset.EncodedSize() != 3 {
83 t.Fatalf("asset bytes were not immutable copies: %v", got)
84 }
85
86 placementID, err := scene.Place(PlacementSpec{Asset: assetID, Destination: PixelRect{Width: 4, Height: 4}})
87 if err != nil {
88 t.Fatal(err)
89 }
90 if err := scene.RemoveAsset(assetID); !errors.Is(err, ErrAssetInUse) {
91 t.Fatalf("remove in-use asset error = %v", err)
92 }
93 if err := scene.RemoveAssetCascade(assetID); err != nil {
94 t.Fatal(err)
95 }
96 if _, ok := scene.Snapshot().Asset(assetID); ok {
97 t.Fatal("removed asset remained in current snapshot")
98 }
99 if _, ok := scene.Snapshot().Placement(placementID); ok {
100 t.Fatal("cascade did not remove placement")
101 }
102 if _, ok := before.Asset(assetID); !ok {
103 t.Fatal("old snapshot lost its asset after scene mutation")
104 }
105}
106
107func TestDecodedPixelAccountingRejectsForgedDeclarations(t *testing.T) {
108 scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 4})
109 _, err := scene.AddAsset(AssetBlob{
110 Encoded: []byte{1},
111 Width: 2,
112 Height: 2,
113 DecodedPixels: 1,
114 })
115 if !errors.Is(err, ErrInvalidAsset) {
116 t.Fatalf("forged decoded-pixel declaration error = %v", err)
117 }
118 if got := scene.Usage(); got != (Usage{}) {
119 t.Fatalf("rejected asset changed usage: %#v", got)
120 }
121}
122
123func TestAssetAndPlacementQuotasAreTransactional(t *testing.T) {
124 scene := NewScene(Limits{
125 MaxAssets: 1,
126 MaxPlacements: 1,
127 MaxEncodedBytes: 4,
128 MaxDecodedPixels: 4,
129 })
130 id, err := scene.AddAsset(testAsset(t, []byte{1, 2, 3, 4}, 2, 2))
131 if err != nil {
132 t.Fatal(err)
133 }
134 generation := scene.Snapshot().Generation()
135 if _, err := scene.AddAsset(testAsset(t, []byte{5}, 1, 1)); !errors.Is(err, ErrTooManyAssets) {
136 t.Fatalf("asset quota error = %v", err)
137 }
138 if scene.Snapshot().Generation() != generation {
139 t.Fatal("failed asset addition changed generation")
140 }
141 firstPlacement, err := scene.Place(PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}})
142 if err != nil {
143 t.Fatal(err)
144 }
145 if _, err := scene.Place(PlacementSpec{Asset: id, Destination: PixelRect{X: 2, Width: 2, Height: 2}}); !errors.Is(err, ErrTooManyPlacements) {
146 t.Fatalf("placement quota error = %v", err)
147 }
148 if err := scene.Apply(
149 Operation{Kind: OperationRemovePlacement, PlacementID: firstPlacement},
150 Operation{Kind: OperationPlace, Placement: PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}},
151 ); err != nil {
152 t.Fatal(err)
153 }
154 if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.EncodedBytes != 4 || got.DecodedPixels != 4 {
155 t.Fatalf("usage = %#v", got)
156 }
157}
158
159func TestSnapshotGenerationAndTransactionalApply(t *testing.T) {
160 scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 64})
161 initial := scene.Snapshot()
162 id, err := scene.AddAsset(testAsset(t, []byte{1}, 2, 2))
163 if err != nil {
164 t.Fatal(err)
165 }
166 if got := scene.Snapshot().Generation(); got <= initial.Generation() {
167 t.Fatalf("generation did not advance: initial=%d current=%d", initial.Generation(), got)
168 }
169 before := scene.Snapshot()
170 err = scene.Apply(
171 Operation{Kind: OperationPlace, Placement: PlacementSpec{Asset: id, Destination: PixelRect{Width: 2, Height: 2}}},
172 Operation{Kind: OperationRemovePlacement, PlacementID: PlacementID{value: 999}},
173 )
174 if !errors.Is(err, ErrPlacementNotFound) {
175 t.Fatalf("transaction error = %v", err)
176 }
177 if scene.Snapshot().Generation() != before.Generation() || len(scene.Snapshot().Placements()) != 0 {
178 t.Fatal("failed transaction partially committed")
179 }
180}
181
182func TestVisibleFragmentsPreserveScaledSourceMapping(t *testing.T) {
183 scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 10000})
184 assetID, err := scene.AddAsset(testAsset(t, []byte{1}, 100, 50))
185 if err != nil {
186 t.Fatal(err)
187 }
188 placementID, err := scene.Place(PlacementSpec{
189 Asset: assetID,
190 Destination: PixelRect{X: 10, Y: 20, Width: 200, Height: 100},
191 Cells: CellRect{X: 2, Y: 3, Width: 20, Height: 10},
192 })
193 if err != nil {
194 t.Fatal(err)
195 }
196 fragments := scene.Snapshot().VisibleFragments(PixelRect{X: 110, Y: 20, Width: 100, Height: 100})
197 if len(fragments) != 1 {
198 t.Fatalf("fragments = %#v", fragments)
199 }
200 fragment := fragments[0]
201 if fragment.PlacementID != placementID || fragment.AssetID != assetID {
202 t.Fatalf("fragment IDs = %#v", fragment)
203 }
204 if fragment.Destination != (PixelRect{X: 110, Y: 20, Width: 100, Height: 100}) {
205 t.Fatalf("destination = %#v", fragment.Destination)
206 }
207 if fragment.Source != (PixelRect{X: 50, Y: 0, Width: 50, Height: 50}) {
208 t.Fatalf("source mapping = %#v", fragment.Source)
209 }
210 if fragment.Cells != (CellRect{X: 12, Y: 3, Width: 10, Height: 10}) {
211 t.Fatalf("cell mapping = %#v", fragment.Cells)
212 }
213
214 cellFragments := scene.Snapshot().VisibleFragments(CellRect{X: 7, Y: 5, Width: 5, Height: 5})
215 if len(cellFragments) != 1 || cellFragments[0].Destination != (PixelRect{X: 60, Y: 40, Width: 50, Height: 50}) {
216 t.Fatalf("cell clipping = %#v", cellFragments)
217 }
218}
219
220func TestVisibleFragmentsRejectInvalidMixedViewport(t *testing.T) {
221 scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 64})
222 assetID, err := scene.AddAsset(testAsset(t, []byte{1}, 2, 2))
223 if err != nil {
224 t.Fatal(err)
225 }
226 if _, err := scene.Place(PlacementSpec{
227 Asset: assetID,
228 Destination: PixelRect{Width: 2, Height: 2},
229 Cells: CellRect{Width: 2, Height: 2},
230 }); err != nil {
231 t.Fatal(err)
232 }
233 for _, viewport := range []Viewport{
234 {Pixels: PixelRect{Width: 2, Height: 2}, Cells: CellRect{Width: -1, Height: 1}},
235 {Pixels: PixelRect{Width: 2, Height: 2}, Cells: CellRect{X: 1}},
236 {Pixels: PixelRect{Width: -1, Height: 1}, Cells: CellRect{Width: 2, Height: 2}},
237 } {
238 if fragments := scene.Snapshot().VisibleFragments(viewport); len(fragments) != 0 {
239 t.Fatalf("invalid mixed viewport %#v produced fragments %#v", viewport, fragments)
240 }
241 }
242}
243
244func TestUpdatePlacementCanResetLayerAndClearCells(t *testing.T) {
245 scene := NewScene(Limits{MaxEncodedBytes: 64, MaxDecodedPixels: 64})
246 assetID, err := scene.AddAsset(testAsset(t, []byte{1}, 4, 4))
247 if err != nil {
248 t.Fatal(err)
249 }
250 placementID, err := scene.Place(PlacementSpec{
251 Asset: assetID,
252 Destination: PixelRect{Width: 4, Height: 4},
253 Cells: CellRect{X: 1, Y: 2, Width: 3, Height: 2},
254 Layer: 7,
255 HasLayer: true,
256 })
257 if err != nil {
258 t.Fatal(err)
259 }
260 if err := scene.UpdatePlacement(placementID, PlacementSpec{HasCells: true, HasLayer: true}); err != nil {
261 t.Fatal(err)
262 }
263 placement, ok := scene.Snapshot().Placement(placementID)
264 if !ok {
265 t.Fatal("updated placement missing")
266 }
267 if placement.Layer() != 0 {
268 t.Fatalf("layer = %d, want 0", placement.Layer())
269 }
270 if placement.Cells() != (CellRect{}) {
271 t.Fatalf("cells = %#v, want cleared", placement.Cells())
272 }
273}