@@ -282,3 +282,90 @@ describe("absent no-default non-nullable ref fields resolve to the type default
282282 expect ( c . narr . length ) . toBe ( 0 ) ;
283283 expect ( c . nst . n ) . toBe ( 3 ) ;
284284} ) ;
285+
286+ // Regression (citm_catalog.lazy): a *present* `null` value for a lazy
287+ // nullable-string field must materialize to null, not abort. Unlike the absent
288+ // slot above (lz==0 -> default), here the slot holds a real range pointing at
289+ // the `null` literal. `isString<string | null>()` is true, so `JSON.__deserialize`
290+ // would otherwise fall into the string branch and try to parse `null` as a
291+ // quoted string - aborting under NAIVE, silently corrupting under SWAR/SIMD.
292+ @json ( { lazy : "auto" } )
293+ class NullableStrings {
294+ name : string = "" ; // non-null string, present
295+ subjectCode : string | null = null ; // nullable string, present-as-null
296+ subtitle : string | null = null ; // nullable string, present-as-null
297+ }
298+
299+ describe ( "present `null` for a lazy nullable-string field materializes to null" , ( ) => {
300+ const r = JSON . parse < NullableStrings > (
301+ '{"name":"hello","subjectCode":null,"subtitle":null}' ,
302+ ) ;
303+ // Reading the non-null string AND a nullable-null on the same struct is the
304+ // exact pattern that aborted (citm name + subjectCode); needs both touched.
305+ expect ( r . name ) . toBe ( "hello" ) ;
306+ expect ( changetype < usize > ( r . subjectCode ) == 0 ? "null" : "set" ) . toBe ( "null" ) ;
307+ expect ( changetype < usize > ( r . subtitle ) == 0 ? "null" : "set" ) . toBe ( "null" ) ;
308+ // Untouched round-trip keeps the present nulls verbatim.
309+ expect (
310+ JSON . stringify (
311+ JSON . parse < NullableStrings > (
312+ '{"name":"hello","subjectCode":null,"subtitle":null}' ,
313+ ) ,
314+ ) ,
315+ ) . toBe ( '{"name":"hello","subjectCode":null,"subtitle":null}' ) ;
316+ } ) ;
317+
318+ describe ( "lazy nullable-string field keeps a present non-null value" , ( ) => {
319+ const r = JSON . parse < NullableStrings > (
320+ '{"name":"hi","subjectCode":"SC","subtitle":null}' ,
321+ ) ;
322+ expect ( r . name ) . toBe ( "hi" ) ;
323+ expect ( r . subjectCode ! ) . toBe ( "SC" ) ;
324+ expect ( changetype < usize > ( r . subtitle ) == 0 ? "null" : "set" ) . toBe ( "null" ) ;
325+ } ) ;
326+
327+
328+ @json class LazyNullableStr {
329+ s ! : JSON . Lazy < string | null > ;
330+ }
331+
332+ describe ( "JSON.Lazy<string | null> materializes a present `null` to null" , ( ) => {
333+ const nul = JSON . parse < LazyNullableStr > ( '{"s":null}' ) ;
334+ expect ( changetype < usize > ( nul . s ) == 0 ? "null" : "set" ) . toBe ( "null" ) ;
335+ const set = JSON . parse < LazyNullableStr > ( '{"s":"x"}' ) ;
336+ expect ( set . s ! ) . toBe ( "x" ) ;
337+ } ) ;
338+
339+ // The citm_catalog.lazy shape: a map of lazy structs, each materialized on
340+ // `.values()`, then a non-null string and a nullable-null string read per entry.
341+ @json ( { lazy : "auto" } )
342+ class Ev {
343+ name : string = "" ;
344+ subjectCode : string | null = null ;
345+ }
346+
347+
348+ @json ( { lazy : "auto" } )
349+ class EvRoot {
350+ events : Map < string , Ev > = new Map < string , Ev > ( ) ;
351+ }
352+
353+ describe ( "lazy nullable-string nulls survive map-of-struct materialization" , ( ) => {
354+ const r = JSON . parse < EvRoot > (
355+ '{"events":{"1":{"name":"a","subjectCode":null},"2":{"name":"b","subjectCode":"x"}}}' ,
356+ ) ;
357+ const evs = r . events . values ( ) ;
358+ let names = "" ;
359+ let nulls = 0 ;
360+ let subjects = "" ;
361+ for ( let i = 0 , n = evs . length ; i < n ; i ++ ) {
362+ const e = unchecked ( evs [ i ] ) ;
363+ names += e . name ; // non-null string field
364+ const sc = e . subjectCode ; // nullable string field
365+ if ( sc === null ) nulls ++ ;
366+ else subjects += sc ;
367+ }
368+ expect ( names ) . toBe ( "ab" ) ;
369+ expect ( nulls ) . toBe ( 1 ) ;
370+ expect ( subjects ) . toBe ( "x" ) ;
371+ } ) ;
0 commit comments