mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|||
|
|
import {
|
||
|
|
actualSizeZoom,
|
||
|
|
anchorPan,
|
||
|
|
canActualSize,
|
||
|
|
clampPan,
|
||
|
|
clampZoom,
|
||
|
|
fitScaleOf,
|
||
|
|
MAX_RENDER_DIM,
|
||
|
|
maxZoomOf,
|
||
|
|
percentOf,
|
||
|
|
renderSize,
|
||
|
|
toContentPoint,
|
||
|
|
wheelZoomFactor,
|
||
|
|
} from "@/hooks/zoom-pan-math";
|
||
|
|
|
||
|
|
// 4000x3000 image fit into an 800x600 frame -> fitScale 0.2 (downscaled).
|
||
|
|
const big = { natural: { w: 4000, h: 3000 }, fitted: { w: 800, h: 600 } };
|
||
|
|
// 800x600 image shown 1:1 -> fitScale 1.
|
||
|
|
const exact = { natural: { w: 800, h: 600 }, fitted: { w: 800, h: 600 } };
|
||
|
|
// 100x75 image upscaled to fill an 800x600 frame -> fitScale 8 (upscaled).
|
||
|
|
const tiny = { natural: { w: 100, h: 75 }, fitted: { w: 800, h: 600 } };
|
||
|
|
|
||
|
|
describe("fitScaleOf / percent", () => {
|
||
|
|
it("computes fit scale", () => {
|
||
|
|
expect(fitScaleOf(big)).toBeCloseTo(0.2, 6);
|
||
|
|
expect(fitScaleOf(exact)).toBeCloseTo(1, 6);
|
||
|
|
expect(fitScaleOf(tiny)).toBeCloseTo(8, 6);
|
||
|
|
});
|
||
|
|
it("shows actual-pixel percentage", () => {
|
||
|
|
expect(percentOf(1, big)).toBe(20);
|
||
|
|
expect(percentOf(5, big)).toBe(100);
|
||
|
|
expect(percentOf(1, exact)).toBe(100);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("clampZoom / maxZoom", () => {
|
||
|
|
it("never below fit", () => {
|
||
|
|
expect(clampZoom(0.3, big)).toBe(1);
|
||
|
|
});
|
||
|
|
it("reaches actual size plus headroom", () => {
|
||
|
|
expect(maxZoomOf(big)).toBeGreaterThanOrEqual(5);
|
||
|
|
expect(clampZoom(999, big)).toBe(maxZoomOf(big));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("actualSize", () => {
|
||
|
|
it("targets natural resolution for large images", () => {
|
||
|
|
expect(actualSizeZoom(big)).toBeCloseTo(5, 6);
|
||
|
|
expect(canActualSize(big)).toBe(true);
|
||
|
|
});
|
||
|
|
it("is disabled when the image is not larger than the frame", () => {
|
||
|
|
expect(canActualSize(exact)).toBe(false);
|
||
|
|
expect(canActualSize(tiny)).toBe(false);
|
||
|
|
expect(actualSizeZoom(tiny)).toBe(1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("wheelZoomFactor", () => {
|
||
|
|
it("zooms in on scroll up, out on scroll down", () => {
|
||
|
|
expect(wheelZoomFactor(-100)).toBeGreaterThan(1);
|
||
|
|
expect(wheelZoomFactor(100)).toBeLessThan(1);
|
||
|
|
expect(wheelZoomFactor(0)).toBe(1);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("anchorPan keeps the cursor point fixed", () => {
|
||
|
|
it("a content point under the cursor stays under it after zoom", () => {
|
||
|
|
const viewportCenter = { x: 400, y: 300 };
|
||
|
|
const cursor = { x: 550, y: 420 };
|
||
|
|
const pan = { x: 0, y: 0 };
|
||
|
|
const oldZoom = 1;
|
||
|
|
const newZoom = 2;
|
||
|
|
const newPan = anchorPan(pan, oldZoom, newZoom, cursor, viewportCenter);
|
||
|
|
const p = {
|
||
|
|
x: (cursor.x - viewportCenter.x - pan.x) / oldZoom,
|
||
|
|
y: (cursor.y - viewportCenter.y - pan.y) / oldZoom,
|
||
|
|
};
|
||
|
|
const screenAfter = {
|
||
|
|
x: viewportCenter.x + newPan.x + p.x * newZoom,
|
||
|
|
y: viewportCenter.y + newPan.y + p.y * newZoom,
|
||
|
|
};
|
||
|
|
expect(screenAfter.x).toBeCloseTo(cursor.x, 6);
|
||
|
|
expect(screenAfter.y).toBeCloseTo(cursor.y, 6);
|
||
|
|
});
|
||
|
|
it("is a no-op when zoom does not change", () => {
|
||
|
|
const pan = { x: 12, y: -7 };
|
||
|
|
expect(anchorPan(pan, 2, 2, { x: 10, y: 10 }, { x: 0, y: 0 })).toEqual(pan);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("clampPan", () => {
|
||
|
|
const fitted = { w: 800, h: 600 };
|
||
|
|
const viewport = { w: 800, h: 600 };
|
||
|
|
it("locks to center when content fits the viewport (zoom 1)", () => {
|
||
|
|
expect(clampPan({ x: 50, y: 50 }, 1, fitted, viewport)).toEqual({ x: 0, y: 0 });
|
||
|
|
});
|
||
|
|
it("bounds pan to the overflow when zoomed in", () => {
|
||
|
|
// zoom 2 -> content 1600x1200 -> overflowX (1600-800)/2 = 400, overflowY (1200-600)/2 = 300
|
||
|
|
expect(clampPan({ x: 999, y: -999 }, 2, fitted, viewport)).toEqual({ x: 400, y: -300 });
|
||
|
|
expect(clampPan({ x: 100, y: 100 }, 2, fitted, viewport)).toEqual({ x: 100, y: 100 });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("toContentPoint is transform-agnostic", () => {
|
||
|
|
const fitted = { w: 800, h: 600 };
|
||
|
|
it("maps screen to content at zoom 1", () => {
|
||
|
|
const rect = { left: 0, top: 0, width: 800, height: 600 };
|
||
|
|
expect(toContentPoint(400, 300, rect, fitted)).toEqual({ x: 400, y: 300 });
|
||
|
|
});
|
||
|
|
it("divides out a 2x zoom encoded in the rect", () => {
|
||
|
|
const rect = { left: -400, top: -300, width: 1600, height: 1200 };
|
||
|
|
expect(toContentPoint(400, 300, rect, fitted)).toEqual({ x: 400, y: 300 });
|
||
|
|
expect(toContentPoint(-400, -300, rect, fitted)).toEqual({ x: 0, y: 0 });
|
||
|
|
});
|
||
|
|
it("returns origin for a degenerate rect", () => {
|
||
|
|
expect(toContentPoint(10, 10, { left: 0, top: 0, width: 0, height: 0 }, fitted)).toEqual({
|
||
|
|
x: 0,
|
||
|
|
y: 0,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("renderSize", () => {
|
||
|
|
it("uses natural resolution when within the cap", () => {
|
||
|
|
expect(renderSize(big)).toEqual({ w: 4000, h: 3000 });
|
||
|
|
});
|
||
|
|
it("caps the longest side at MAX_RENDER_DIM", () => {
|
||
|
|
const huge = { natural: { w: 8000, h: 6000 }, fitted: { w: 800, h: 600 } };
|
||
|
|
const r = renderSize(huge);
|
||
|
|
expect(Math.max(r.w, r.h)).toBe(MAX_RENDER_DIM);
|
||
|
|
expect(r).toEqual({ w: 4096, h: 3072 });
|
||
|
|
});
|
||
|
|
it("floors at the fitted size for small upscaled images", () => {
|
||
|
|
expect(renderSize(tiny)).toEqual({ w: 800, h: 600 });
|
||
|
|
});
|
||
|
|
});
|