176 lines
7.7 KiB
TypeScript
176 lines
7.7 KiB
TypeScript
/**
|
||
* T2.1 — CoordinateResolver unit tests
|
||
*
|
||
* Covers: 4 coordinate modes × Gemini yx swap × Retina/HiDPI × multi-display offset × boundary clamp
|
||
*/
|
||
|
||
import { describe, it, expect } from 'vitest';
|
||
import { resolveCoordinate, type ScreenshotMeta, type DisplayInfo } from '../../src/coordinates/resolver.js';
|
||
import type { ModelProfile } from '../../src/coordinates/modelProfiles.js';
|
||
|
||
// --- Helpers ---
|
||
|
||
const primaryDisplay: DisplayInfo = { origin: { x: 0, y: 0 }, bounds: { width: 1920, height: 1080 } };
|
||
const secondaryDisplay: DisplayInfo = { origin: { x: -2560, y: 0 }, bounds: { width: 2560, height: 1440 } };
|
||
const retinaMeta: ScreenshotMeta = { imageWidth: 1440, imageHeight: 900, logicalWidth: 1440, logicalHeight: 900 };
|
||
const fullHdMeta: ScreenshotMeta = { imageWidth: 1920, imageHeight: 1080, logicalWidth: 1920, logicalHeight: 1080 };
|
||
|
||
const xyProfile = (mode: ModelProfile['coordinateMode']): ModelProfile => ({ coordinateMode: mode, coordinateOrder: 'xy' });
|
||
const geminiProfile: ModelProfile = { coordinateMode: 'normalized-999', coordinateOrder: 'yx' };
|
||
|
||
// --- Tests ---
|
||
|
||
describe('resolveCoordinate', () => {
|
||
describe('image-absolute mode', () => {
|
||
it('converts absolute pixel coordinates on 1920x1080', () => {
|
||
const result = resolveCoordinate(960, 540, xyProfile('image-absolute'), fullHdMeta, primaryDisplay);
|
||
expect(result.globalX).toBe(960);
|
||
expect(result.globalY).toBe(540);
|
||
expect(result.normalizedX).toBeCloseTo(0.5, 2);
|
||
expect(result.normalizedY).toBeCloseTo(0.5, 2);
|
||
});
|
||
|
||
it('handles top-left corner (0, 0)', () => {
|
||
const result = resolveCoordinate(0, 0, xyProfile('image-absolute'), fullHdMeta, primaryDisplay);
|
||
expect(result.globalX).toBe(0);
|
||
expect(result.globalY).toBe(0);
|
||
});
|
||
|
||
it('handles bottom-right corner', () => {
|
||
const result = resolveCoordinate(1920, 1080, xyProfile('image-absolute'), fullHdMeta, primaryDisplay);
|
||
// 1920/1920 * 1920 = 1920, clamped to bounds.width-1 = 1919
|
||
expect(result.globalX).toBe(1919);
|
||
expect(result.globalY).toBe(1079);
|
||
});
|
||
});
|
||
|
||
describe('normalized-1000 mode', () => {
|
||
it('converts 500, 500 → center of display', () => {
|
||
const result = resolveCoordinate(500, 500, xyProfile('normalized-1000'), fullHdMeta, primaryDisplay);
|
||
expect(result.globalX).toBe(960);
|
||
expect(result.globalY).toBe(540);
|
||
});
|
||
|
||
it('converts 0, 0 → top-left', () => {
|
||
const result = resolveCoordinate(0, 0, xyProfile('normalized-1000'), fullHdMeta, primaryDisplay);
|
||
expect(result.globalX).toBe(0);
|
||
expect(result.globalY).toBe(0);
|
||
});
|
||
|
||
it('converts 1000, 1000 → bottom-right (clamped)', () => {
|
||
const result = resolveCoordinate(1000, 1000, xyProfile('normalized-1000'), fullHdMeta, primaryDisplay);
|
||
expect(result.globalX).toBe(1919);
|
||
expect(result.globalY).toBe(1079);
|
||
});
|
||
});
|
||
|
||
describe('normalized-999 mode', () => {
|
||
it('converts 499, 499 → approximate center', () => {
|
||
const result = resolveCoordinate(499, 499, xyProfile('normalized-999'), fullHdMeta, primaryDisplay);
|
||
// 499/999 ≈ 0.4995 → 0.4995 * 1920 ≈ 959
|
||
expect(result.globalX).toBeGreaterThanOrEqual(958);
|
||
expect(result.globalX).toBeLessThanOrEqual(960);
|
||
});
|
||
});
|
||
|
||
describe('normalized-0-1 mode', () => {
|
||
it('converts 0.5, 0.5 → center', () => {
|
||
const result = resolveCoordinate(0.5, 0.5, xyProfile('normalized-0-1'), fullHdMeta, primaryDisplay);
|
||
expect(result.globalX).toBe(960);
|
||
expect(result.globalY).toBe(540);
|
||
});
|
||
|
||
it('converts 0, 0 → top-left', () => {
|
||
const result = resolveCoordinate(0, 0, xyProfile('normalized-0-1'), fullHdMeta, primaryDisplay);
|
||
expect(result.globalX).toBe(0);
|
||
expect(result.globalY).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('Gemini yx coordinate order swap', () => {
|
||
it('swaps yx to xy before normalization', () => {
|
||
// Gemini outputs [y, x] = [499, 200] → should become rawX=200, rawY=499
|
||
const result = resolveCoordinate(499, 200, geminiProfile, fullHdMeta, primaryDisplay);
|
||
// rawX = 200 (from modelY), rawY = 499 (from modelX)
|
||
// normX = 200/999 ≈ 0.2002, normY = 499/999 ≈ 0.4995
|
||
// localX = 0.2002 * 1920 ≈ 384, localY = 0.4995 * 1080 ≈ 539
|
||
expect(result.normalizedX).toBeCloseTo(200 / 999, 3);
|
||
expect(result.normalizedY).toBeCloseTo(499 / 999, 3);
|
||
expect(result.globalX).toBeGreaterThanOrEqual(383);
|
||
expect(result.globalX).toBeLessThanOrEqual(385);
|
||
});
|
||
|
||
it('Gemini center point [500, 500] should still map to center', () => {
|
||
// [y=500, x=500] → swap → rawX=500, rawY=500
|
||
const result = resolveCoordinate(500, 500, geminiProfile, fullHdMeta, primaryDisplay);
|
||
expect(result.normalizedX).toBeCloseTo(500 / 999, 3);
|
||
expect(result.normalizedY).toBeCloseTo(500 / 999, 3);
|
||
});
|
||
});
|
||
|
||
describe('Retina display (scaleFactor absorbed at screenshot level)', () => {
|
||
// Retina: physical 2880x1800, scaleFactor=2, logical 1440x900
|
||
// After screenshot pipeline: imageWidth=1440, imageHeight=900 (already logical)
|
||
// logicalWidth=1440, logicalHeight=900
|
||
it('converts center on Retina display (image-absolute)', () => {
|
||
const result = resolveCoordinate(720, 450, xyProfile('image-absolute'), retinaMeta, {
|
||
origin: { x: 0, y: 0 },
|
||
bounds: { width: 1440, height: 900 },
|
||
});
|
||
expect(result.globalX).toBe(720);
|
||
expect(result.globalY).toBe(450);
|
||
expect(result.normalizedX).toBeCloseTo(0.5, 2);
|
||
expect(result.normalizedY).toBeCloseTo(0.5, 2);
|
||
});
|
||
});
|
||
|
||
describe('HiDPI Windows (scaleFactor=1.5)', () => {
|
||
// Physical 2880x1620, scaleFactor=1.5, logical 1920x1080
|
||
// Screenshot pipeline outputs imageWidth=1920, imageHeight=1080
|
||
const hidpiMeta: ScreenshotMeta = { imageWidth: 1920, imageHeight: 1080, logicalWidth: 1920, logicalHeight: 1080 };
|
||
|
||
it('converts absolute coords on HiDPI display', () => {
|
||
const result = resolveCoordinate(960, 540, xyProfile('image-absolute'), hidpiMeta, primaryDisplay);
|
||
expect(result.globalX).toBe(960);
|
||
expect(result.globalY).toBe(540);
|
||
});
|
||
});
|
||
|
||
describe('multi-display offset', () => {
|
||
it('applies negative origin offset for secondary display on left', () => {
|
||
// Secondary display at origin (-2560, 0), 2560x1440
|
||
const meta: ScreenshotMeta = { imageWidth: 1920, imageHeight: 1080, logicalWidth: 2560, logicalHeight: 1440 };
|
||
const result = resolveCoordinate(960, 540, xyProfile('image-absolute'), meta, secondaryDisplay);
|
||
// normX = 960/1920 = 0.5, localX = 0.5 * 2560 = 1280
|
||
// globalX = 1280 + (-2560) = -1280
|
||
expect(result.localX).toBe(1280);
|
||
expect(result.globalX).toBe(-1280);
|
||
expect(result.localY).toBe(720);
|
||
expect(result.globalY).toBe(720);
|
||
});
|
||
|
||
it('primary display has zero offset', () => {
|
||
const result = resolveCoordinate(100, 100, xyProfile('image-absolute'), fullHdMeta, primaryDisplay);
|
||
expect(result.localX).toBe(result.globalX);
|
||
expect(result.localY).toBe(result.globalY);
|
||
});
|
||
});
|
||
|
||
describe('boundary clamp', () => {
|
||
it('clamps coordinates exceeding display bounds', () => {
|
||
// Send coordinates beyond the display
|
||
const result = resolveCoordinate(2500, 1500, xyProfile('image-absolute'), fullHdMeta, primaryDisplay);
|
||
// normX = 2500/1920 ≈ 1.302, localX = 1.302 * 1920 ≈ 2500 → clamped to 1919
|
||
expect(result.globalX).toBe(1919);
|
||
expect(result.globalY).toBe(1079);
|
||
});
|
||
|
||
it('clamps negative coordinates', () => {
|
||
const result = resolveCoordinate(-100, -100, xyProfile('image-absolute'), fullHdMeta, primaryDisplay);
|
||
// normX = -100/1920 < 0, localX < 0 → clamped to 0
|
||
expect(result.globalX).toBe(0);
|
||
expect(result.globalY).toBe(0);
|
||
});
|
||
});
|
||
});
|