A 4K60 game view recording on the user's machine had 517 of 827 frames. The rest were skipped without a word: when the editor's tick came late, FrameGate let the due slots pass, and only frames lost to a busy encoder or read back counted as dropped. The file kept its time but stuttered, and nothing said so. - FrameGate counts the frames it took and the slots the recording's time has room for. The difference is skipped, next to dropped in __gm.recording(). - FramePace judges the missed frames over spans of 2 s. The first time more than 10 % are missed, the log warns once with the frames a second the recording really got. - At the end, when more than 5 % are missed, a warning gives the frames of the whole file and what helps (30 fps or a smaller resolution). It comes after the upload's notice, which would cover it at once in the editor. - The first second counts for neither warning. Without that, a short e2e recording warned about 3 frames lost while starting. - A recording that starts logs an info line with its size, fps, codec and sound. TakeHost.log takes a level for it. The editor recording keeps the line to the console, its notice would be in the video. - consoleLine is the one place with an eslint exception for console.info, so the console panel shows the line plain. Found along the way: headless Chrome in WSL records the editor with real stutter in e2e 67 (1280x800@60 27 %, 2304x1440@30 12-14 %). That is the software encoder there, and only now shown. Tests: - Gate on melektaus-atx: green, 309 files, 4810 tests. - Unit recording.test.ts: 36 green, 6 new for FrameGate.skipped and FramePace. With skipped stuck at 0 the test turns red. - e2e 67 on melektaus-atx: ALL PASSED, 15 checks, run twice. The new 67b-ii slows the editor to about 20 fps: a 640x360@60 recording says "about 19 of 60 fps" and saves with 95 of 192 frames. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
466 lines
18 KiB
TypeScript
466 lines
18 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
CURSOR_SIZE,
|
||
cursorCss,
|
||
cursorKeyword,
|
||
cursorKeywords,
|
||
cursorShape,
|
||
cursorShapes,
|
||
cursorSvgAt,
|
||
readCursor,
|
||
} from '../../src/core/recording/cursors';
|
||
import { editorRecordingPlan, editorRecordingSettingsOf } from '../../src/core/recording/editorRecording';
|
||
import {
|
||
avcCodec,
|
||
editorRecordingSize,
|
||
elapsedText,
|
||
fitPicture,
|
||
FrameGate,
|
||
FramePace,
|
||
i420Layout,
|
||
isKeyFrame,
|
||
pictureTransform,
|
||
RecordingClock,
|
||
recordingName,
|
||
recordingSettingsOf,
|
||
recordingSize,
|
||
} from '../../src/core/recording/recording';
|
||
import { requireNodeDef } from '../../src/core/commands/require';
|
||
import { runDef, fakeContext } from '../helpers';
|
||
|
||
describe('recording settings (#405)', () => {
|
||
it('names a recording after its start when no name is given', () => {
|
||
const at = new Date(2026, 9, 11, 16, 42, 5);
|
||
expect(recordingName('', at)).toBe('Recording 2026-10-11 16:42:05');
|
||
expect(recordingName(' ', at)).toBe('Recording 2026-10-11 16:42:05');
|
||
expect(recordingName(' Boss fight ', at)).toBe('Boss fight');
|
||
});
|
||
|
||
it('reads what the node hands over, an unknown choice as the default', () => {
|
||
expect(recordingSettingsOf(' Run ', '1080p', '30', { sound: false, adopt: false })).toEqual({
|
||
name: 'Run',
|
||
resolution: '1080p',
|
||
fps: 30,
|
||
sound: false,
|
||
convert: true,
|
||
adopt: false,
|
||
});
|
||
// The flags left out are on, as in "Start recording…".
|
||
expect(recordingSettingsOf('', 'huge', 25)).toEqual({
|
||
name: '',
|
||
resolution: 'view',
|
||
fps: 60,
|
||
sound: true,
|
||
convert: true,
|
||
adopt: true,
|
||
});
|
||
});
|
||
|
||
it('records the game view at its size on screen, even on both sides', () => {
|
||
expect(recordingSize('view', { w: 1279, h: 721 }, { w: 1279, h: 721 })).toEqual({ w: 1280, h: 722 });
|
||
// A 5K screen is held to 4K.
|
||
expect(recordingSize('view', { w: 5120, h: 2880 }, { w: 5120, h: 2880 })).toEqual({ w: 3840, h: 2160 });
|
||
});
|
||
|
||
it('holds the height of 720p to 4K and takes the width from the camera', () => {
|
||
expect(recordingSize('1080p', { w: 800, h: 450 }, { w: 800, h: 450 })).toEqual({ w: 1920, h: 1080 });
|
||
expect(recordingSize('720p', { w: 400, h: 400 }, { w: 400, h: 400 })).toEqual({ w: 720, h: 720 });
|
||
// 4:3 at 720p, and the camera's whole picture rules where the screen cuts it.
|
||
expect(recordingSize('720p', { w: 300, h: 400 }, { w: 640, h: 480 })).toEqual({ w: 960, h: 720 });
|
||
expect(recordingSize('1440p', { w: 800, h: 450 }, { w: 800, h: 450 })).toEqual({ w: 2560, h: 1440 });
|
||
expect(recordingSize('4k', { w: 800, h: 450 }, { w: 800, h: 450 })).toEqual({ w: 3840, h: 2160 });
|
||
expect(recordingSize('4k', { w: 400, h: 400 }, { w: 400, h: 400 })).toEqual({ w: 2160, h: 2160 });
|
||
});
|
||
|
||
it('scales a camera wider than 16:9 down to fit 4K', () => {
|
||
// 21:9 at 4K would be 5040 wide.
|
||
expect(recordingSize('4k', { w: 2100, h: 900 }, { w: 2100, h: 900 })).toEqual({ w: 3840, h: 1646 });
|
||
// At 1440p it still fits: 3360 × 1440.
|
||
expect(recordingSize('1440p', { w: 2100, h: 900 }, { w: 2100, h: 900 })).toEqual({ w: 3360, h: 1440 });
|
||
});
|
||
|
||
it('lays the I420 planes of a frame read back from the GPU out on whole texels', () => {
|
||
// 1920 is a multiple of 8, 1080 / 2 rows of chroma fill 270 texel rows a plane.
|
||
expect(i420Layout(1920, 1080)).toEqual({
|
||
stride: 1920,
|
||
chromaRows: 270,
|
||
texels: { w: 480, h: 1620 },
|
||
bytes: 1920 * 1620,
|
||
layout: [
|
||
{ offset: 0, stride: 1920 },
|
||
{ offset: 1920 * 1080, stride: 960 },
|
||
{ offset: 1920 * 1350, stride: 960 },
|
||
],
|
||
});
|
||
// 1646 is not: a luma row takes 1648 bytes. 722 / 2 = 361 chroma rows, odd, so the last texel
|
||
// row of each plane is half used.
|
||
const odd = i420Layout(1646, 722);
|
||
expect(odd.stride).toBe(1648);
|
||
expect(odd.chromaRows).toBe(181);
|
||
expect(odd.texels).toEqual({ w: 412, h: 722 + 362 });
|
||
expect(odd.layout[2]!.offset - odd.layout[1]!.offset).toBe(1648 * 181);
|
||
expect(odd.layout[1]!.stride * 361).toBeLessThanOrEqual(1648 * 181);
|
||
expect(odd.bytes).toBe(odd.layout[2]!.offset + 1648 * 181);
|
||
});
|
||
|
||
it('picks the lowest H.264 level that takes the frames', () => {
|
||
expect(avcCodec(1280, 720, 30)).toBe('avc1.64001f');
|
||
expect(avcCodec(1920, 1080, 30)).toBe('avc1.640028');
|
||
expect(avcCodec(1920, 1080, 60)).toBe('avc1.64002a');
|
||
expect(avcCodec(2560, 1440, 60)).toBe('avc1.640033');
|
||
expect(avcCodec(3840, 2160, 60)).toBe('avc1.640034');
|
||
});
|
||
|
||
it('starts a group every two seconds', () => {
|
||
expect([0, 1, 119, 120, 240].map((i) => isKeyFrame(i, 60))).toEqual([true, false, false, true, true]);
|
||
expect(isKeyFrame(60, 30)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('RecordingClock', () => {
|
||
it('counts from the start and leaves the editor pause out', () => {
|
||
const clock = new RecordingClock();
|
||
expect(clock.at(500)).toBe(0);
|
||
clock.start(1000);
|
||
expect(clock.at(1500)).toBe(500);
|
||
clock.pause(2000);
|
||
expect(clock.at(5000)).toBe(1000);
|
||
clock.resume(6000);
|
||
expect(clock.at(6500)).toBe(1500);
|
||
// Pausing twice and resuming once still takes out the whole pause.
|
||
clock.pause(7000);
|
||
clock.pause(7500);
|
||
clock.resume(8000);
|
||
expect(clock.at(8000)).toBe(2000);
|
||
});
|
||
|
||
it('leaves a hold before the first frame out of the time, not off it', () => {
|
||
const clock = new RecordingClock();
|
||
clock.pause(0);
|
||
clock.resume(2000);
|
||
clock.start(2000);
|
||
expect(clock.at(2500)).toBe(500);
|
||
});
|
||
});
|
||
|
||
describe('FrameGate', () => {
|
||
const taken = (fps: number, frameMs: number, seconds: number, jitter = 0) => {
|
||
const gate = new FrameGate(fps);
|
||
let count = 0;
|
||
for (let i = 0; i * frameMs < seconds * 1000; i++) {
|
||
const wobble = jitter ? ((i * 7919) % 11) / 10 - 0.5 : 0;
|
||
if (gate.take(Math.max(0, i * frameMs + wobble * jitter))) count++;
|
||
}
|
||
return count;
|
||
};
|
||
|
||
it('takes every frame at 60 on a 60 Hz screen, however unevenly they come', () => {
|
||
expect(taken(60, 1000 / 60, 10)).toBe(600);
|
||
expect(taken(60, 1000 / 60, 10, 6)).toBe(600);
|
||
});
|
||
|
||
it('takes every other at 30', () => {
|
||
expect(taken(30, 1000 / 60, 10)).toBe(300);
|
||
});
|
||
|
||
it('takes about 60 a second from a 144 Hz screen', () => {
|
||
expect(Math.abs(taken(60, 1000 / 144, 10) - 600)).toBeLessThanOrEqual(6);
|
||
});
|
||
|
||
const gateAfter = (fps: number, gaps: number[], seconds: number) => {
|
||
const gate = new FrameGate(fps);
|
||
for (let i = 0, ms = 0; ms < seconds * 1000; ms += gaps[i++ % gaps.length]!) gate.take(ms);
|
||
return gate;
|
||
};
|
||
|
||
it('counts no frame skipped while the screen keeps up', () => {
|
||
expect(gateAfter(60, [1000 / 60], 10).skipped).toBe(0);
|
||
expect(gateAfter(30, [1000 / 60], 10).skipped).toBe(0);
|
||
expect(gateAfter(60, [1000 / 144], 10).skipped).toBeLessThanOrEqual(1);
|
||
});
|
||
|
||
it('counts the frames a slow screen skips, as many as the recording misses', () => {
|
||
// As in a 4K recording on the user's machine: 18 and 35 ms in turn, about 38 a second.
|
||
const slow = gateAfter(60, [18, 35], 10);
|
||
expect(slow.slots).toBeGreaterThanOrEqual(599);
|
||
expect(slow.slots).toBeLessThanOrEqual(601);
|
||
expect(slow.skipped).toBe(slow.slots - 378);
|
||
// A 50 Hz screen leaves out one in six of 60, however evenly the frames come.
|
||
const fifty = gateAfter(60, [20], 10);
|
||
expect(Math.abs(fifty.skipped - 100)).toBeLessThanOrEqual(2);
|
||
});
|
||
});
|
||
|
||
describe('FramePace', () => {
|
||
/** What `behind` said, frame by frame, of a recording that misses `missedPerSecond`. */
|
||
const said = (fps: number, missedPerSecond: (s: number) => number, seconds: number) => {
|
||
const pace = new FramePace(fps);
|
||
const told: { at: number; got: number }[] = [];
|
||
let missed = 0;
|
||
for (let ms = 0; ms <= seconds * 1000; ms += 1000 / fps) {
|
||
missed += missedPerSecond(ms / 1000) / fps;
|
||
const got = pace.behind(ms, Math.round(missed));
|
||
if (got !== null) told.push({ at: Math.round(ms), got });
|
||
}
|
||
return told;
|
||
};
|
||
|
||
it('says once that a recording falls behind, with the frames a second it gets', () => {
|
||
const told = said(60, () => 22, 20);
|
||
expect(told).toHaveLength(1);
|
||
expect(told[0]!.got).toBe(38);
|
||
// It has settled for a second and then judged two.
|
||
expect(told[0]!.at).toBeGreaterThanOrEqual(3000);
|
||
expect(told[0]!.at).toBeLessThan(3100);
|
||
});
|
||
|
||
it('lets a stall while it starts and a few frames missed pass', () => {
|
||
// Half a second lost at the start, then 5 % missed: never more than a tenth over a span.
|
||
expect(said(60, (s) => (s < 0.5 ? 60 : 3), 20)).toEqual([]);
|
||
});
|
||
|
||
it('says at the end that a recording stutters only when it missed frames after it settled', () => {
|
||
const after = (missedPerSecond: (s: number) => number, seconds: number) => {
|
||
const pace = new FramePace(60);
|
||
let missed = 0;
|
||
for (let ms = 0; ms <= seconds * 1000; ms += 1000 / 60) {
|
||
missed += missedPerSecond(ms / 1000) / 60;
|
||
pace.behind(ms, Math.round(missed));
|
||
}
|
||
return pace.stutters;
|
||
};
|
||
// A stall while it starts, in a short recording too: e2e 67 missed 3 of 37 frames so.
|
||
expect(after((s) => (s < 0.3 ? 60 : 0), 1.2)).toBe(false);
|
||
expect(after((s) => (s < 0.5 ? 60 : 1), 20)).toBe(false);
|
||
expect(after(() => 22, 13.8)).toBe(true);
|
||
expect(after(() => 6, 20)).toBe(true);
|
||
// Shorter than its first second: nothing to judge.
|
||
expect(after(() => 30, 0.9)).toBe(false);
|
||
});
|
||
|
||
it('says it when a recording falls behind only later', () => {
|
||
const told = said(30, (s) => (s < 7 ? 0 : 12), 20);
|
||
expect(told).toHaveLength(1);
|
||
expect(told[0]!.got).toBe(18);
|
||
expect(told[0]!.at).toBeGreaterThan(7000);
|
||
});
|
||
});
|
||
|
||
describe('pictureTransform', () => {
|
||
it('maps the picture onto the whole frame', () => {
|
||
// A picture of 640x360 CSS pixels centred at (400, 300), recorded at 1080p.
|
||
const area = { x: 400, y: 300, w: 640, h: 360 };
|
||
const m = pictureTransform(area, { w: 1920, h: 1080 });
|
||
const at = (x: number, y: number) => ({ x: m.a * x + m.tx, y: m.d * y + m.ty });
|
||
expect(at(80, 120)).toEqual({ x: 0, y: 0 });
|
||
expect(at(720, 480)).toEqual({ x: 1920, y: 1080 });
|
||
expect(at(400, 300)).toEqual({ x: 960, y: 540 });
|
||
});
|
||
});
|
||
|
||
describe('the recording nodes', () => {
|
||
it('Start recording hands its settings over and flows on', () => {
|
||
const ctx = fakeContext();
|
||
const result = runDef('StartRecording', ctx, { name: 'Intro', resolution: '720p', fps: '30' }, {});
|
||
expect(result.next).toBe('exec');
|
||
expect(ctx.recordings).toEqual([
|
||
{ name: 'Intro', resolution: '720p', fps: 30, sound: true, convert: true, adopt: true },
|
||
]);
|
||
});
|
||
|
||
it('Start recording asks for the game view at 60, with sound, converted and imported by default', () => {
|
||
const ctx = fakeContext();
|
||
runDef('StartRecording', ctx, {}, {});
|
||
expect(ctx.recordings).toEqual([
|
||
{ name: '', resolution: 'view', fps: 60, sound: true, convert: true, adopt: true },
|
||
]);
|
||
});
|
||
|
||
it('Start recording hands its flags over', () => {
|
||
const ctx = fakeContext();
|
||
runDef('StartRecording', ctx, { sound: false, convert: true, adopt: false }, {});
|
||
expect(ctx.recordings).toEqual([
|
||
{ name: '', resolution: 'view', fps: 60, sound: false, convert: true, adopt: false },
|
||
]);
|
||
});
|
||
|
||
it('Start recording offers the flags of "Start recording…", all on', () => {
|
||
const fields = requireNodeDef('StartRecording').inputs.flatMap((port) =>
|
||
port.field ? [port.field] : [],
|
||
);
|
||
expect(fields.slice(3).map((f) => [f.label, f.default])).toEqual([
|
||
['Sound', true],
|
||
['Convert', true],
|
||
['Import after conversion', true],
|
||
]);
|
||
});
|
||
|
||
it('Stop recording asks for the stop and flows on', () => {
|
||
const ctx = fakeContext();
|
||
expect(runDef('StopRecording', ctx, {}, {}).next).toBe('exec');
|
||
expect(ctx.recordings).toEqual(['stop']);
|
||
});
|
||
});
|
||
|
||
describe('recording the editor', () => {
|
||
it('records the whole canvas, an odd row or column cut off', () => {
|
||
expect(editorRecordingSize({ w: 2560, h: 1369 })).toEqual({ w: 2560, h: 1368 });
|
||
expect(editorRecordingSize({ w: 1281, h: 721 })).toEqual({ w: 1280, h: 720 });
|
||
});
|
||
|
||
it('puts the canvas where it is while its size is the one it started with', () => {
|
||
expect(fitPicture({ w: 1281, h: 720 }, { w: 1280, h: 720 })).toEqual({ x: 0, y: 0, w: 1281, h: 720 });
|
||
});
|
||
|
||
it('fits a resized window in whole and centres it', () => {
|
||
expect(fitPicture({ w: 1000, h: 720 }, { w: 1280, h: 720 })).toEqual({ x: 140, y: 0, w: 1000, h: 720 });
|
||
expect(fitPicture({ w: 2560, h: 720 }, { w: 1280, h: 720 })).toEqual({ x: 0, y: 180, w: 1280, h: 360 });
|
||
});
|
||
|
||
it('records the screen as it is, at the resolution it is drawn at', () => {
|
||
expect(editorRecordingPlan({ w: 1280, h: 685 }, 1.25, 'screen')).toEqual({
|
||
size: { w: 1600, h: 856 },
|
||
resolution: 1.25,
|
||
});
|
||
});
|
||
|
||
it('draws sharper for a larger frame, fitted into it with the window’s shape', () => {
|
||
expect(editorRecordingPlan({ w: 1920, h: 1080 }, 1, '4k')).toEqual({
|
||
size: { w: 3840, h: 2160 },
|
||
resolution: 2,
|
||
});
|
||
expect(editorRecordingPlan({ w: 2560, h: 1305 }, 1, '4k')).toEqual({
|
||
size: { w: 3840, h: 1958 },
|
||
resolution: 1.5,
|
||
});
|
||
expect(editorRecordingPlan({ w: 1280, h: 800 }, 1, '1440p')).toEqual({
|
||
size: { w: 2304, h: 1440 },
|
||
resolution: 1.8,
|
||
});
|
||
});
|
||
|
||
it('keeps the screen’s resolution for a smaller frame, and draws at most four times as fine', () => {
|
||
expect(editorRecordingPlan({ w: 2560, h: 1440 }, 1, '1080p')).toEqual({
|
||
size: { w: 1920, h: 1080 },
|
||
resolution: 1,
|
||
});
|
||
expect(editorRecordingPlan({ w: 640, h: 360 }, 1, '4k')).toEqual({
|
||
size: { w: 3840, h: 2160 },
|
||
resolution: 4,
|
||
});
|
||
});
|
||
|
||
it('starts on the defaults, keeps what was chosen and never a name', () => {
|
||
expect(editorRecordingSettingsOf(null)).toEqual({
|
||
name: '',
|
||
resolution: 'screen',
|
||
fps: 60,
|
||
pointer: true,
|
||
sound: true,
|
||
convert: true,
|
||
adopt: true,
|
||
});
|
||
expect(
|
||
editorRecordingSettingsOf({
|
||
name: 'Old',
|
||
resolution: '4k',
|
||
fps: 30,
|
||
pointer: false,
|
||
sound: false,
|
||
convert: false,
|
||
adopt: 'no',
|
||
}),
|
||
).toEqual({
|
||
name: '',
|
||
resolution: '4k',
|
||
fps: 30,
|
||
pointer: false,
|
||
sound: false,
|
||
convert: false,
|
||
adopt: true,
|
||
});
|
||
expect(editorRecordingSettingsOf({ resolution: '8k', fps: 25 })).toMatchObject({
|
||
resolution: 'screen',
|
||
fps: 60,
|
||
});
|
||
});
|
||
|
||
it('shows the time it runs in minutes and seconds, hours once there are any', () => {
|
||
expect(elapsedText(0)).toBe('0:00');
|
||
expect(elapsedText(7999)).toBe('0:07');
|
||
expect(elapsedText(765_000)).toBe('12:45');
|
||
expect(elapsedText(3_723_000)).toBe('1:02:03');
|
||
});
|
||
|
||
it('draws the pointer the canvas shows, the arrow for what it does not know', () => {
|
||
expect(cursorShape('')).toBe(cursorShape('default'));
|
||
expect(cursorShape('inherit')).toBe(cursorShape('default'));
|
||
expect(cursorShape('ew-resize')).toEqual(cursorShape('col-resize'));
|
||
expect(cursorShape('ns-resize')!.turn).toBe(90);
|
||
expect(cursorShape('url(a.png) 4 4, grab')).toBe(cursorShape('grab'));
|
||
expect(cursorShape('grab')).not.toBe(cursorShape('default'));
|
||
expect(cursorShape('none')).toBeNull();
|
||
});
|
||
|
||
it('reads a cursor file: its viewBox is the picture, data-hot the point on the pointer', () => {
|
||
expect(cursorShape('pointer')).toMatchObject({
|
||
box: { x: -2, y: -2, w: 18, h: 22 },
|
||
hot: { x: 5, y: 0 },
|
||
});
|
||
expect(cursorShapes()).toHaveLength(11);
|
||
// As an editor might save it: other attributes first, commas, size in millimetres.
|
||
const edited = readCursor(
|
||
'<?xml version="1.0"?>\n<svg width="10mm" height="12mm" data-hot=" 3,4 " viewBox="1, 2, 30, 40" ' +
|
||
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
|
||
45,
|
||
);
|
||
expect(edited).toMatchObject({ box: { x: 1, y: 2, w: 30, h: 40 }, hot: { x: 3, y: 4 }, turn: 45 });
|
||
expect(readCursor('<svg><path /></svg>')).toMatchObject({
|
||
box: { x: 0, y: 0, w: 24, h: 24 },
|
||
hot: { x: 0, y: 0 },
|
||
});
|
||
// Drawn at the size it is needed, whatever size the file names.
|
||
const sized = cursorSvgAt(edited, 2.5);
|
||
expect(sized).toContain('<svg width="75" height="100" data-hot=" 3,4 " viewBox="1, 2, 30, 40"');
|
||
expect(sized).not.toContain('mm"');
|
||
expect(sized).toContain('<path d="M0 0" />');
|
||
});
|
||
|
||
it('gives the editor each cursor file as a CSS cursor, turned where the shape is turned', () => {
|
||
const svgOf = (css: string): string =>
|
||
decodeURIComponent(/^url\("data:image\/svg\+xml,([^"]*)"\)/.exec(css)![1]!);
|
||
// The arrow at a unit per CSS pixel: its own picture, the hotspot counted from the picture's corner.
|
||
const arrow = cursorCss('default', undefined, 1);
|
||
const { box, hot } = cursorShape('default')!;
|
||
expect(arrow).toMatch(new RegExp(` ${hot.x - box.x} ${hot.y - box.y}, default$`));
|
||
expect(cursorKeyword(arrow)).toBe('default');
|
||
expect(cursorKeywords()).toEqual(expect.arrayContaining(['default', 'pointer', 'grab', 'nesw-resize']));
|
||
// The resize arrow, 26 × 14 around its middle, turned upright: 14 × 26, the hotspot in the middle.
|
||
const upright = cursorCss('ns-resize', undefined, 1);
|
||
expect(upright).toMatch(/ 7 13, ns-resize$/);
|
||
expect(svgOf(upright)).toContain('width="14" height="26" viewBox="-7 -13 14 26"');
|
||
expect(svgOf(upright)).toContain(
|
||
'<g transform="rotate(90 0 0)"><svg x="-13" y="-7" width="26" height="14"',
|
||
);
|
||
// Turned by 45°, the picture grows to hold the corners.
|
||
expect(cursorCss('nwse-resize', undefined, 1)).toMatch(/ 15 15, nwse-resize$/);
|
||
// Shown at CURSOR_SIZE: the arrow's 16 × 23 units are 12.8 × 18.4 pixels, rounded outwards from
|
||
// the corner at -1.6 to 14 × 19, and the viewBox still counts in the file's units.
|
||
expect(CURSOR_SIZE).toBe(0.8);
|
||
expect(cursorCss('default')).toMatch(/ 2 2, default$/);
|
||
expect(svgOf(cursorCss('default'))).toContain('width="14" height="19" viewBox="-2.5 -2.5 17.5 23.75"');
|
||
expect(cursorCss('ns-resize')).toMatch(/ 6 11, ns-resize$/);
|
||
// An edited file: what stands before its root is left out, the root is placed where its viewBox says.
|
||
const edited = readCursor(
|
||
'<?xml version="1.0"?>\n<!-- drawn by hand -->\n<svg x="5" viewBox="1 2 30 40" data-hot="3 4" ' +
|
||
'xmlns="http://www.w3.org/2000/svg"><path d="M0 0" /></svg>',
|
||
);
|
||
const css = cursorCss('grab', edited, 1);
|
||
expect(css).toMatch(/ 2 2, grab$/);
|
||
const svg = svgOf(css);
|
||
expect(svg).not.toContain('<?xml');
|
||
expect(svg).not.toContain('drawn by hand');
|
||
expect(svg).toContain('<svg x="1" y="2" width="30" height="40" viewBox="1 2 30 40"');
|
||
});
|
||
});
|