openpencil/tests/engine/tools/input.test.ts
Danila Poyarkov f25e3f72a2 fix(tools): preserve atomic property state and validate inputs
Record property presence for exact undo/redo, including explicit undefined values, and reject instance index mutations. Tighten numeric and operand schemas to match execution contracts.
2026-09-15 16:47:37 +03:00

107 lines
4.3 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
// eslint-disable-next-line open-pencil/no-mixed-case-acronym-identifiers -- Upstream export spelling.
import { toStandardJsonSchema as toStandardJSONSchema } from '@valibot/to-json-schema'
import * as v from 'valibot'
import { toolNumber } from '@open-pencil/core/tools'
import { getTool, setupToolTest } from '#tests/helpers/tools'
describe('native tool input contracts', () => {
const schema = v.object({ value: toolNumber(v.pipe(v.number(), v.minValue(0), v.maxValue(100))) })
test.each([
Infinity,
-Infinity,
Number.NaN,
'Infinity',
'-Infinity',
'NaN',
'1e999',
'abc',
'',
' ',
'\t\n'
])('rejects non-finite or invalid numeric input %s', async (value) => {
expect(v.safeParse(schema, { value }).success).toBe(false)
const standard = toStandardJSONSchema(schema)
expect((await standard['~standard'].validate({ value })).issues).toBeDefined()
})
test('numeric strings share numeric bounds and output types', () => {
expect(v.parse(schema, { value: '42' })).toEqual({ value: 42 })
expect(v.parse(schema, { value: ' 42 ' })).toEqual({ value: 42 })
expect(v.safeParse(schema, { value: '101' }).success).toBe(false)
expect(v.safeParse(schema, { value: '-1' }).success).toBe(false)
})
test('JSON Schema advertises canonical numeric bounds and the string compatibility input', () => {
const standard = toStandardJSONSchema(schema)
expect(standard['~standard'].jsonSchema.input({ target: 'draft-07' })).toMatchObject({
properties: {
value: { anyOf: [{ type: 'number', minimum: 0, maximum: 100 }, { type: 'string' }] }
}
})
})
test('direct execution validates and normalizes the same declared schema', () => {
const { figma } = setupToolTest()
const node = figma.createRectangle()
const tool = getTool('set_opacity')
tool.execute(figma, { id: node.id, value: '0.5' })
expect(node.opacity).toBe(0.5)
expect(() => tool.execute(figma, { id: node.id, value: 'Infinity' })).toThrow()
expect(() => tool.execute(figma, { id: node.id, value: ' ' })).toThrow()
expect(node.opacity).toBe(0.5)
})
test.each([
{ name: 'analyze_spacing', field: 'grid', invalid: [0, -1], valid: [0.5, 8] },
{ name: 'arrange', field: 'cols', invalid: [0, -1, 1.5], valid: [1, 3] },
{ name: 'get_components', field: 'limit', invalid: [-1, 1.5], valid: [0, 50] },
{ name: 'update_node', field: 'font_weight', invalid: [99, 901], valid: [100, 900] }
])('$name validates $field before execution', ({ name, field, invalid, valid }) => {
const input = getTool(name).input
for (const value of invalid) {
expect(v.safeParse(input, { id: 'node', [field]: value }).success).toBe(false)
expect(v.safeParse(input, { id: 'node', [field]: String(value) }).success).toBe(false)
}
for (const value of valid) {
expect(v.safeParse(input, { id: 'node', [field]: value }).success).toBe(true)
expect(v.safeParse(input, { id: 'node', [field]: String(value) }).success).toBe(true)
}
})
test.each([
'group_nodes',
'boolean_subtract',
'boolean_union',
'boolean_intersect',
'boolean_exclude'
])('%s advertises and enforces at least two operands', (name) => {
const input = getTool(name).input
expect(v.safeParse(input, { ids: ['one'] }).success).toBe(false)
expect(v.safeParse(input, { ids: ['one', 'two'] }).success).toBe(true)
const standard = toStandardJSONSchema(input)
expect(standard['~standard'].jsonSchema.input({ target: 'draft-07' })).toMatchObject({
properties: { ids: { minItems: 2 } }
})
})
test('font weight bounds also appear in the generated schema', () => {
const standard = toStandardJSONSchema(getTool('update_node').input)
expect(standard['~standard'].jsonSchema.input({ target: 'draft-07' })).toMatchObject({
properties: { font_weight: { anyOf: [{ minimum: 100, maximum: 900 }, { type: 'string' }] } }
})
})
test('defaults and enum aliases remain schema-owned', () => {
const tool = getTool('create_shape')
const args = v.parse(tool.input, { type: 'RECTANGLE', x: 0, y: 0, width: 10, height: 10 })
expect(args.type).toBe('RECTANGLE')
expect(v.parse(getTool('get_components').input, {}).source).toBe('all')
expect(v.safeParse(tool.input, { type: 'not-a-shape' }).success).toBe(false)
})
})