* build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
378 lines
15 KiB
TypeScript
378 lines
15 KiB
TypeScript
import { beforeAll, describe, expect, test } from 'bun:test'
|
|
|
|
import {
|
|
preprocessForVectorize,
|
|
resolveVectorFramePlacement,
|
|
svgToVectorPaths
|
|
} from '@open-pencil/core/vector'
|
|
|
|
import { initCanvasKit } from '#cli/headless'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
import { testPath } from '#tests/helpers/paths'
|
|
|
|
let ck: Awaited<ReturnType<typeof initCanvasKit>>
|
|
|
|
const VECTORIZE_FIXTURES = testPath('fixtures/vectorize')
|
|
|
|
async function loadFixturePNG(name: string): Promise<Uint8Array> {
|
|
const buf = await Bun.file(`${VECTORIZE_FIXTURES}/${name}`).arrayBuffer()
|
|
return new Uint8Array(buf)
|
|
}
|
|
|
|
function countTransparentPixels(
|
|
image: NonNullable<ReturnType<typeof ck.MakeImageFromEncoded>>
|
|
): number {
|
|
const pixels = image.readPixels(0, 0, {
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
colorSpace: ck.ColorSpace.SRGB,
|
|
width: image.width(),
|
|
height: image.height()
|
|
})
|
|
if (!pixels) return 0
|
|
let transparent = 0
|
|
for (let i = 3; i < pixels.length; i += 4) {
|
|
if (pixels[i] === 0) transparent++
|
|
}
|
|
return transparent
|
|
}
|
|
|
|
function createPNG(width: number, height: number, alpha = 255): Uint8Array {
|
|
const pixels = ck.Malloc(Uint8Array, width * height * 4)
|
|
const view = pixels.toTypedArray()
|
|
for (let i = 0; i < width * height; i++) {
|
|
const offset = i * 4
|
|
view[offset] = 80
|
|
view[offset + 1] = 120
|
|
view[offset + 2] = 200
|
|
view[offset + 3] = alpha
|
|
}
|
|
const surface = ck.MakeRasterDirectSurface(
|
|
{
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
colorSpace: ck.ColorSpace.SRGB,
|
|
width,
|
|
height
|
|
},
|
|
pixels,
|
|
width * 4
|
|
)
|
|
if (!surface) throw new Error('Failed to create surface')
|
|
const image = surface.makeImageSnapshot()
|
|
const encoded = image.encodeToBytes(ck.ImageFormat.PNG, 100)
|
|
image.delete()
|
|
surface.delete()
|
|
ck.Free(pixels)
|
|
if (!encoded) throw new Error('Failed to encode PNG')
|
|
return new Uint8Array(encoded)
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
ck = await initCanvasKit()
|
|
})
|
|
|
|
describe('preprocessForVectorize', () => {
|
|
test('leaves images at or above 256px short side unchanged', () => {
|
|
const bytes = createPNG(400, 300)
|
|
const result = preprocessForVectorize(bytes, () => ck)
|
|
expect(result).not.toBeNull()
|
|
expect(result?.width).toBe(400)
|
|
expect(result?.height).toBe(300)
|
|
expect(result?.originalWidth).toBe(400)
|
|
expect(result?.originalHeight).toBe(300)
|
|
})
|
|
|
|
test('upscales images below 256px short side', () => {
|
|
const bytes = createPNG(100, 200)
|
|
const result = preprocessForVectorize(bytes, () => ck)
|
|
expect(result).not.toBeNull()
|
|
expect(Math.min(result?.width ?? 0, result?.height ?? 0)).toBeGreaterThanOrEqual(256)
|
|
})
|
|
|
|
test('preserves alpha channel', () => {
|
|
const bytes = createPNG(128, 128, 0)
|
|
const result = preprocessForVectorize(bytes, () => ck)
|
|
const processed = expectDefined(result, 'preprocess result')
|
|
const decoded = expectDefined(ck.MakeImageFromEncoded(processed.pngBytes), 'decoded png')
|
|
expect(countTransparentPixels(decoded)).toBeGreaterThan(0)
|
|
decoded.delete()
|
|
})
|
|
})
|
|
|
|
describe('preprocessForVectorize fixtures', () => {
|
|
test('python_logo.png upscales short side below 256px', async () => {
|
|
const bytes = await loadFixturePNG('python_logo.png')
|
|
const source = expectDefined(ck.MakeImageFromEncoded(bytes), 'source png')
|
|
expect(source.width()).toBe(580)
|
|
expect(source.height()).toBe(164)
|
|
source.delete()
|
|
|
|
const result = preprocessForVectorize(bytes, () => ck)
|
|
const processed = expectDefined(result, 'preprocess result')
|
|
expect(processed.originalWidth).toBe(580)
|
|
expect(processed.originalHeight).toBe(164)
|
|
expect(Math.min(processed.width, processed.height)).toBeGreaterThanOrEqual(256)
|
|
expect(processed.width).toBeGreaterThan(580)
|
|
expect(processed.height).toBe(256)
|
|
})
|
|
|
|
test('euro_shield.png keeps dimensions and alpha', async () => {
|
|
const bytes = await loadFixturePNG('euro_shield.png')
|
|
const result = preprocessForVectorize(bytes, () => ck)
|
|
const processed = expectDefined(result, 'preprocess result')
|
|
expect(processed.width).toBe(577)
|
|
expect(processed.height).toBe(721)
|
|
|
|
const decoded = expectDefined(ck.MakeImageFromEncoded(processed.pngBytes), 'decoded png')
|
|
expect(countTransparentPixels(decoded)).toBeGreaterThan(0)
|
|
decoded.delete()
|
|
})
|
|
|
|
test('pilot_avatar.png keeps dimensions without upscale', async () => {
|
|
const bytes = await loadFixturePNG('pilot_avatar.png')
|
|
const result = preprocessForVectorize(bytes, () => ck)
|
|
const processed = expectDefined(result, 'preprocess result')
|
|
expect(processed.width).toBe(412)
|
|
expect(processed.height).toBe(364)
|
|
expect(Math.min(processed.width, processed.height)).toBeGreaterThanOrEqual(256)
|
|
|
|
const decoded = expectDefined(ck.MakeImageFromEncoded(processed.pngBytes), 'decoded png')
|
|
expect(countTransparentPixels(decoded)).toBeGreaterThan(0)
|
|
decoded.delete()
|
|
})
|
|
|
|
test('sander_test_01.png preprocesses opaque illustration', async () => {
|
|
const bytes = await loadFixturePNG('sander_test_01.png')
|
|
const source = expectDefined(ck.MakeImageFromEncoded(bytes), 'source png')
|
|
expect(source.width()).toBe(417)
|
|
expect(source.height()).toBe(391)
|
|
const sourceTransparent = countTransparentPixels(source)
|
|
source.delete()
|
|
|
|
const result = preprocessForVectorize(bytes, () => ck)
|
|
const processed = expectDefined(result, 'preprocess result')
|
|
expect(processed.width).toBe(417)
|
|
expect(processed.height).toBe(391)
|
|
expect(processed.pngBytes.length).toBeGreaterThan(0)
|
|
expect(processed.pngBytes.length).toBeLessThanOrEqual(5 * 1024 * 1024)
|
|
expect(sourceTransparent).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('resolveVectorFramePlacement', () => {
|
|
test('tightens the replacement frame around inset vector content', () => {
|
|
expect(
|
|
resolveVectorFramePlacement(
|
|
{ x: 10, y: 20, width: 200, height: 100, rotation: 0 },
|
|
{ x: 25, y: 10, width: 150, height: 80 }
|
|
)
|
|
).toEqual({ x: 35, y: 30, width: 150, height: 80, offsetX: 25, offsetY: 10 })
|
|
})
|
|
|
|
test('keeps rotated replacements in the original coordinate box', () => {
|
|
expect(
|
|
resolveVectorFramePlacement(
|
|
{ x: 10, y: 20, width: 200, height: 100, rotation: 30 },
|
|
{ x: 25, y: 10, width: 150, height: 80 }
|
|
)
|
|
).toEqual({ x: 10, y: 20, width: 200, height: 100, offsetX: 0, offsetY: 0 })
|
|
})
|
|
})
|
|
|
|
describe('svgToVectorPaths', () => {
|
|
test('accepts single-quoted SVG attributes', () => {
|
|
const result = svgToVectorPaths(
|
|
`<svg viewBox='0 0 10 10'><path d='M0 0 H10 V10 H0 Z' fill='#336699'/></svg>`,
|
|
{ width: 20, height: 20 }
|
|
)
|
|
expect(result?.paths).toHaveLength(1)
|
|
expect(result?.paths[0]?.fills[0]?.type).toBe('SOLID')
|
|
})
|
|
|
|
test('maps multi-path vendor SVG with viewBox larger than width/height attrs', () => {
|
|
const svg = `<svg viewBox="0 0 1000 800" width="200" height="160" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M0 0 H1000 V800 H0 Z" fill="#336699"/>
|
|
<path d="M100 100 H400 V400 H100 Z" fill="#ffcc00"/>
|
|
<path d="M600 200 H900 V500 H600 Z" fill="#ffcc00"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 200, height: 160 })
|
|
expect(result?.paths.length).toBe(3)
|
|
|
|
let maxX = 0
|
|
let maxY = 0
|
|
for (const path of result?.paths ?? []) {
|
|
for (const vertex of path.vectorNetwork.vertices) {
|
|
maxX = Math.max(maxX, vertex.x)
|
|
maxY = Math.max(maxY, vertex.y)
|
|
}
|
|
}
|
|
expect(maxX).toBeCloseTo(200, 0)
|
|
expect(maxY).toBeCloseTo(160, 0)
|
|
})
|
|
|
|
test('maps viewBox paths onto target bounds', () => {
|
|
const svg = `<svg viewBox="0 0 100 50" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M0 0 H100 V50 H0 Z" fill="#336699"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 200, height: 100 })
|
|
expect(result?.paths.length).toBe(1)
|
|
const path = expectDefined(result?.paths[0], 'vector path')
|
|
expect(path.fills[0]?.type).toBe('SOLID')
|
|
const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x))
|
|
expect(maxX).toBeCloseTo(200, 0)
|
|
})
|
|
|
|
test('uses viewBox user units when width/height attributes differ', () => {
|
|
const svg = `<svg width="240" height="300" viewBox="0 0 100 125" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M0 0 H100 V125 H0 Z" fill="#003399"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 240, height: 300 })
|
|
const path = expectDefined(result?.paths[0], 'vector path')
|
|
const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x))
|
|
const maxY = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.y))
|
|
expect(maxX).toBeCloseTo(240, 0)
|
|
expect(maxY).toBeCloseTo(300, 0)
|
|
})
|
|
|
|
test('maps viewBox with non-zero origin into target bounds', () => {
|
|
const svg = `<svg viewBox="50 50 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M50 50 H150 V150 H50 Z" fill="#336699"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 200, height: 200 })
|
|
const path = expectDefined(result?.paths[0], 'vector path')
|
|
const minX = Math.min(...path.vectorNetwork.vertices.map((vertex) => vertex.x))
|
|
const minY = Math.min(...path.vectorNetwork.vertices.map((vertex) => vertex.y))
|
|
const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x))
|
|
const maxY = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.y))
|
|
expect(minX).toBeCloseTo(0, 0)
|
|
expect(minY).toBeCloseTo(0, 0)
|
|
expect(maxX).toBeCloseTo(200, 0)
|
|
expect(maxY).toBeCloseTo(200, 0)
|
|
})
|
|
|
|
test('scales cubic control points under non-uniform viewBox mapping', () => {
|
|
const svg = `<svg viewBox="0 0 100 200" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M0 100 C40 0, 60 200, 100 100" fill="#336699"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 300, height: 100 })
|
|
const segment = expectDefined(result?.paths[0]?.vectorNetwork.segments[0], 'cubic segment')
|
|
expect(segment.tangentStart.x).toBeCloseTo(120, 0)
|
|
expect(segment.tangentStart.y).toBeCloseTo(-50, 0)
|
|
expect(segment.tangentEnd.x).toBeCloseTo(-120, 0)
|
|
expect(segment.tangentEnd.y).toBeCloseTo(50, 0)
|
|
})
|
|
|
|
test('applies path transform before viewBox mapping', () => {
|
|
const svg = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
<path transform="translate(10, 20)" d="M0 0 H50 V50 H0 Z" fill="#336699"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 200, height: 200 })
|
|
const path = expectDefined(result?.paths[0], 'vector path')
|
|
const minX = Math.min(...path.vectorNetwork.vertices.map((vertex) => vertex.x))
|
|
const minY = Math.min(...path.vectorNetwork.vertices.map((vertex) => vertex.y))
|
|
const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x))
|
|
const maxY = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.y))
|
|
expect(minX).toBeCloseTo(20, 0)
|
|
expect(minY).toBeCloseTo(40, 0)
|
|
expect(maxX).toBeCloseTo(120, 0)
|
|
expect(maxY).toBeCloseTo(140, 0)
|
|
})
|
|
|
|
test('applies non-translate transforms (scale) via svgpath', () => {
|
|
// scale(2) was silently ignored by the old translate/matrix-only parser.
|
|
const svg = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
<path transform="scale(2)" d="M0 0 H25 V25 H0 Z" fill="#336699"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 100, height: 100 })
|
|
const path = expectDefined(result?.paths[0], 'vector path')
|
|
// 25→50 in user space after scale(2), then viewBox 100→bounds 100 (1:1).
|
|
const maxX = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.x))
|
|
const maxY = Math.max(...path.vectorNetwork.vertices.map((vertex) => vertex.y))
|
|
expect(maxX).toBeCloseTo(50, 0)
|
|
expect(maxY).toBeCloseTo(50, 0)
|
|
})
|
|
|
|
test('reports tight content bounds inside target bounds', () => {
|
|
const svg = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M25 25 H75 V75 H25 Z" fill="#ffcc00"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 200, height: 200 })
|
|
const bounds = expectDefined(result?.contentBounds, 'content bounds')
|
|
expect(bounds.x).toBeCloseTo(50, 0)
|
|
expect(bounds.y).toBeCloseTo(50, 0)
|
|
expect(bounds.width).toBeCloseTo(100, 0)
|
|
expect(bounds.height).toBeCloseTo(100, 0)
|
|
})
|
|
|
|
test('resolves objectBoundingBox linearGradient reference to a GRADIENT_LINEAR fill', () => {
|
|
const svg = `<svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="g"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient>
|
|
</defs>
|
|
<path d="M0 0 H10 V10 H0 Z" fill="url(#g)"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 10, height: 10 })
|
|
const fill = expectDefined(result?.paths[0]?.fills[0], 'gradient fill')
|
|
expect(fill.type).toBe('GRADIENT_LINEAR')
|
|
expect(fill.gradientStops?.length).toBe(2)
|
|
expect(fill.gradientStops?.[0]?.color.r).toBeCloseTo(0, 2)
|
|
expect(fill.gradientStops?.[1]?.color.r).toBeCloseTo(1, 2)
|
|
expect(fill.gradientTransform).toBeDefined()
|
|
})
|
|
|
|
test('resolves Recraft-style userSpaceOnUse gradient with two stops', () => {
|
|
// Mirrors Recraft vectorize output: userSpaceOnUse coords across the node box.
|
|
const svg = `<svg viewBox="0 0 100 100" width="100" height="100" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="Gradient1" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="100" y2="0">
|
|
<stop offset="0" stop-color="#3572b0"/>
|
|
<stop offset="1" stop-color="#ffd43b"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<path d="M0 0 H100 V100 H0 Z" fill="url(#Gradient1)"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 100, height: 100 })
|
|
const fill = expectDefined(result?.paths[0]?.fills[0], 'gradient fill')
|
|
expect(fill.type).toBe('GRADIENT_LINEAR')
|
|
expect(fill.gradientStops?.[0]?.color.b).toBeCloseTo(176 / 255, 2)
|
|
expect(fill.gradientStops?.[1]?.color.r).toBeCloseTo(1, 2)
|
|
// horizontal axis across the node box: start x≈0, end x≈1
|
|
const t = expectDefined(fill.gradientTransform, 'gradient transform')
|
|
expect(t.m02).toBeCloseTo(0, 1)
|
|
expect(t.m00 + t.m02).toBeCloseTo(1, 1)
|
|
})
|
|
|
|
test('preserves rotated radial gradient axes', () => {
|
|
const svg = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<radialGradient id="g" gradientUnits="userSpaceOnUse" cx="50" cy="50" r="50" gradientTransform="rotate(90 50 50)">
|
|
<stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/>
|
|
</radialGradient>
|
|
</defs>
|
|
<path d="M0 0 H100 V100 H0 Z" fill="url(#g)"/>
|
|
</svg>`
|
|
const fill = expectDefined(
|
|
svgToVectorPaths(svg, { width: 100, height: 100 })?.paths[0]?.fills[0],
|
|
'radial fill'
|
|
)
|
|
const transform = expectDefined(fill.gradientTransform, 'gradient transform')
|
|
expect(fill.type).toBe('GRADIENT_RADIAL')
|
|
expect(transform.m00).toBeCloseTo(0, 3)
|
|
expect(transform.m10).toBeCloseTo(0.5, 3)
|
|
expect(transform.m01).toBeCloseTo(-0.5, 3)
|
|
expect(transform.m11).toBeCloseTo(0, 3)
|
|
})
|
|
|
|
test('still resolves a solid color fill alongside gradients', () => {
|
|
const svg = `<svg viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M0 0 H10 V10 H0 Z" fill="#e1e1e1"/>
|
|
</svg>`
|
|
const result = svgToVectorPaths(svg, { width: 10, height: 10 })
|
|
const fill = expectDefined(result?.paths[0]?.fills[0], 'solid fill')
|
|
expect(fill.type).toBe('SOLID')
|
|
expect(fill.color.r).toBeCloseTo(225 / 255, 2)
|
|
})
|
|
})
|