2020 * access, and method calls all keep working, and the chain resolves to an
2121 * empty/null value of the right type for the mode it was born in.
2222 *
23- * Method calls are handled by __call, which routes three ways:
24- * - names on SmartArrayBase -> an empty SmartArray/SmartArrayHtml (mode inherited)
25- * - names only on SmartString -> SmartString::new(null)
26- * - names on neither -> the library's undefined-method Error
23+ * Method calls are handled by __call. In HTML mode, public SmartString methods
24+ * are tried first and the result decides what comes back: value producers like
25+ * or() return their fallback, terminals like int() return their scalar, and
26+ * transforms like trim() propagate the same SmartNull so the chain stays open
27+ * for either a value or a collection ending. map() propagates without running
28+ * its callback (a missing key has no value to pass). Everything else delegates
29+ * to an empty SmartArray/SmartArrayHtml of the same mode, and unknown names
30+ * throw the library's undefined-method Error.
2731 */
2832class SmartNullTest extends SmartArrayTestCase
2933{
@@ -224,17 +228,93 @@ public function testDelegatedArraysCarryTheSourceMetadata(string $class): void
224228 //endregion
225229 //region Method delegation: SmartString methods
226230
227- public function testSmartStringOnlyMethodsDelegateToANullSmartStringInHtmlMode (): void
231+ public function testTransformsPropagateTheSameSmartNullInHtmlMode (): void
228232 {
233+ // A missing key stays missing through a chain: there is nothing to
234+ // transform, so the SmartNull itself comes back and the chain stays
235+ // open for either ending, ->or() for a value or ->implode() for a collection
229236 $ smartNull = $ this ->smartNullFrom (SmartArrayHtml::class);
230237
231- $ trimmed = $ smartNull ->trim ();
232- $ this ->assertInstanceOf (SmartString::class, $ trimmed );
233- $ this ->assertNull ($ trimmed ->value (), 'delegation target is SmartString::new(null) ' );
238+ $ transforms = ['trim ' => [], 'maxChars ' => [5 ], 'dateFormat ' => ['Y-m-d ' ], 'numberFormat ' => [], 'add ' => [5 ]];
239+ foreach ($ transforms as $ method => $ args ) {
240+ $ this ->assertSame ($ smartNull , $ smartNull ->$ method (...$ args ), "-> $ method() propagates " );
241+ }
242+
243+ $ this ->assertSame ('n/a ' , $ smartNull ->trim ()->maxChars (5 )->or ('n/a ' )->value (), 'chain resolves at the end ' );
244+ $ this ->assertSame ('' , $ smartNull ->trim ()->implode (', ' )->value (), 'collection ending still works after a transform ' );
245+ }
246+
247+ public function testValueProducersEndTheChainInHtmlMode (): void
248+ {
249+ $ smartNull = $ this ->smartNullFrom (SmartArrayHtml::class);
234250
235251 $ fallback = $ smartNull ->or ('n/a ' );
236252 $ this ->assertInstanceOf (SmartString::class, $ fallback );
237253 $ this ->assertSame ('n/a ' , $ fallback ->value ());
254+ $ this ->assertSame ('n/a ' , $ smartNull ->ifNull ('n/a ' )->value ());
255+ }
256+
257+ public function testOrThrowThrowsInHtmlMode (): void
258+ {
259+ $ smartNull = $ this ->smartNullFrom (SmartArrayHtml::class);
260+
261+ $ this ->expectException (RuntimeException::class);
262+ $ this ->expectExceptionMessage ('no user found ' );
263+
264+ $ smartNull ->orThrow ('no user found ' );
265+ }
266+
267+ public function testMapPropagatesWithoutRunningTheCallbackInHtmlMode (): void
268+ {
269+ // map() takes a user callback, and a missing key has no value to pass it
270+ $ smartNull = $ this ->smartNullFrom (SmartArrayHtml::class);
271+ $ calls = 0 ;
272+
273+ $ result = $ smartNull ->map (function ($ value ) use (&$ calls ) {
274+ $ calls ++;
275+ return 'computed ' ;
276+ });
277+
278+ $ this ->assertSame ($ smartNull , $ result );
279+ $ this ->assertSame (0 , $ calls , 'the callback never runs on a missing key ' );
280+ $ this ->assertSame ('n/a ' , $ smartNull ->map ('strtoupper ' )->or ('n/a ' )->value (), 'chain stays open after map ' );
281+ }
282+
283+ public function testMapStillRunsOnAKeyThatExistsWithANullValue (): void
284+ {
285+ // The boundary map() propagation must not cross: NULL is a present value
286+ // (SmartString(null), the ordinary path), only an absent key is a SmartNull
287+ $ row = SmartArrayHtml::new (['bio ' => null ]);
288+ $ result = $ row ->bio ->map (fn ($ value ) => $ value ?? 'default ' );
289+
290+ $ this ->assertInstanceOf (SmartString::class, $ result );
291+ $ this ->assertSame ('default ' , $ result ->value ());
292+ }
293+
294+ public function testMapOnACollectionShapedSmartNullKeepsCollectionChainsWorking (): void
295+ {
296+ // first() on an empty result, then a per-element map: the SmartNull
297+ // propagates, so collection endings and iteration still degrade gracefully
298+ $ smartNull = $ this ->smartNullFrom (SmartArrayHtml::class);
299+
300+ $ result = $ smartNull ->map (fn ($ value ) => strtoupper ((string )$ value ))->implode (', ' );
301+ $ this ->assertInstanceOf (SmartString::class, $ result );
302+ $ this ->assertSame ('' , $ result ->value ());
303+ }
304+
305+ public function testMapDelegatesToAnEmptyArrayInRawMode (): void
306+ {
307+ // Raw mode has no SmartString delegation: map is SmartArray's per-element
308+ // map, which returns an empty array of the same mode
309+ $ result = $ this ->smartNullFrom (SmartArray::class)->map (fn ($ value ) => $ value );
310+
311+ $ this ->assertSame (SmartArray::class, get_class ($ result ));
312+ $ this ->assertSame ([], $ result ->toArray ());
313+ }
314+
315+ public function testHtmlEncodeReturnsEmptyStringInHtmlMode (): void
316+ {
317+ $ this ->assertSame ('' , $ this ->smartNullFrom (SmartArrayHtml::class)->htmlEncode ());
238318 }
239319
240320 public function testSmartStringTypeCastsReturnEmptyScalarsInHtmlMode (): void
@@ -373,9 +453,10 @@ public function testArrayWriteThrowsRuntimeException(string $class): void
373453 }
374454
375455 #[DataProvider('modeProvider ' )]
376- public function testSetThrowsTheSameGuardAsArraySyntax (string $ class ): void
456+ public function testTwoArgumentSetThrowsTheSameGuardAsArraySyntax (string $ class ): void
377457 {
378- // All writes throw the same guard: property, set(), and array syntax
458+ // Two arguments is SmartArray's set($key, $value), a write, and all
459+ // writes throw the same guard: property, set(), and array syntax
379460 $ smartNull = $ this ->smartNullFrom ($ class );
380461
381462 $ this ->expectException (RuntimeException::class);
@@ -384,6 +465,28 @@ public function testSetThrowsTheSameGuardAsArraySyntax(string $class): void
384465 $ smartNull ->set ('key ' , 'value ' );
385466 }
386467
468+ public function testOneArgumentSetProducesTheValueInHtmlMode (): void
469+ {
470+ // One argument is SmartString's set($value): not a write, it produces
471+ // a new value and ends the chain, like or()
472+ $ smartNull = $ this ->smartNullFrom (SmartArrayHtml::class);
473+
474+ $ result = $ smartNull ->set ('fallback ' );
475+ $ this ->assertInstanceOf (SmartString::class, $ result );
476+ $ this ->assertSame ('fallback ' , $ result ->value ());
477+
478+ $ this ->assertSame ($ smartNull , $ smartNull ->set (null ), 'set(null) produces nothing, so the chain stays missing ' );
479+ }
480+
481+ public function testOneArgumentSetThrowsInRawMode (): void
482+ {
483+ // Raw mode has no SmartString delegation, so the write guard answers
484+ $ this ->expectException (RuntimeException::class);
485+ $ this ->expectExceptionMessage ('Cannot set values on SmartNull - this value came from a missing key or empty result, check ->isNotEmpty() first ' );
486+
487+ $ this ->smartNullFrom (SmartArray::class)->set ('value ' );
488+ }
489+
387490 #[DataProvider('modeProvider ' )]
388491 public function testPropertyWriteThrowsTheSameGuardAsArraySyntax (string $ class ): void
389492 {
0 commit comments