properties.spec.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. /**
  2. * Property-based tests for the tool-schema DSL (the property-testing Agent Note), including
  3. * the property-testing ↔ runtime-validation composition: generated args that satisfy a ParameterSchemaSpec must
  4. * pass validateArgs, and targeted corruptions must be rejected. This closes the
  5. * validator/InferArgs drift risk noted in the arg-validation Agent Note.
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import fc from 'fast-check'
  9. import { isJsonValue } from '@deepseek-ai/dsh-util-values'
  10. import { parameterSchemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
  11. import type { ParameterPropertySpec, ParameterSchemaSpec, ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
  12. /** Remove parameter-only requiredness before nesting a schema as an array item. */
  13. function asValueSchema(prop: ParameterPropertySpec): ValueSchemaSpec {
  14. const { required: _required, ...schema } = prop
  15. return schema
  16. }
  17. // A leaf prop arbitrary (no nesting) with optional required/enum.
  18. function leafPropArb(): fc.Arbitrary<ParameterPropertySpec> {
  19. return fc.oneof(
  20. fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'string', ...required ? { required: true } : {} })),
  21. fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'number', ...required ? { required: true } : {} })),
  22. fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'integer', ...required ? { required: true } : {} })),
  23. fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'boolean', ...required ? { required: true } : {} })),
  24. fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'null', ...required ? { required: true } : {} })),
  25. fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'json', ...required ? { required: true } : {} })),
  26. fc.record({ values: fc.uniqueArray(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 3 }), required: fc.boolean() })
  27. .map(({ values, required }): ParameterPropertySpec => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
  28. fc.record({ value: fc.string(), required: fc.boolean() })
  29. .map(({ value, required }): ParameterPropertySpec => ({ type: 'string', const: value, ...required ? { required: true } : {} })),
  30. fc.record({ required: fc.boolean() })
  31. .map(({ required }): ParameterPropertySpec => ({
  32. oneOf: [{ type: 'string' }, { type: 'null' }],
  33. ...required ? { required: true } : {},
  34. })),
  35. )
  36. }
  37. /** A prop arbitrary up to `depth` levels of nesting (objects and arrays). */
  38. function propArb(depth: number): fc.Arbitrary<ParameterPropertySpec> {
  39. if (depth <= 0) return leafPropArb()
  40. return fc.oneof(
  41. { weight: 3, arbitrary: leafPropArb() },
  42. {
  43. weight: 1,
  44. arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean(), additionalProperties: fc.boolean() })
  45. .map(({ properties, required, additionalProperties }): ParameterPropertySpec => ({
  46. type: 'object',
  47. additionalProperties,
  48. properties,
  49. ...required ? { required: true } : {},
  50. })),
  51. },
  52. {
  53. weight: 1,
  54. arbitrary: fc.record({ items: propArb(depth - 1), required: fc.boolean() })
  55. .map(({ items, required }): ParameterPropertySpec => ({
  56. type: 'array',
  57. items: asValueSchema(items),
  58. ...required ? { required: true } : {},
  59. })),
  60. },
  61. )
  62. }
  63. function specArb(depth: number): fc.Arbitrary<ParameterSchemaSpec> {
  64. return fc.dictionary(fc.string({ minLength: 1, maxLength: 6 }), propArb(depth), { maxKeys: 4 })
  65. }
  66. /** Generate a value that satisfies a prop (used to build valid args). */
  67. function valueForProp(prop: ParameterPropertySpec): fc.Arbitrary<unknown> {
  68. if ('oneOf' in prop) return fc.oneof(...prop.oneOf.map(valueForProp))
  69. if ('const' in prop) return fc.constant(prop.const)
  70. switch (prop.type) {
  71. case 'string': return prop.enum ? fc.constantFrom(...prop.enum) : fc.string()
  72. case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true }).filter(value => !Object.is(value, -0))
  73. case 'integer': return fc.integer()
  74. case 'boolean': return fc.boolean()
  75. case 'null': return fc.constant(null)
  76. case 'object': return prop.properties ? validArgsForSpec(prop.properties) : fc.constant({})
  77. case 'array': return prop.items ? fc.array(valueForProp(prop.items), { maxLength: 3 }) : fc.constant([])
  78. case 'json': return fc.jsonValue().filter(value => isJsonValue(value))
  79. }
  80. }
  81. /** Generate args satisfying every required key of a spec (optionals included randomly). */
  82. function validArgsForSpec(spec: ParameterSchemaSpec): fc.Arbitrary<Record<string, unknown>> {
  83. const entries = Object.entries(spec)
  84. return fc.tuple(...entries.map(([key, prop]) =>
  85. fc.tuple(
  86. fc.constant(key),
  87. // required keys are always present; optional keys are present ~half the time
  88. prop.required === true
  89. ? valueForProp(prop).map(v => ({ include: true, value: v }))
  90. : fc.oneof(
  91. valueForProp(prop).map(v => ({ include: true, value: v })),
  92. fc.constant({ include: false, value: undefined }),
  93. ),
  94. ),
  95. )).map((pairs) => {
  96. const out: Record<string, unknown> = {}
  97. for (const [key, { include, value }] of pairs) if (include) out[key] = value
  98. return out
  99. })
  100. }
  101. /** Collect the `required: true` keys at the top level of a spec. */
  102. function requiredKeys(spec: ParameterSchemaSpec): string[] {
  103. return Object.entries(spec).filter(([, p]) => p.required === true).map(([k]) => k)
  104. }
  105. describe('schema DSL properties', () => {
  106. it('JSON Schema `required` equals the required:true keys at every level', () => {
  107. fc.assert(fc.property(specArb(2), (spec) => {
  108. const checkLevel = (s: ParameterSchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
  109. expect(new Set(json.required ?? [])).toEqual(new Set(requiredKeys(s)))
  110. for (const [key, prop] of Object.entries(s)) {
  111. const propJson = json.properties[key] as Record<string, unknown>
  112. if ('type' in prop && prop.type === 'object' && prop.properties) {
  113. checkLevel(prop.properties, propJson as { required?: string[]; properties: Record<string, unknown> })
  114. }
  115. }
  116. }
  117. checkLevel(spec, parameterSchemaSpecToJsonSchema(spec))
  118. }))
  119. })
  120. it('conversion is total (never throws) for any spec', () => {
  121. fc.assert(fc.property(specArb(3), (spec) => {
  122. expect(() => parameterSchemaSpecToJsonSchema(spec)).not.toThrow()
  123. }))
  124. })
  125. it('validateArgs is total (never throws) for any spec and any input', () => {
  126. fc.assert(fc.property(specArb(2), fc.anything(), (spec, args) => {
  127. expect(() => validateArgs(spec, args)).not.toThrow()
  128. }))
  129. })
  130. it('the property-testing ↔ runtime-validation composition: args satisfying the spec pass validateArgs', () => {
  131. fc.assert(fc.property(
  132. specArb(2).chain(spec => fc.tuple(fc.constant(spec), validArgsForSpec(spec))),
  133. ([spec, args]) => {
  134. expect(validateArgs(spec, args)).toEqual([])
  135. },
  136. ))
  137. })
  138. it('the property-testing ↔ runtime-validation composition: dropping a required key is always rejected', () => {
  139. fc.assert(fc.property(
  140. specArb(1)
  141. .filter(spec => requiredKeys(spec).length > 0)
  142. .chain(spec => fc.tuple(fc.constant(spec), validArgsForSpec(spec))),
  143. ([spec, args]) => {
  144. const required = requiredKeys(spec)
  145. const victim = required[0]!
  146. const broken = Object.fromEntries(Object.entries(args).filter(([k]) => k !== victim))
  147. const violations = validateArgs(spec, broken)
  148. expect(violations.some(v => v.includes(`"${victim}"`))).toBe(true)
  149. },
  150. ))
  151. })
  152. it('the property-testing ↔ runtime-validation composition: a non-object top level is always rejected', () => {
  153. fc.assert(fc.property(
  154. specArb(1),
  155. fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null), fc.array(fc.anything())),
  156. (spec, notAnObject) => {
  157. expect(validateArgs(spec, notAnObject).length).toBeGreaterThan(0)
  158. },
  159. ))
  160. })
  161. })