properties.spec.ts 6.3 KB

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