Skip to content

Commit 24825f4

Browse files
obiotclaude
andcommitted
test: math-based screen-space verification of flip transforms
Address concern that the previous tests only verified call structure, not that the resulting transform was actually correct. New approach: replay the recorded translate/scale renderer calls into a real Matrix2d, then apply that matrix to the corners of drawPattern's destination rect. The resulting screen-space coordinates can be compared against the values the math says they should be. 6 new tests: - zoom=1, no flip → corners at (0,0) … (2W, 2H) - zoom=2, no flip → corners at (0,0) … (4W, 4H) - flipX zoom=1 → SAME screen footprint, horizontal corner swap - flipX zoom=2 → SAME screen footprint (the actual regression: this would FAIL on the original preDraw-based flip) - flipY zoom=2 → SAME screen footprint, vertical corner swap - flipX+flipY zoom=2 → corners rotate 180° around the rect centre These prove the algebra (pivot location, ordering with the zoom transforms) — not just that calls happen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 95b990f commit 24825f4

1 file changed

Lines changed: 250 additions & 1 deletion

File tree

packages/melonjs/tests/imagelayer.spec.js

Lines changed: 250 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeAll, describe, expect, it, vi } from "vitest";
2-
import { boot, game, ImageLayer, Rect, video } from "../src/index.js";
2+
import { boot, game, ImageLayer, Matrix2d, Rect, video } from "../src/index.js";
33

44
describe("ImageLayer", () => {
55
let testImage;
@@ -314,4 +314,253 @@ describe("ImageLayer", () => {
314314
expect(setMaskIdx).toBeGreaterThan(zoomScaleIdx);
315315
});
316316
});
317+
318+
describe("draw screen-space transform math (replays renderer calls)", () => {
319+
// Replays the recorded translate/scale calls into a real Matrix2d,
320+
// then applies the matrix to the corners of `drawPattern(0,0,W*2,H*2)`
321+
// to compute their final screen-space positions. This tests the actual
322+
// composed transform — not just that individual calls happened.
323+
function replayInto(matrix, events) {
324+
for (const e of events) {
325+
if (e.kind === "translate") {
326+
matrix.translate(e.args[0], e.args[1]);
327+
} else if (e.kind === "scale") {
328+
matrix.scale(e.args[0], e.args[1]);
329+
}
330+
}
331+
return matrix;
332+
}
333+
334+
function makeRendererStub(events) {
335+
return {
336+
save: vi.fn(),
337+
translate: vi.fn((x, y) => {
338+
events.push({ kind: "translate", args: [x, y] });
339+
}),
340+
scale: vi.fn((x, y) => {
341+
events.push({ kind: "scale", args: [x, y] });
342+
}),
343+
setMask: vi.fn(),
344+
drawPattern: vi.fn(),
345+
};
346+
}
347+
348+
function makeLayer() {
349+
const layer = new ImageLayer(0, 0, {
350+
image: testImage,
351+
name: "test",
352+
repeat: "no-repeat",
353+
});
354+
// pin the layer geometry — `pos = (0,0)`, anchor `(0,0)`, ratio `(0,0)`
355+
// (static), so the computed `x` / `y` in draw() are both 0
356+
layer.width = 64;
357+
layer.height = 64;
358+
return layer;
359+
}
360+
361+
function makeViewport({ zoom = 1, width = 800, height = 600 } = {}) {
362+
return {
363+
zoom,
364+
width,
365+
height,
366+
pos: { x: 0, y: 0 },
367+
bounds: { width, height },
368+
};
369+
}
370+
371+
// Corners of drawPattern's destination rect in local coordinates,
372+
// before any of the recorded transforms are applied. drawPattern is
373+
// always called with `(0, 0, viewport.width * 2, viewport.height * 2)`.
374+
function corners(viewport) {
375+
const W2 = viewport.width * 2;
376+
const H2 = viewport.height * 2;
377+
return {
378+
topLeft: { x: 0, y: 0 },
379+
topRight: { x: W2, y: 0 },
380+
bottomLeft: { x: 0, y: H2 },
381+
bottomRight: { x: W2, y: H2 },
382+
};
383+
}
384+
385+
// At pos=(0,0) anchor=(0,0) static, the unflipped pattern occupies
386+
// the screen-space rect (0, 0) → (W*2*Z, H*2*Z).
387+
function expectedUnflipped(viewport) {
388+
const W2 = viewport.width * 2;
389+
const H2 = viewport.height * 2;
390+
const Z = viewport.zoom;
391+
return {
392+
topLeft: { x: 0, y: 0 },
393+
topRight: { x: W2 * Z, y: 0 },
394+
bottomLeft: { x: 0, y: H2 * Z },
395+
bottomRight: { x: W2 * Z, y: H2 * Z },
396+
};
397+
}
398+
399+
function transformCorners(matrix, c) {
400+
const out = {};
401+
for (const k of Object.keys(c)) {
402+
out[k] = { x: c[k].x, y: c[k].y };
403+
matrix.apply(out[k]);
404+
}
405+
return out;
406+
}
407+
408+
function expectClose(actual, expected) {
409+
expect(actual.x).toBeCloseTo(expected.x, 6);
410+
expect(actual.y).toBeCloseTo(expected.y, 6);
411+
}
412+
413+
it("zoom=1, no flip: pattern occupies (0,0) → (2W, 2H) in screen space", () => {
414+
const events = [];
415+
const layer = makeLayer();
416+
const viewport = makeViewport({ zoom: 1 });
417+
layer.draw(makeRendererStub(events), viewport);
418+
419+
const actual = transformCorners(
420+
replayInto(new Matrix2d(), events),
421+
corners(viewport),
422+
);
423+
const exp = expectedUnflipped(viewport);
424+
expectClose(actual.topLeft, exp.topLeft);
425+
expectClose(actual.topRight, exp.topRight);
426+
expectClose(actual.bottomLeft, exp.bottomLeft);
427+
expectClose(actual.bottomRight, exp.bottomRight);
428+
});
429+
430+
it("zoom=2, no flip: pattern occupies (0,0) → (2W*2, 2H*2) in screen space", () => {
431+
const events = [];
432+
const layer = makeLayer();
433+
const viewport = makeViewport({ zoom: 2 });
434+
layer.draw(makeRendererStub(events), viewport);
435+
436+
const actual = transformCorners(
437+
replayInto(new Matrix2d(), events),
438+
corners(viewport),
439+
);
440+
const exp = expectedUnflipped(viewport);
441+
expectClose(actual.topLeft, exp.topLeft);
442+
expectClose(actual.topRight, exp.topRight);
443+
expectClose(actual.bottomLeft, exp.bottomLeft);
444+
expectClose(actual.bottomRight, exp.bottomRight);
445+
});
446+
447+
it("flipX at zoom=1: covers SAME screen rect as unflipped, but corners swap horizontally", () => {
448+
const events = [];
449+
const layer = makeLayer();
450+
layer.flipX(true);
451+
const viewport = makeViewport({ zoom: 1 });
452+
layer.draw(makeRendererStub(events), viewport);
453+
454+
const actual = transformCorners(
455+
replayInto(new Matrix2d(), events),
456+
corners(viewport),
457+
);
458+
const exp = expectedUnflipped(viewport);
459+
460+
// the screen-space footprint is identical (mirror, not translate)
461+
const actualXs = [
462+
actual.topLeft.x,
463+
actual.topRight.x,
464+
actual.bottomLeft.x,
465+
actual.bottomRight.x,
466+
];
467+
const expectedXs = [
468+
exp.topLeft.x,
469+
exp.topRight.x,
470+
exp.bottomLeft.x,
471+
exp.bottomRight.x,
472+
];
473+
expect(Math.min(...actualXs)).toBeCloseTo(Math.min(...expectedXs), 6);
474+
expect(Math.max(...actualXs)).toBeCloseTo(Math.max(...expectedXs), 6);
475+
476+
// the LEFT corner of the original rect now maps to the RIGHT side
477+
expectClose(actual.topLeft, exp.topRight);
478+
expectClose(actual.topRight, exp.topLeft);
479+
expectClose(actual.bottomLeft, exp.bottomRight);
480+
expectClose(actual.bottomRight, exp.bottomLeft);
481+
});
482+
483+
it("flipX at zoom=2: still covers the SAME screen rect (this is the fix)", () => {
484+
// Regression test for the original bug: at non-1 zoom, the pre-zoom
485+
// flip pivot would land in the wrong screen-space location.
486+
// With flip moved to draw() (post-zoom), the mirrored pattern still
487+
// occupies the SAME screen rect as the unflipped pattern at zoom=2.
488+
const events = [];
489+
const layer = makeLayer();
490+
layer.flipX(true);
491+
const viewport = makeViewport({ zoom: 2 });
492+
layer.draw(makeRendererStub(events), viewport);
493+
494+
const actual = transformCorners(
495+
replayInto(new Matrix2d(), events),
496+
corners(viewport),
497+
);
498+
const exp = expectedUnflipped(viewport);
499+
500+
const actualXs = [
501+
actual.topLeft.x,
502+
actual.topRight.x,
503+
actual.bottomLeft.x,
504+
actual.bottomRight.x,
505+
];
506+
const expectedXs = [
507+
exp.topLeft.x,
508+
exp.topRight.x,
509+
exp.bottomLeft.x,
510+
exp.bottomRight.x,
511+
];
512+
expect(Math.min(...actualXs)).toBeCloseTo(Math.min(...expectedXs), 6);
513+
expect(Math.max(...actualXs)).toBeCloseTo(Math.max(...expectedXs), 6);
514+
});
515+
516+
it("flipY at zoom=2: covers SAME screen rect (vertical mirror)", () => {
517+
const events = [];
518+
const layer = makeLayer();
519+
layer.flipY(true);
520+
const viewport = makeViewport({ zoom: 2 });
521+
layer.draw(makeRendererStub(events), viewport);
522+
523+
const actual = transformCorners(
524+
replayInto(new Matrix2d(), events),
525+
corners(viewport),
526+
);
527+
const exp = expectedUnflipped(viewport);
528+
529+
const actualYs = [
530+
actual.topLeft.y,
531+
actual.topRight.y,
532+
actual.bottomLeft.y,
533+
actual.bottomRight.y,
534+
];
535+
const expectedYs = [
536+
exp.topLeft.y,
537+
exp.topRight.y,
538+
exp.bottomLeft.y,
539+
exp.bottomRight.y,
540+
];
541+
expect(Math.min(...actualYs)).toBeCloseTo(Math.min(...expectedYs), 6);
542+
expect(Math.max(...actualYs)).toBeCloseTo(Math.max(...expectedYs), 6);
543+
});
544+
545+
it("flipX+flipY at zoom=2: corners rotate 180° around the rect centre", () => {
546+
const events = [];
547+
const layer = makeLayer();
548+
layer.flipX(true);
549+
layer.flipY(true);
550+
const viewport = makeViewport({ zoom: 2 });
551+
layer.draw(makeRendererStub(events), viewport);
552+
553+
const actual = transformCorners(
554+
replayInto(new Matrix2d(), events),
555+
corners(viewport),
556+
);
557+
const exp = expectedUnflipped(viewport);
558+
559+
// top-left ↔ bottom-right, top-right ↔ bottom-left
560+
expectClose(actual.topLeft, exp.bottomRight);
561+
expectClose(actual.topRight, exp.bottomLeft);
562+
expectClose(actual.bottomLeft, exp.topRight);
563+
expectClose(actual.bottomRight, exp.topLeft);
564+
});
565+
});
317566
});

0 commit comments

Comments
 (0)