/** * Pixel art (#130): the cel format and its name, the drawing rules, how layers make a frame, the * edits and the one command that takes them back, the project file, and the picture path. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { CommandError } from '../../src/core/commands/Command'; import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../src/core/commands/pixelArtCommands'; import type { PaletteAsset, PixelArtAsset } from '../../src/core/model/Asset'; import { registerPictureSource } from '../../src/core/model/generated'; import { celFilesOf, colorAt, composeFrame, composeSheet, DEFAULT_PALETTE, findPixelFrame, PALETTE_KEY, paletteColors, pixelArtPicture, pixelArtRevision, pixelArtWornId, } from '../../src/core/model/pixelart'; import { type CelPixels, celFileName, decodeCel, emptyCel, encodeCel, isCelFile, sha256Hex, } from '../../src/core/model/pixelCel'; import { anchorOffset, ellipsePoints, floodFill, linePoints, mergeCels, rectPoints, resizeCel, stamp, } from '../../src/core/model/pixelDraw'; import { pixelArtState, withCanvasSize, withCels, withFrameAdded, withFrameMoved, withFrameRemoved, withLayerAdded, withLayerChanged, withLayerDuplicated, withLayerMergedDown, withLayerMoved, withLayerRemoved, } from '../../src/core/model/pixelEdit'; import { findAsset, type Project } from '../../src/core/model/Project'; import { resolvePicture } from '../../src/core/model/texture'; import { pictureChoices, textureRefLabel } from '../../src/core/properties/textureRef'; import type { ProjectStore } from '../../src/core/store/ProjectStore'; import { CelUploadStore } from '../../src/editor/pixelart/celUploadStore'; import { PixelCels } from '../../src/viewport/PixelCels'; import { fitZoom, pixelUnder, stepZoom } from '../../src/editor/pixelart/pixelView'; import { makeProject, roundTrip } from '../helpers'; const FILE_A = `${'a'.repeat(64)}.px`; const FILE_B = `${'b'.repeat(64)}.px`; function cel(width: number, height: number, rows: number[][]): CelPixels { return { width, height, data: Uint8Array.from(rows.flat()) }; } function art(project: Project): PixelArtAsset { const create = new CreateAssetCommand('pixelart', 'Art'); create.execute(project); return create.asset as PixelArtAsset; } describe('cel files', () => { it('hashes like SHA-256', () => { expect(sha256Hex(new Uint8Array())).toBe( 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', ); expect(sha256Hex(new TextEncoder().encode('abc'))).toBe( 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', ); // Two blocks: the padding has to spill into a second one. expect(sha256Hex(new TextEncoder().encode('a'.repeat(64)))).toBe( 'ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb', ); }); it('writes a header the server checks and reads it back', () => { const pixels = cel(3, 2, [ [1, 0, 2], [0, 3, 0], ]); const bytes = encodeCel(pixels); expect([...bytes.slice(0, 10)]).toEqual([0x47, 0x4d, 0x50, 0x58, 1, 0, 3, 0, 2, 0]); expect(decodeCel(bytes)).toEqual(pixels); expect(isCelFile(celFileName(bytes))).toBe(true); }); it('refuses a cel of the wrong length, version or size', () => { const bytes = encodeCel(emptyCel(2, 2)); expect(decodeCel(bytes.slice(0, -1))).toBeNull(); const version = bytes.slice(); version[4] = 2; expect(decodeCel(version)).toBeNull(); const huge = encodeCel(emptyCel(2, 2)); huge[6] = 0x01; huge[7] = 0x02; // 513 wide expect(decodeCel(huge)).toBeNull(); }); }); describe('drawing', () => { it('draws a line pixel by pixel, both ends included', () => { expect(linePoints({ x: 0, y: 0 }, { x: 3, y: 1 })).toEqual([ { x: 0, y: 0 }, { x: 1, y: 0 }, { x: 2, y: 1 }, { x: 3, y: 1 }, ]); expect(linePoints({ x: 2, y: 2 }, { x: 2, y: 2 })).toEqual([{ x: 2, y: 2 }]); }); it('draws the border of a rectangle, or all of it', () => { expect(rectPoints({ x: 3, y: 3 }, { x: 0, y: 0 }, false)).toHaveLength(12); expect(rectPoints({ x: 0, y: 0 }, { x: 3, y: 3 }, true)).toHaveLength(16); }); it('draws an ellipse whose outline has no gaps and whose fill leaves the corners', () => { const filled = ellipsePoints({ x: 0, y: 0 }, { x: 7, y: 7 }, true); const outline = ellipsePoints({ x: 0, y: 0 }, { x: 7, y: 7 }, false); const has = (list: { x: number; y: number }[], x: number, y: number) => list.some((p) => p.x === x && p.y === y); expect(has(filled, 0, 0)).toBe(false); expect(has(filled, 3, 3)).toBe(true); expect(has(outline, 3, 3)).toBe(false); expect(has(outline, 0, 3)).toBe(true); // Every outline pixel touches another one, so the ring is closed. for (const p of outline) expect(outline.some((q) => q !== p && Math.abs(q.x - p.x) <= 1 && Math.abs(q.y - p.y) <= 1)).toBe(true); // Symmetric in both axes. for (const p of filled) expect(has(filled, 7 - p.x, p.y) && has(filled, p.x, 7 - p.y)).toBe(true); }); it('stamps a brush centred on the point, an even one reaching right and down', () => { const pixels = emptyCel(6, 6); stamp(pixels, { x: 2, y: 2 }, 2, 5); expect(pixels.data.reduce((n, i) => n + (i === 5 ? 1 : 0), 0)).toBe(4); expect(pixels.data[2 * 6 + 2]).toBe(5); expect(pixels.data[3 * 6 + 3]).toBe(5); expect(pixels.data[1 * 6 + 1]).toBe(0); }); it('fills the area joined by edges, or every pixel of that colour', () => { const rows = [ [0, 1, 0], [1, 0, 0], [0, 0, 0], ]; const joined = cel(3, 3, rows); floodFill(joined, { x: 2, y: 2 }, 7, true); // The corner pixel is cut off by two edges and only touches the area diagonally. expect([...joined.data]).toEqual([0, 1, 7, 1, 7, 7, 7, 7, 7]); const every = cel(3, 3, rows); floodFill(every, { x: 2, y: 2 }, 7, false); expect([...every.data]).toEqual([7, 1, 7, 1, 7, 7, 7, 7, 7]); }); it('resizes about an anchor and cuts what sticks out', () => { const pixels = cel(2, 2, [ [1, 2], [3, 4], ]); expect([...resizeCel(pixels, 4, 2, { x: 0.5, y: 0 }).data]).toEqual([0, 1, 2, 0, 0, 3, 4, 0]); expect([...resizeCel(pixels, 1, 1, { x: 1, y: 1 }).data]).toEqual([4]); expect(anchorOffset(32, 40, 0.5)).toBe(4); }); it('merges a layer down: what the upper one paints covers the lower one', () => { const merged = mergeCels(cel(2, 1, [[1, 1]]), cel(2, 1, [[0, 2]])); expect([...merged.data]).toEqual([1, 2]); }); }); describe('composing', () => { const colors = [0xff0000, 0x0000ff]; it('lays the layers over each other in list order, the last on top', () => { const { project } = makeProject(); const asset = art(project); asset.width = 2; asset.height = 1; asset.layers = [ { id: 'low', name: 'Low', opacity: 1 }, { id: 'high', name: 'High', opacity: 1 }, ]; asset.frames = [{ id: 'f', cels: { low: FILE_A, high: FILE_B } }]; const read = (file: string) => file === FILE_A ? cel(2, 1, [[1, 1]]) : file === FILE_B ? cel(2, 1, [[0, 2]]) : null; const rgba = composeFrame(asset, colors, asset.frames[0]!, read); expect([...rgba!]).toEqual([255, 0, 0, 255, 0, 0, 255, 255]); }); it('mixes a layer by its opacity and leaves a hidden one out', () => { const { project } = makeProject(); const asset = art(project); asset.width = 1; asset.height = 1; asset.layers = [ { id: 'low', name: 'Low', opacity: 1 }, { id: 'high', name: 'High', opacity: 0.5 }, ]; asset.frames = [{ id: 'f', cels: { low: FILE_A, high: FILE_B } }]; const read = (file: string) => (file === FILE_A ? cel(1, 1, [[1]]) : cel(1, 1, [[2]])); expect([...composeFrame(asset, colors, asset.frames[0]!, read)!]).toEqual([128, 0, 128, 255]); asset.layers[1]!.hidden = true; expect([...composeFrame(asset, colors, asset.frames[0]!, read)!]).toEqual([255, 0, 0, 255]); }); it('is transparent where nothing paints, and waits while a cel is loading', () => { const { project } = makeProject(); const asset = art(project); asset.width = 1; asset.height = 1; const layer = asset.layers[0]!.id; asset.frames[0]!.cels[layer] = FILE_A; expect([...composeFrame(asset, colors, asset.frames[0]!, () => cel(1, 1, [[0]]))!]).toEqual([0, 0, 0, 0]); // An index past the palette's end is transparent too. expect([...composeFrame(asset, colors, asset.frames[0]!, () => cel(1, 1, [[9]]))!]).toEqual([0, 0, 0, 0]); expect(composeFrame(asset, colors, asset.frames[0]!, () => undefined)).toBeNull(); }); it('puts the frames side by side into one sheet', () => { const { project } = makeProject(); const asset = art(project); asset.width = 1; asset.height = 1; const layer = asset.layers[0]!.id; asset.frames = [ { id: 'f1', cels: { [layer]: FILE_A } }, { id: 'f2', cels: { [layer]: FILE_B } }, ]; const sheet = composeSheet(asset, colors, (file) => file === FILE_A ? cel(1, 1, [[1]]) : cel(1, 1, [[2]]), ); expect([...sheet!]).toEqual([255, 0, 0, 255, 0, 0, 255, 255]); }); it('reads colours by index, 0 being transparent', () => { expect(colorAt(colors, 0)).toBeNull(); expect(colorAt(colors, 2)).toBe(0x0000ff); expect(colorAt(colors, 3)).toBeNull(); }); }); describe('the picture', () => { it('changes its revision with the pixels, the palette and the layers, not with a name', () => { const { project } = makeProject(); const asset = art(project); const layer = asset.layers[0]!.id; const before = pixelArtRevision(asset, DEFAULT_PALETTE); asset.layers[0]!.name = 'Renamed'; expect(pixelArtRevision(asset, DEFAULT_PALETTE)).toBe(before); asset.frames[0]!.cels[layer] = FILE_A; const painted = pixelArtRevision(asset, DEFAULT_PALETTE); expect(painted).not.toBe(before); expect(pixelArtRevision(asset, [0x123456])).not.toBe(painted); asset.layers[0]!.opacity = 0.5; expect(pixelArtRevision(asset, DEFAULT_PALETTE)).not.toBe(painted); }); it('is a sheet of all frames, and a frame id reads as a cut of it', () => { const remove = registerPictureSource('pixelart', pixelArtPicture); try { const { project } = makeProject(); const asset = art(project); asset.width = 16; asset.height = 8; asset.frames.push({ id: 'second', cels: {} }); const whole = resolvePicture(project, asset.id); expect(whole).toMatchObject({ rect: null, sourceWidth: 32, sourceHeight: 8, smooth: false }); expect(whole!.file.startsWith(`gen:${asset.id}@`)).toBe(true); const second = resolvePicture(project, 'second'); expect(second).toMatchObject({ file: whole!.file, rect: { x: 16, y: 0, w: 16, h: 8 } }); expect(findPixelFrame(project, 'second')?.index).toBe(1); expect(textureRefLabel(project, 'second')).toBe('Art / Frame 2'); expect(pictureChoices(project).map((c) => c.label)).toEqual(['Art', 'Art / Frame 1', 'Art / Frame 2']); expect(pixelArtWornId(asset)).toBe(asset.frames[0]!.id); } finally { remove(); } }); it('with one frame is offered as the picture alone', () => { const remove = registerPictureSource('pixelart', pixelArtPicture); try { const { project } = makeProject(); const asset = art(project); expect(pictureChoices(project).map((c) => c.label)).toEqual(['Art']); expect(pixelArtWornId(asset)).toBe(asset.id); } finally { remove(); } }); it('is painted in the palette its row names, else in the default colours', () => { const { project } = makeProject(); const asset = art(project); expect(paletteColors(project, asset)).toBe(DEFAULT_PALETTE); const palette = new CreateAssetCommand('palette', 'Palette'); palette.execute(project); (palette.asset as PaletteAsset).colors = [0x112233]; asset.props[PALETTE_KEY] = palette.asset.id; expect(paletteColors(project, asset)).toEqual([0x112233]); }); }); describe('edits', () => { const start = () => { const { project, stack } = makeProject(); const asset = art(project); return { project, stack, asset, layer: asset.layers[0]!.id, frame: asset.frames[0]!.id }; }; it('sets cels and empties one with the empty name', () => { const { asset, layer, frame } = start(); const painted = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]); expect(painted.frames[0]!.cels[layer]).toBe(FILE_A); expect(withCels(painted, [{ frameId: frame, layerId: layer, file: '' }]).frames[0]!.cels).toEqual({}); // The state it was given is left alone. expect(asset.frames[0]!.cels).toEqual({}); }); it('adds, renames, hides, moves and removes layers, and keeps the last one', () => { const { asset, layer } = start(); let s = withLayerAdded(pixelArtState(asset), 'second'); expect(s.layers.map((l) => [l.id, l.name])).toEqual([ [layer, 'Layer 1'], ['second', 'Layer 2'], ]); s = withLayerChanged(s, 'second', { name: ' Ink ', hidden: true, opacity: 3 }); expect(s.layers[1]).toEqual({ id: 'second', name: 'Ink', hidden: true, opacity: 1 }); expect(withLayerChanged(s, 'second', { hidden: false }).layers[1]).not.toHaveProperty('hidden'); s = withLayerMoved(s, 'second', 0); expect(s.layers[0]!.id).toBe('second'); s = withLayerRemoved(s, 'second'); expect(() => withLayerRemoved(s, layer)).toThrow('at least one layer'); expect(() => withLayerChanged(s, layer, { name: ' ' })).toThrow('needs a name'); }); it('merges a layer down into the cels it is handed', () => { const { asset, layer, frame } = start(); let s = withLayerAdded(pixelArtState(asset), 'top'); s = withCels(s, [{ frameId: frame, layerId: 'top', file: FILE_B }]); s = withLayerMergedDown(s, 'top', { [frame]: FILE_A }); expect(s.layers.map((l) => l.id)).toEqual([layer]); expect(s.frames[0]!.cels).toEqual({ [layer]: FILE_A }); expect(() => withLayerMergedDown(s, layer, {})).toThrow('no layer below'); }); it('duplicates a layer right above it, naming the same cels on every frame', () => { const { asset, layer, frame } = start(); let s = withLayerAdded(pixelArtState(asset), 'top'); s = withLayerChanged(s, layer, { opacity: 0.5, hidden: true }); s = withCels(s, [{ frameId: frame, layerId: layer, file: FILE_A }]); s = withLayerDuplicated(s, 'copy', layer); s = withLayerDuplicated(s, 'again', layer); expect(s.layers.map((l) => [l.id, l.name])).toEqual([ [layer, 'Layer 1'], ['again', 'Layer 1 copy 2'], ['copy', 'Layer 1 copy'], ['top', 'Layer 2'], ]); expect(s.layers[2]).toMatchObject({ opacity: 0.5, hidden: true }); expect(s.frames[0]!.cels).toMatchObject({ [layer]: FILE_A, copy: FILE_A, again: FILE_A }); expect(s.frames[0]!.cels).not.toHaveProperty('top'); expect(() => withLayerDuplicated(s, 'x', 'nobody')).toThrow('not found'); }); it('adds a frame empty or as a copy, moves it and keeps the last one', () => { const { asset, layer, frame } = start(); let s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]); s = withFrameAdded(s, 'copy', frame, frame); s = withFrameAdded(s, 'blank', frame); expect(s.frames.map((f) => [f.id, f.cels[layer] ?? ''])).toEqual([ [frame, FILE_A], ['blank', ''], ['copy', FILE_A], ]); s = withFrameMoved(s, 'copy', 0); expect(s.frames[0]!.id).toBe('copy'); s = withFrameRemoved(withFrameRemoved(s, 'copy'), 'blank'); expect(() => withFrameRemoved(s, frame)).toThrow('at least one frame'); }); it('resizes by swapping every cel for its counterpart', () => { const { asset, layer, frame } = start(); const s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]); const resized = withCanvasSize(s, 40, 16, { [FILE_A]: FILE_B }); expect(resized).toMatchObject({ width: 40, height: 16 }); expect(resized.frames[0]!.cels[layer]).toBe(FILE_B); expect(() => withCanvasSize(s, 40, 16, {})).toThrow('no counterpart'); expect(() => withCanvasSize(s, 513, 16, { [FILE_A]: FILE_B })).toThrow('1 to 512'); }); it('is one undo step per command, and a refused edit changes nothing', () => { const { project, stack, asset, layer, frame } = start(); stack.execute( new EditPixelArtCommand(asset.id, 'Pencil', (s) => withCels(s, [{ frameId: frame, layerId: layer, file: FILE_A }]), ), ); stack.execute(new EditPixelArtCommand(asset.id, 'Add layer', (s) => withLayerAdded(s, 'top'))); const current = findAsset(project, asset.id) as PixelArtAsset; expect(current.layers).toHaveLength(2); stack.undo(); expect(current.layers).toHaveLength(1); stack.undo(); expect(current.frames[0]!.cels).toEqual({}); stack.redo(); expect(current.frames[0]!.cels[layer]).toBe(FILE_A); const refused = new EditPixelArtCommand(asset.id, 'Delete layer', (s) => withLayerRemoved(s, layer)); expect(() => refused.execute(project)).toThrow(CommandError); expect(current.layers).toHaveLength(1); }); it('sets the colours of a palette and takes them back', () => { const { project, stack } = makeProject(); const create = new CreateAssetCommand('palette', 'Palette'); stack.execute(create); const palette = create.asset as PaletteAsset; expect(palette.colors).toEqual(DEFAULT_PALETTE); stack.execute(new SetPaletteColorsCommand(palette.id, 'Palette colour', [0x010203])); expect(palette.colors).toEqual([0x010203]); stack.undo(); expect(palette.colors).toEqual(DEFAULT_PALETTE); expect(() => new SetPaletteColorsCommand(palette.id, 'x', [0x1000000]).execute(project)).toThrow( CommandError, ); }); }); describe('the project file', () => { it('brings pixel art and palettes back as they were', () => { const { project } = makeProject(); const asset = art(project); const layer = asset.layers[0]!.id; asset.width = 48; asset.layers.push({ id: 'ink', name: 'Ink', opacity: 0.25, hidden: true }); asset.frames[0]!.cels = { [layer]: FILE_A, ink: FILE_B }; asset.frames.push({ id: 'second', cels: {} }); const palette = new CreateAssetCommand('palette', 'Palette'); palette.execute(project); const loaded = roundTrip(project); expect(findAsset(loaded, asset.id)).toEqual(asset); expect(findAsset(loaded, palette.asset.id)).toEqual(palette.asset); }); it('drops what it cannot trust and keeps something to paint on', () => { const { project } = makeProject(); const raw = JSON.parse(JSON.stringify(project)) as { assets: unknown[] }; raw.assets.push( { id: 'bad', type: 'pixelart', name: 'Bad', props: {}, width: 9999, height: 'x', layers: [{ id: 'l', name: 'L', opacity: 7 }, { id: 'l' }, { name: 'no id' }], frames: [ { id: 'f', cels: { l: FILE_A, gone: FILE_B, l2: 'not-a-file' } }, { id: 'f' }, { id: 'bad' }, ], }, { id: 'empty', type: 'pixelart', name: 'Empty', props: {} }, { id: 'pal', type: 'palette', name: 'Pal', props: {}, colors: [1, 'x', 0x1ffffff, 2.7] }, ); const loaded = roundTrip(raw as unknown as Project); const bad = findAsset(loaded, 'bad') as PixelArtAsset; expect(bad).toMatchObject({ width: 512, height: 32 }); expect(bad.layers).toEqual([{ id: 'l', name: 'L', opacity: 1 }]); // A frame may not reuse its own asset's id, nor another frame's. expect(bad.frames).toEqual([{ id: 'f', cels: { l: FILE_A } }]); const empty = findAsset(loaded, 'empty') as PixelArtAsset; expect(empty.layers).toHaveLength(1); expect(empty.frames).toHaveLength(1); expect((findAsset(loaded, 'pal') as PaletteAsset).colors).toEqual([1, 0xffffff, 2]); expect(celFilesOf(bad)).toEqual([FILE_A]); }); }); describe('cels in the browser', () => { it('names painted pixels at once, keeps them, and uploads what the project names', async () => { const cels = new PixelCels(); const pixels = cel(2, 1, [[1, 0]]); const file = cels.put(pixels); expect(file).toBe(celFileName(encodeCel(pixels))); expect(cels.put(emptyCel(2, 1))).toBe(''); // A copy is kept: painting on on the array handed in changes nothing stored. pixels.data[0] = 9; expect([...cels.read(file)!.data]).toEqual([1, 0]); const { project } = makeProject(); const asset = art(project); const unnamed = cels.put(cel(1, 1, [[3]])); asset.frames[0]!.cels[asset.layers[0]!.id] = file; expect(cels.pending(project)).toEqual([file]); const sent: string[] = []; await cels.upload(project, { upload: (bytes) => { const name = celFileName(new Uint8Array(bytes)); sent.push(name); return Promise.resolve({ file: name }); }, }); expect(sent).toEqual([file]); expect(cels.pending(project)).toEqual([]); expect(unnamed).not.toBe(file); }); it('fails the upload when the server names the bytes differently', async () => { const cels = new PixelCels(); const { project } = makeProject(); const asset = art(project); asset.frames[0]!.cels[asset.layers[0]!.id] = cels.put(cel(1, 1, [[1]])); await expect(cels.upload(project, { upload: () => Promise.resolve({ file: FILE_A }) })).rejects.toThrow( 'The server stored pixels as', ); expect(cels.pending(project)).toHaveLength(1); }); }); describe('saving', () => { const inner = (log: string[]): ProjectStore => ({ list: () => Promise.resolve([]), load: () => Promise.reject(new Error('unused')), save: () => { log.push('save'); return Promise.resolve(2); }, create: () => Promise.reject(new Error('unused')), delete: () => Promise.resolve(), }); it('uploads the painted cels before the project that names them', async () => { const log: string[] = []; const cels = new PixelCels(); const { project } = makeProject(); const asset = art(project); asset.frames[0]!.cels[asset.layers[0]!.id] = cels.put(cel(1, 1, [[1]])); const store = new CelUploadStore(inner(log), cels, { upload: (bytes) => { log.push('upload'); return Promise.resolve({ file: celFileName(new Uint8Array(bytes)) }); }, }); expect(await store.save(project, 1)).toBe(2); expect(log).toEqual(['upload', 'save']); }); it('does not save a project whose cels could not be uploaded', async () => { const log: string[] = []; const cels = new PixelCels(); const { project } = makeProject(); const asset = art(project); asset.frames[0]!.cels[asset.layers[0]!.id] = cels.put(cel(1, 1, [[1]])); const store = new CelUploadStore(inner(log), cels, { upload: () => Promise.reject(new Error('offline')), }); await expect(store.save(project, 1)).rejects.toThrow('offline'); expect(log).toEqual([]); }); }); describe('the view', () => { it('zooms in whole steps and fits the largest one', () => { expect(fitZoom(32, 32, 500, 400)).toBe(8); expect(fitZoom(512, 512, 300, 300)).toBe(1); expect(stepZoom(8, 1)).toBe(12); expect(stepZoom(8, -1)).toBe(6); expect(stepZoom(32, 1)).toBe(32); expect(stepZoom(1, -1)).toBe(1); }); it('finds the pixel under the pointer, outside the canvas too', () => { const view = { panX: 10, panY: 20, zoom: 8 }; expect(pixelUnder(view, 10, 20)).toEqual({ x: 0, y: 0 }); expect(pixelUnder(view, 17.9, 27.9)).toEqual({ x: 0, y: 0 }); expect(pixelUnder(view, 9, 20)).toEqual({ x: -1, y: 0 }); }); });