@@ -78,6 +78,252 @@ describe('AshbyBlock', () => {
7878 expect ( alternateEmailAddresses ?. wandConfig ?. generationType ) . not . toBe ( 'json-object' )
7979 expect ( socialLinks ?. wandConfig ?. generationType ) . not . toBe ( 'json-object' )
8080 } )
81+
82+ it ( 'does not force braces or brackets on the polymorphic fieldValue' , ( ) => {
83+ // fieldValue legitimately takes a bare boolean, number, string, or null,
84+ // so neither the 'json-object' nor the 'json-array' reinforcement applies -
85+ // both would make the wand emit a wrapper the field must not receive.
86+ const fieldValue = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'fieldValue' )
87+ expect ( fieldValue ?. wandConfig ?. enabled ) . toBe ( true )
88+ expect ( fieldValue ?. wandConfig ?. generationType ) . toBeUndefined ( )
89+ } )
90+
91+ it ( 'requests array output for fieldValues, whose contract is a JSON array' , ( ) => {
92+ const fieldValues = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'fieldValues' )
93+ expect ( fieldValues ?. wandConfig ?. generationType ) . toBe ( 'json-array' )
94+ } )
95+ } )
96+
97+ describe ( 'fieldValue parsing (set_custom_field_value)' , ( ) => {
98+ const parse = ( fieldValue : unknown ) =>
99+ AshbyBlock . tools . config . params ! ( buildParams ( 'set_custom_field_value' , { fieldValue } ) )
100+ . fieldValue
101+
102+ it ( 'decodes null so the annotation can be cleared' , ( ) => {
103+ // Ashby clears a custom field when it receives an explicit null, which is
104+ // what makes a written annotation reversible.
105+ expect ( parse ( 'null' ) ) . toBeNull ( )
106+ } )
107+
108+ it ( 'decodes booleans and numbers for Boolean and Number fields' , ( ) => {
109+ expect ( parse ( 'true' ) ) . toBe ( true )
110+ expect ( parse ( '42' ) ) . toBe ( 42 )
111+ } )
112+
113+ it ( 'decodes a JSON array for MultiValueSelect fields' , ( ) => {
114+ expect ( parse ( '["Remote","Hybrid"]' ) ) . toEqual ( [ 'Remote' , 'Hybrid' ] )
115+ } )
116+
117+ it ( 'decodes a JSON object for Currency and range fields' , ( ) => {
118+ expect ( parse ( '{"value":150000,"currencyCode":"USD"}' ) ) . toEqual ( {
119+ value : 150000 ,
120+ currencyCode : 'USD' ,
121+ } )
122+ } )
123+
124+ it ( 'passes unparseable text through as a plain string' , ( ) => {
125+ // A bare option name is the most common input for String, LongText, and
126+ // ValueSelect fields, so it must not be rejected as invalid JSON.
127+ expect ( parse ( 'Senior Engineer' ) ) . toBe ( 'Senior Engineer' )
128+ } )
129+
130+ it ( 'decodes a quoted numeric string back to a string' , ( ) => {
131+ // The escape hatch for a String field whose value looks like a number.
132+ expect ( parse ( '"123"' ) ) . toBe ( '123' )
133+ } )
134+
135+ it ( 'does not let an overflowing number become a field clear' , ( ) => {
136+ // 1e999 parses to Infinity, which JSON.stringify emits as null - and null
137+ // clears the field. The user typed a number, not a clear.
138+ expect ( parse ( '1e999' ) ) . toBe ( '1e999' )
139+ } )
140+
141+ it ( 'does not silently lose precision on long numeric ids' , ( ) => {
142+ expect ( parse ( '12345678901234567890' ) ) . toBe ( '12345678901234567890' )
143+ expect ( parse ( '0123' ) ) . toBe ( '0123' )
144+ } )
145+
146+ it ( 'leaves prose that merely starts like JSON alone when it does not parse' , ( ) => {
147+ expect ( parse ( '{not really json' ) ) . toBe ( '{not really json' )
148+ } )
149+
150+ it ( 'passes an already-parsed value through untouched' , ( ) => {
151+ // An upstream block reference resolves to a real value, not to text.
152+ expect ( parse ( { value : 1 } ) ) . toEqual ( { value : 1 } )
153+ expect ( parse ( false ) ) . toBe ( false )
154+ } )
155+
156+ it ( 'leaves fieldValue alone for other operations' , ( ) => {
157+ const result = AshbyBlock . tools . config . params ! (
158+ buildParams ( 'list_jobs' , { fieldValue : 'Senior Engineer' } )
159+ )
160+ expect ( result . fieldValue ) . toBeUndefined ( )
161+ } )
162+ } )
163+
164+ describe ( 'fieldValues parsing (set_custom_field_values)' , ( ) => {
165+ it ( 'maps the fieldValues subBlock onto the tool’s values param' , ( ) => {
166+ const result = AshbyBlock . tools . config . params ! (
167+ buildParams ( 'set_custom_field_values' , {
168+ fieldValues : '[{"fieldId":"abc","fieldValue":"High"}]' ,
169+ } )
170+ )
171+ expect ( result . values ) . toEqual ( [ { fieldId : 'abc' , fieldValue : 'High' } ] )
172+ expect ( result . fieldValues ) . toBeUndefined ( )
173+ } )
174+
175+ it ( 'throws instead of silently dropping the writes when the JSON is malformed' , ( ) => {
176+ expect ( ( ) =>
177+ AshbyBlock . tools . config . params ! (
178+ buildParams ( 'set_custom_field_values' , { fieldValues : 'not json' } )
179+ )
180+ ) . toThrow ( / I n v a l i d J S O N i n A s h b y c u s t o m f i e l d v a l u e s / )
181+ } )
182+
183+ it ( 'throws when the parsed JSON is not an array' , ( ) => {
184+ expect ( ( ) =>
185+ AshbyBlock . tools . config . params ! (
186+ buildParams ( 'set_custom_field_values' , { fieldValues : '{"fieldId":"abc"}' } )
187+ )
188+ ) . toThrow ( / e x p e c t e d a J S O N a r r a y / )
189+ } )
190+ } )
191+
192+ describe ( 'change_application_source' , ( ) => {
193+ it ( 'emits sourceId as undefined when the field is left blank' , ( ) => {
194+ // The key must be PRESENT and undefined, not absent. The executor merges
195+ // `{ ...inputs, ...transformedParams }`, so an absent key inherits whatever
196+ // inputs held - which is exactly how a stale create-path sourceId used to
197+ // leak in. Presence is what overrides it.
198+ const result = AshbyBlock . tools . config . params ! (
199+ buildParams ( 'change_application_source' , { applicationId : 'app-1' , changeSourceId : '' } )
200+ )
201+ expect ( result ) . toHaveProperty ( 'sourceId' )
202+ expect ( result . sourceId ) . toBeUndefined ( )
203+ expect ( result ) . not . toHaveProperty ( 'unsetSource' )
204+ } )
205+
206+ it ( 'passes unsetSource through only when the switch is on' , ( ) => {
207+ const result = AshbyBlock . tools . config . params ! (
208+ buildParams ( 'change_application_source' , { changeSourceId : '' , unsetSource : 'true' } )
209+ )
210+ expect ( result . unsetSource ) . toBe ( true )
211+ } )
212+
213+ it ( 'never sends a stale source id alongside a clear request' , ( ) => {
214+ // The Source ID field is hidden once the clear switch is on, but a value
215+ // typed beforehand is still stored. Sending both would trip the tool's
216+ // exclusivity guard and surface as an error the user cannot see the cause of.
217+ const result = AshbyBlock . tools . config . params ! (
218+ buildParams ( 'change_application_source' , {
219+ changeSourceId : 'src-left-over' ,
220+ unsetSource : 'true' ,
221+ } )
222+ )
223+ expect ( result . unsetSource ) . toBe ( true )
224+ expect ( result ) . toHaveProperty ( 'sourceId' )
225+ expect ( result . sourceId ) . toBeUndefined ( )
226+ } )
227+
228+ it ( 'never inherits a stale create-path source id through the executor merge' , ( ) => {
229+ // The executor runs `{ ...inputs, ...transformedParams }`, so any key this
230+ // mapping leaves unset inherits whatever inputs held. The shared
231+ // create-path `sourceId` subblock reaches inputs even on this operation:
232+ // it is mode 'advanced', and the serializer includes an advanced subblock
233+ // on a non-empty value without evaluating its condition. Assert the merged
234+ // result, not just the mapping, since that gap is where the bug lived.
235+ const merge = ( inputs : Record < string , unknown > ) => ( {
236+ ...inputs ,
237+ ...AshbyBlock . tools . config . params ! ( inputs ) ,
238+ } )
239+
240+ const cleared = merge (
241+ buildParams ( 'change_application_source' , {
242+ applicationId : 'app-1' ,
243+ sourceId : 'stale-from-create-application' ,
244+ changeSourceId : '' ,
245+ unsetSource : 'true' ,
246+ } )
247+ )
248+ expect ( cleared . sourceId ) . toBeUndefined ( )
249+ expect ( cleared . unsetSource ) . toBe ( true )
250+
251+ const untouched = merge (
252+ buildParams ( 'change_application_source' , {
253+ applicationId : 'app-1' ,
254+ sourceId : 'stale-from-create-application' ,
255+ changeSourceId : '' ,
256+ } )
257+ )
258+ expect ( untouched . sourceId ) . toBeUndefined ( )
259+
260+ const explicit = merge (
261+ buildParams ( 'change_application_source' , {
262+ applicationId : 'app-1' ,
263+ sourceId : 'stale-from-create-application' ,
264+ changeSourceId : 'src-intended' ,
265+ } )
266+ )
267+ expect ( explicit . sourceId ) . toBe ( 'src-intended' )
268+ } )
269+
270+ it ( 'hides the source id field while the clear switch is on' , ( ) => {
271+ const sourceField = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'changeSourceId' )
272+ const condition = sourceField ?. condition as { and ?: { field : string ; not ?: boolean } }
273+ expect ( condition . and ) . toEqual ( { field : 'unsetSource' , value : true , not : true } )
274+ } )
275+
276+ it ( 'maps a provided source id onto sourceId' , ( ) => {
277+ const result = AshbyBlock . tools . config . params ! (
278+ buildParams ( 'change_application_source' , { changeSourceId : 'src-1' } )
279+ )
280+ expect ( result . sourceId ) . toBe ( 'src-1' )
281+ } )
282+
283+ it ( 'does not emit a null sourceId for other operations' , ( ) => {
284+ // create_candidate treats an absent source as "no source", so a null here
285+ // would turn an omitted optional field into an explicit write.
286+ const result = AshbyBlock . tools . config . params ! ( buildParams ( 'create_candidate' , { } ) )
287+ expect ( result ) . not . toHaveProperty ( 'sourceId' )
288+ } )
289+ } )
290+
291+ describe ( 'operation and tool registration stay in sync' , ( ) => {
292+ it ( 'has a matching ashby_<operation> tool in access for every dropdown option' , ( ) => {
293+ // tools.config.tool is a bare `ashby_${operation}` concat, so a dropdown
294+ // option without a matching tool id resolves to a tool that does not exist.
295+ const operation = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'operation' )
296+ const optionIds = ( operation ?. options as Array < { id : string } > ) . map ( ( o ) => o . id )
297+ const access = new Set ( AshbyBlock . tools . access )
298+ const missing = optionIds . filter ( ( id ) => ! access . has ( `ashby_${ id } ` ) )
299+ expect ( missing ) . toEqual ( [ ] )
300+ } )
301+
302+ it ( 'has a dropdown option for every tool listed in access' , ( ) => {
303+ const operation = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'operation' )
304+ const optionIds = new Set (
305+ ( operation ?. options as Array < { id : string } > ) . map ( ( o ) => `ashby_${ o . id } ` )
306+ )
307+ const unreachable = AshbyBlock . tools . access ! . filter ( ( id ) => ! optionIds . has ( id ) )
308+ expect ( unreachable ) . toEqual ( [ ] )
309+ } )
310+
311+ it ( 'has a canvas sentence for every dropdown option' , ( ) => {
312+ const operation = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'operation' )
313+ const optionIds = ( operation ?. options as Array < { id : string } > ) . map ( ( o ) => o . id )
314+ const sentences = AshbyBlock . canvasPresentation ?. sentences ?. byOperation ?? { }
315+ const missing = optionIds . filter ( ( id ) => ! ( id in sentences ) )
316+ expect ( missing ) . toEqual ( [ ] )
317+ } )
318+ } )
319+
320+ describe ( 'list_jobs incremental sync' , ( ) => {
321+ it ( 'offers the syncToken field on list_jobs' , ( ) => {
322+ // Without a sync token every scheduled run rescans the full req set.
323+ const syncToken = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'syncToken' )
324+ const condition = syncToken ?. condition as { value : string [ ] }
325+ expect ( condition . value ) . toContain ( 'list_jobs' )
326+ } )
81327 } )
82328
83329 describe ( 'list_applications candidateId filter' , ( ) => {
0 commit comments