Trace results can now be inset (negative) or outset (positive) by a pixel amount. Offset the outline and holes in opposite directions with clipper-lib's miter joins, then recombine with a boolean difference so holes grow on inset and shrink on outset. Collapsed shapes return an empty outline; a split outline keeps the largest ring. Document the parameter and the new dependency.
147 lines
4.8 KiB
TypeScript
147 lines
4.8 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { deserialize } from 'bson';
|
|
import sharp from 'sharp';
|
|
import trace from './trace.js';
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
/** Build a small PNG with an opaque circle in the center on a transparent bg. */
|
|
async function makePng(size = 20): Promise<Buffer> {
|
|
const rgba = Buffer.alloc(size * size * 4);
|
|
const cx = size / 2;
|
|
const cy = size / 2;
|
|
const r = size / 3;
|
|
for (let y = 0; y < size; y++) {
|
|
for (let x = 0; x < size; x++) {
|
|
if (Math.hypot(x - cx, y - cy) <= r) {
|
|
const i = (y * size + x) * 4;
|
|
rgba[i] = 255;
|
|
rgba[i + 1] = 255;
|
|
rgba[i + 2] = 255;
|
|
rgba[i + 3] = 255;
|
|
}
|
|
}
|
|
}
|
|
return sharp(rgba, { raw: { width: size, height: size, channels: 4 } })
|
|
.png()
|
|
.toBuffer();
|
|
}
|
|
|
|
function stubFetch(body: Buffer) {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
new Response(new Uint8Array(body), {
|
|
headers: { 'content-type': 'image/png' },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
describe('trace route', () => {
|
|
it('rejects a missing url', async () => {
|
|
const res = await trace.request('/');
|
|
expect(res.status).toBe(400);
|
|
expect(await res.json()).toEqual({ error: 'Required' });
|
|
});
|
|
|
|
it('rejects a non-http url', async () => {
|
|
const res = await trace.request('/?url=file%3A%2F%2F%2Fetc%2Fpasswd');
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 502 when the upstream fetch fails', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(new Response('error', { status: 500 })),
|
|
);
|
|
const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
|
expect(res.status).toBe(502);
|
|
});
|
|
|
|
it('returns a BSON shape by default', async () => {
|
|
stubFetch(await makePng());
|
|
const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('content-type')).toBe('application/octet-stream');
|
|
|
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
|
expect(result.format).toBe('shape');
|
|
expect(result.mode).toBe('alpha');
|
|
expect(result.width).toBe(20);
|
|
expect(result.height).toBe(20);
|
|
expect(typeof result.svg).toBe('string');
|
|
expect(result.svg).toContain('<svg');
|
|
expect(result.shape).toBeDefined();
|
|
expect(result.shape.outline.length).toBeGreaterThan(3);
|
|
});
|
|
|
|
it('returns a raw SVG when format=svg', async () => {
|
|
stubFetch(await makePng());
|
|
const res = await trace.request(
|
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&format=svg',
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
|
expect(result.format).toBe('svg');
|
|
expect(result.shape).toBeUndefined();
|
|
expect(typeof result.svg).toBe('string');
|
|
});
|
|
|
|
it('supports bw mode', async () => {
|
|
stubFetch(await makePng());
|
|
const res = await trace.request(
|
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&mode=bw',
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
|
expect(result.mode).toBe('bw');
|
|
expect(result.shape.outline.length).toBeGreaterThan(3);
|
|
});
|
|
|
|
it('applies an inset offset to the shape', async () => {
|
|
stubFetch(await makePng());
|
|
const res = await trace.request(
|
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&offset=-2',
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
|
expect(result.offset).toBe(-2);
|
|
expect(result.shape.outline.length).toBeGreaterThan(3);
|
|
// An inset of 2px on a 20px circle must be visibly smaller.
|
|
const extent = (pts: number[][]) => [
|
|
Math.min(...pts.map((p) => p[0]!)),
|
|
Math.max(...pts.map((p) => p[0]!)),
|
|
];
|
|
const [minX, maxX] = extent(result.shape.outline);
|
|
expect((maxX ?? 0) - (minX ?? 0)).toBeLessThan(20);
|
|
});
|
|
|
|
it('applies an outset offset to the shape', async () => {
|
|
stubFetch(await makePng());
|
|
const res = await trace.request(
|
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&offset=2',
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
|
expect(result.offset).toBe(2);
|
|
expect(result.shape.outline.length).toBeGreaterThan(3);
|
|
});
|
|
|
|
it('rejects a non-numeric offset', async () => {
|
|
stubFetch(await makePng());
|
|
const res = await trace.request(
|
|
'/?url=https%3A%2F%2Fexample.com%2Fa.png&offset=abc',
|
|
);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('omits offset when not requested', async () => {
|
|
stubFetch(await makePng());
|
|
const res = await trace.request('/?url=https%3A%2F%2Fexample.com%2Fa.png');
|
|
const result = deserialize(new Uint8Array(await res.arrayBuffer()));
|
|
expect(result.offset).toBeUndefined();
|
|
});
|
|
}); |