@@ -78,6 +78,206 @@ 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 ( 'does not emit a sourceId at all when the field is left blank' , ( ) => {
194+ // Blank must not mean "clear": the tool rejects that unless unsetSource is
195+ // set, so an accidentally empty field can no longer wipe attribution.
196+ const result = AshbyBlock . tools . config . params ! (
197+ buildParams ( 'change_application_source' , { applicationId : 'app-1' , changeSourceId : '' } )
198+ )
199+ expect ( result ) . not . toHaveProperty ( 'sourceId' )
200+ expect ( result ) . not . toHaveProperty ( 'unsetSource' )
201+ } )
202+
203+ it ( 'passes unsetSource through only when the switch is on' , ( ) => {
204+ const result = AshbyBlock . tools . config . params ! (
205+ buildParams ( 'change_application_source' , { changeSourceId : '' , unsetSource : 'true' } )
206+ )
207+ expect ( result . unsetSource ) . toBe ( true )
208+ } )
209+
210+ it ( 'never sends a stale source id alongside a clear request' , ( ) => {
211+ // The Source ID field is hidden once the clear switch is on, but a value
212+ // typed beforehand is still stored. Sending both would trip the tool's
213+ // exclusivity guard and surface as an error the user cannot see the cause of.
214+ const result = AshbyBlock . tools . config . params ! (
215+ buildParams ( 'change_application_source' , {
216+ changeSourceId : 'src-left-over' ,
217+ unsetSource : 'true' ,
218+ } )
219+ )
220+ expect ( result . unsetSource ) . toBe ( true )
221+ expect ( result ) . not . toHaveProperty ( 'sourceId' )
222+ } )
223+
224+ it ( 'hides the source id field while the clear switch is on' , ( ) => {
225+ const sourceField = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'changeSourceId' )
226+ const condition = sourceField ?. condition as { and ?: { field : string ; not ?: boolean } }
227+ expect ( condition . and ) . toEqual ( { field : 'unsetSource' , value : true , not : true } )
228+ } )
229+
230+ it ( 'maps a provided source id onto sourceId' , ( ) => {
231+ const result = AshbyBlock . tools . config . params ! (
232+ buildParams ( 'change_application_source' , { changeSourceId : 'src-1' } )
233+ )
234+ expect ( result . sourceId ) . toBe ( 'src-1' )
235+ } )
236+
237+ it ( 'does not emit a null sourceId for other operations' , ( ) => {
238+ // create_candidate treats an absent source as "no source", so a null here
239+ // would turn an omitted optional field into an explicit write.
240+ const result = AshbyBlock . tools . config . params ! ( buildParams ( 'create_candidate' , { } ) )
241+ expect ( result ) . not . toHaveProperty ( 'sourceId' )
242+ } )
243+ } )
244+
245+ describe ( 'operation and tool registration stay in sync' , ( ) => {
246+ it ( 'has a matching ashby_<operation> tool in access for every dropdown option' , ( ) => {
247+ // tools.config.tool is a bare `ashby_${operation}` concat, so a dropdown
248+ // option without a matching tool id resolves to a tool that does not exist.
249+ const operation = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'operation' )
250+ const optionIds = ( operation ?. options as Array < { id : string } > ) . map ( ( o ) => o . id )
251+ const access = new Set ( AshbyBlock . tools . access )
252+ const missing = optionIds . filter ( ( id ) => ! access . has ( `ashby_${ id } ` ) )
253+ expect ( missing ) . toEqual ( [ ] )
254+ } )
255+
256+ it ( 'has a dropdown option for every tool listed in access' , ( ) => {
257+ const operation = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'operation' )
258+ const optionIds = new Set (
259+ ( operation ?. options as Array < { id : string } > ) . map ( ( o ) => `ashby_${ o . id } ` )
260+ )
261+ const unreachable = AshbyBlock . tools . access ! . filter ( ( id ) => ! optionIds . has ( id ) )
262+ expect ( unreachable ) . toEqual ( [ ] )
263+ } )
264+
265+ it ( 'has a canvas sentence for every dropdown option' , ( ) => {
266+ const operation = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'operation' )
267+ const optionIds = ( operation ?. options as Array < { id : string } > ) . map ( ( o ) => o . id )
268+ const sentences = AshbyBlock . canvasPresentation ?. sentences ?. byOperation ?? { }
269+ const missing = optionIds . filter ( ( id ) => ! ( id in sentences ) )
270+ expect ( missing ) . toEqual ( [ ] )
271+ } )
272+ } )
273+
274+ describe ( 'list_jobs incremental sync' , ( ) => {
275+ it ( 'offers the syncToken field on list_jobs' , ( ) => {
276+ // Without a sync token every scheduled run rescans the full req set.
277+ const syncToken = AshbyBlock . subBlocks . find ( ( s ) => s . id === 'syncToken' )
278+ const condition = syncToken ?. condition as { value : string [ ] }
279+ expect ( condition . value ) . toContain ( 'list_jobs' )
280+ } )
81281 } )
82282
83283 describe ( 'list_applications candidateId filter' , ( ) => {
0 commit comments