View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.juneau.commons.lang;
18  
19  import static org.apache.juneau.commons.utils.Utils.*;
20  import static org.junit.jupiter.api.Assertions.*;
21  import static java.util.stream.Collectors.*;
22  
23  import java.math.*;
24  import java.text.*;
25  import java.util.*;
26  import java.util.stream.*;
27  
28  import org.apache.juneau.*;
29  import org.apache.juneau.commons.function.*;
30  import org.junit.jupiter.api.*;
31  
32  class StringFormat_Test extends TestBase {
33  
34  	private static StringFormat fs(String pattern) {
35  		return StringFormat.of(pattern);
36  	}
37  
38  	private static String stringify(ThrowingSupplier<String> supplier) {
39  		try {
40  			return supplier.get();
41  		} catch (Throwable t) {
42  			return t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
43  		}
44  	}
45  
46  	private static void assertStringFormat(String pattern, Locale locale, Object... args) {
47  		var expected = stringify(()->String.format(locale, pattern, args));
48  		var actual = "";
49  		var fmt = (StringFormat)null;
50  		try {
51  			var fmt2 = fs(pattern);
52  			fmt = fmt2;
53  			actual = stringify(()->fmt2.format(locale, args));
54  		} catch (Throwable t) {
55  			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
56  		}
57  		if (!expected.equals(actual)) {
58  			System.out.println("Pattern: " + pattern);
59  			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
60  			System.out.println("toPattern(): " + toPattern);
61  			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
62  		}
63  	}
64  
65  	private static void assertStringFormat(String pattern, Object... args) {
66  		var expected = stringify(()->String.format(pattern, args));
67  		var actual = "";
68  		var fmt = (StringFormat)null;
69  		try {
70  			var fmt2 = fs(pattern);
71  			fmt = fmt2;
72  			actual = stringify(()->fmt2.format(args));
73  		} catch (Throwable t) {
74  			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
75  		}
76  		if (!expected.equals(actual)) {
77  			System.out.println("Pattern: " + pattern);
78  			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
79  			System.out.println("toPattern(): " + toPattern);
80  			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
81  		}
82  	}
83  
84  	private static void assertMessageFormat(String pattern, Locale locale, Object... args) {
85  		var expected = stringify(()->new MessageFormat(pattern, locale).format(args));
86  		var actual = "";
87  		var fmt = (StringFormat)null;
88  		try {
89  			var fmt2 = fs(pattern);
90  			fmt = fmt2;
91  			actual = stringify(()->fmt2.format(locale, args));
92  		} catch (Throwable t) {
93  			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
94  		}
95  		if (!expected.equals(actual)) {
96  			System.out.println("Pattern: " + pattern);
97  			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
98  			System.out.println("toPattern(): " + toPattern);
99  			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
100 		}
101 	}
102 
103 	private static void assertMessageFormat(String pattern, Object... args) {
104 		var expected = stringify(()->MessageFormat.format(pattern, args));
105 		var actual = "";
106 		var fmt = (StringFormat)null;
107 		try {
108 			var fmt2 = fs(pattern);
109 			fmt = fmt2;
110 			actual = stringify(()->fmt2.format(args));
111 		} catch (Throwable t) {
112 			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
113 		}
114 		if (!expected.equals(actual)) {
115 			System.out.println("Pattern: " + pattern);
116 			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
117 			System.out.println("toPattern(): " + toPattern);
118 			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
119 		}
120 	}
121 
122 	private static void assertMixedFormat(String expected, String pattern, Locale locale, Object... args) {
123 		var actual = "";
124 		var fmt = (StringFormat)null;
125 		try {
126 			var fmt2 = fs(pattern);
127 			fmt = fmt2;
128 			actual = stringify(()->fmt2.format(locale, args));
129 		} catch (Throwable t) {
130 			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
131 		}
132 		if (!expected.equals(actual)) {
133 			System.out.println("Pattern: " + pattern);
134 			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
135 			System.out.println("toPattern(): " + toPattern);
136 			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
137 		}
138 	}
139 
140 	private static void assertMixedFormat(String expected, String pattern, Object... args) {
141 		var actual = "";
142 		var fmt = (StringFormat)null;
143 		try {
144 			var fmt2 = fs(pattern);
145 			fmt = fmt2;
146 			actual = stringify(()->fmt2.format(args));
147 		} catch (Throwable t) {
148 			actual = t.getClass().getSimpleName() + ": " + t.getLocalizedMessage();
149 		}
150 		if (!expected.equals(actual)) {
151 			System.out.println("Pattern: " + pattern);
152 			var toPattern = opt(fmt).map(x -> x.toPattern()).orElse(null);
153 			System.out.println("toPattern(): " + toPattern);
154 			fail("Pattern: " + pattern + ", toPattern(): " + toPattern + ", expected: <" + expected + "> but was: <" + actual + ">");
155 		}
156 	}
157 
158 	//====================================================================================================
159 	// MessageFormat tests
160 	//====================================================================================================
161 	@Test void a01_messageFormat() {
162 		assertMessageFormat("Hello {0}", "John");
163 		assertMessageFormat("Price: {0,number,currency}", 19.99);
164 		assertMessageFormat("{0} has {1} items and {2} friends", "John", 5, 3);
165 		assertMessageFormat("Hello {0} world", "John");
166 		assertMessageFormat("Count: {0,number,integer}", 1234);
167 		assertMessageFormat("Date: {0,date,short}", new Date(0));
168 		assertMessageFormat("Time: {0,time,short}", new Date(0));
169 		// Simple {0} with Date - uses DATE_FORMAT_CACHE for formatting
170 		assertMessageFormat("Date: {0}", new Date(0));
171 		assertMessageFormat("Value: {0}", (String)null);
172 		assertMessageFormat("Name: {0}", "");
173 		assertMessageFormat("Text: {0}\nNewline\tTab", "Hello");
174 		assertMessageFormat("Unicode: {0} 中文", "Test");
175 		assertMessageFormat("{0}{1}", "A", "B");
176 		assertMessageFormat("{0} and {0} again", "Hello");
177 		assertMessageFormat("Price: {0,number,currency}, Count: {1,number,integer}, Date: {2,date,short}", 19.99, 42, new java.util.Date());
178 		assertMessageFormat("Price: {0,number,currency}", Locale.US, 19.99);
179 		assertMessageFormat("Price: {0,number,currency}", Locale.FRANCE, 19.99);
180 		assertMessageFormat("a '{0}' b");
181 		assertMessageFormat("a ''{0}'' b", 1);
182 		assertMessageFormat("'{0}'");
183 		assertMessageFormat("''{0}''", 1);
184 
185 		// Errors
186 		assertMessageFormat("Set: {{0}}", 50);
187 		assertMessageFormat("Set: {{0}} and {{1}}", "A", "B");
188 		assertMessageFormat("Hello {0}");
189 		assertMessageFormat("{0} has {1} items and {2} friends", "John", 5);
190 		assertMessageFormat("Hello {");
191 		assertMessageFormat("Hello {0");
192 		assertMessageFormat("Hello '");
193 		assertMessageFormat("Hello 'x");
194 	}
195 
196 	//====================================================================================================
197 	// StringFormat (printf) tests
198 	//====================================================================================================
199 	@Test void a02_stringFormat() {
200 		assertStringFormat("Hello %s", "John");
201 		assertStringFormat("Price: $%.2f", 19.99);
202 		assertStringFormat("Name: %-10s Age: %3d", "John", 25);
203 		assertStringFormat("Color: #%06X", 0xFF5733);
204 		assertStringFormat("Hello world");
205 		assertStringFormat("Progress: %d%%", 50);
206 		assertStringFormat("");
207 		assertStringFormat("%1$s loves %2$s, and %1$s also loves %3$s", "Alice", "Bob", "Charlie");
208 		assertStringFormat("Hello %1$s", "John");
209 		assertStringFormat("Price: %1$.2f", 19.99);
210 		assertStringFormat("Octal: %o", 64);
211 		assertStringFormat("Octal: %o", 255);
212 		assertStringFormat("Octal: %o", (Number)null);
213 		assertStringFormat("Flag: %b", true);
214 		assertStringFormat("Flag: %b", false);
215 		assertStringFormat("Flag: %b", (Boolean)null);
216 		// %B uppercase boolean formatting
217 		assertStringFormat("Flag: %B", true);
218 		assertStringFormat("Flag: %B", false);
219 		assertStringFormat("Flag: %B", (Boolean)null);  // Line 281: null -> "FALSE"
220 		assertStringFormat("Flag: %B", "hello");  // Line 285: non-Boolean -> "TRUE"
221 		assertStringFormat("Flag: %B", 42);  // Line 285: non-Boolean -> "TRUE"
222 		assertStringFormat("Char: %c", 'A');
223 		assertStringFormat("Char: %c", "A");
224 		assertStringFormat("Char: %c", (String)null);
225 		assertStringFormat("Value: %.2e", 1234567.0);
226 		assertStringFormat("Value: %.2e", (Number)null);
227 		assertStringFormat("Number: %+10.2f", 19.99);
228 		assertStringFormat("ID: %05d", 42);
229 		assertStringFormat("Value: %d", (Number)null);  // Line 304: null -> "null"
230 		assertStringFormat("Value: %s", (String)null);
231 		assertStringFormat("Name: %s", "");
232 		assertStringFormat("%s%s", "A", "B");
233 		assertStringFormat("Progress: %d%% Complete: %d%%", 50, 75);
234 		assertStringFormat("%1$s and %1$s again", "Hello");
235 		assertStringFormat("Hex: 0x%08X, Decimal: %+d, Float: %10.3f", 255, 42, 3.14159);
236 		assertStringFormat("Price: %.2f", Locale.US, 19.99);
237 		assertStringFormat("Price: %.2f", Locale.FRANCE, 19.99);
238 		assertStringFormat("Value: %s", (Object)null);
239 		assertStringFormat("Int: %d", 42);
240 		assertStringFormat("Long: %d", 1234567890L);
241 		assertStringFormat("Byte: %d", (byte)127);
242 		assertStringFormat("Short: %d", (short)32767);
243 		assertStringFormat("Int: %d", Locale.FRANCE, 1234);
244 		assertStringFormat("Long: %d", Locale.GERMANY, 1234567L);
245 		assertStringFormat("Value: %d", "not-a-number");
246 		assertStringFormat("Hex: %x", 255);  // Line 328: Integer -> Integer.toHexString()
247 		assertStringFormat("Hex: %x", 255L);
248 		assertStringFormat("Hex: %x", (byte)255);
249 		assertStringFormat("Hex: %x", (Number)null);  // Line 324: null -> "null"
250 		assertStringFormat("Hex: %X", 255);
251 		assertStringFormat("Hex: %X", 0xABCL);
252 		assertStringFormat("Hex: %X", (short)255);
253 		assertStringFormat("Hex: %X", (Number)null);  // Line 337: null -> "null"
254 		assertStringFormat("Octal: %o", 255L);
255 		assertStringFormat("Octal: %o", (byte)64);
256 		assertStringFormat("Value: %b", "hello");
257 		assertStringFormat("Value: %b", 42);
258 		assertStringFormat("Char: %c", 65);
259 		assertStringFormat("Char: %c", 65L);
260 		assertStringFormat("Char: %c", "X");
261 		assertStringFormat("Char: %C", (Character)null);  // Line 376: null -> "null"
262 		assertStringFormat("Char: %C", 66);  // Line 382-383: Number (Integer) -> Character.toUpperCase((char)o2.intValue())
263 		assertStringFormat("Char: %C", 66L);  // Line 382-383: Number (Long) -> Character.toUpperCase((char)o2.intValue())
264 		assertStringFormat("Float: %f", 3.14f);
265 		assertStringFormat("Double: %f", 3.14159);
266 		assertStringFormat("Float: %f", Locale.FRANCE, 3.14f);
267 		assertStringFormat("Double: %f", Locale.GERMANY, 1234.56);
268 		assertStringFormat("Value: %f", (Number)null);  // Line 389: null -> "null"
269 		assertStringFormat("Value: %f", "not-a-number");
270 		assertStringFormat("Value: %.2e", 1234.56);
271 		assertStringFormat("Value: %S", "hello");
272 		assertStringFormat("Value: %S", (String)null);  // Line 297: null -> "null"
273 		assertStringFormat("Value: %B", true);
274 		assertStringFormat("Char: %C", 'a');
275 		assertStringFormat("Float: %F", 3.14);
276 		// %n doesn't consume an argument - test sequential index behavior
277 		assertStringFormat("Line 1%nLine 2");
278 		assertStringFormat("First: %s%nSecond: %s", "one", "two");
279 		assertStringFormat("%s %n %s", "first", "second");
280 
281 		// Errors
282 		assertStringFormat("Hello %s");
283 		assertStringFormat("Hello %s and %s", "John");
284 		assertStringFormat("Hello %");
285 		assertStringFormat("Hello %s and %", "John");
286 		assertStringFormat("Hello %x$s", "John");
287 	}
288 
289 	//====================================================================================================
290 	// Mixed format tests
291 	//====================================================================================================
292 	@Test void a03_mixedFormat() {
293 		assertMixedFormat("Hello John, you have 5 items", "Hello {0}, you have %d items", "John", 5);
294 		assertMixedFormat("User Alice has admin and 10 items", "User {0} has %s and {2} items", "Alice", "admin", 10);
295 		assertMixedFormat("Alice loves Bob, and Alice also loves Charlie", "%1$s loves %2$s, and {0} also loves %3$s", "Alice", "Bob", "Charlie");
296 		assertMixedFormat("Alice has 5 items, Bob has 3 items, total: 8", "{0} has %d items, {2} has %d items, total: %d", "Alice", 5, "Bob", 3, 8);
297 		assertMixedFormat("Alice Bob Charlie", "{0} %2$s {2}", "Alice", "Bob", "Charlie");
298 		assertMixedFormat("Hello John, you have 5 items", "Hello {0}, you have %d items", "John", 5);
299 		assertMixedFormat("A B B D C", "{0} %s {1} %s {2}", "A", "B", "C", "D");
300 		assertMixedFormat("ABB", "{0}%s{1}", "A", "B", "C");
301 		assertMixedFormat("Hello and Hello are the same", "{0} and %1$s are the same", "Hello");
302 
303 		// Errors
304 		assertMixedFormat("MissingFormatArgumentException: Format specifier '%s'", "Hello {0} and %s", "John");
305 		assertMixedFormat("John has 5 items and {2} friends", "{0} has %d items and {2} friends", "John", 5);
306 		assertMixedFormat("MissingFormatArgumentException: Format specifier '%s'", "%1$s loves %2$s, and {0} also loves %3$s", "Alice", "Bob");
307 	}
308 
309 	//====================================================================================================
310 	// Supported but deviates from MessageFormat/String.format
311 	//====================================================================================================
312 	@Test void a04_supportedButDeviatesFromMessageFormat() {
313 		// {} is not supported by MessageFormat, only by StringFormat as an extension
314 		assertMixedFormat("Hello John world", "Hello {} world", "John");
315 		assertMixedFormat("A B C", "{} {} {}", "A", "B", "C");
316 		// BigDecimal with %d - String.format throws exception, but our optimized code handles it
317 		assertMixedFormat("Number: 42", "Number: %d", new BigDecimal("42"));
318 		// MessageFormat throws NullPointerException when locale is null, but StringFormat handles it
319 		// So we test StringFormat's behavior directly instead of comparing with MessageFormat
320 		// Use Locale.US to get dollar sign for currency formatting
321 		// Note: Java 25 changed currency format from "$19.99" to "USD 19.99", so we check for both
322 		var fmt = fs("Price: {0,number,currency}");
323 		var result = stringify(()->fmt.format(Locale.US, 19.99));
324 		assertTrue(result.contains("19.99"), "Result should contain '19.99', but was: " + result);
325 		assertTrue(result.contains("Price: "), "Result should contain 'Price: ', but was: " + result);
326 		// Accept either old format ($19.99) or new format (USD 19.99) for Java 25 compatibility
327 		assertTrue(result.contains("$") || result.contains("USD"), 
328 			"Result should contain '$' or 'USD' for currency, but was: " + result);
329 	}
330 
331 	//====================================================================================================
332 	// Error handling
333 	//====================================================================================================
334 	@Test void a05_errors() {
335 		assertThrows(IllegalArgumentException.class, () -> new StringFormat(null));
336 		assertThrows(IllegalArgumentException.class, () -> fs(null));
337 	}
338 
339 	@Test void a06_caching() {
340 		// Should return the same instance due to caching
341 		assertSame(fs("Hello {0}"), fs("Hello {0}"));
342 
343 		// Different patterns should return different instances
344 		assertNotSame(fs("Hello {0}"), fs("Hello %s"));
345 
346 		// Constructor doesn't use cache, so instances should be different
347 		var fmt1 = new StringFormat("Hello {0}");
348 		var fmt2 = new StringFormat("Hello {0}");
349 		assertNotSame(fmt1, fmt2);
350 		assertEquals(fmt1, fmt2); // But they should be equal
351 	}
352 
353 	@Test void a07_equalsAndHashCode() {
354 		var fmt1 = StringFormat.of("Hello {0}");
355 		var fmt2 = StringFormat.of("Hello {0}");
356 		var fmt3 = StringFormat.of("Hello %s");
357 
358 		// Test equals - covers line 623
359 		assertEquals(fmt1, fmt2);
360 		assertNotEquals(fmt1, fmt3);
361 		
362 		// Test equals with null - covers line 623 (instanceof check fails)
363 		assertNotEquals(fmt1, null);
364 		
365 		// Test equals with different type - covers line 623 (instanceof check fails)
366 		assertNotEquals(fmt1, "Hello {0}");
367 		assertNotEquals(fmt1, new Object());
368 		
369 		// Test equals with different pattern - covers line 623 (pattern comparison)
370 		var fmt4 = StringFormat.of("Different pattern");
371 		assertNotEquals(fmt1, fmt4);
372 		
373 		// Test hashCode
374 		assertEquals(fmt1.hashCode(), fmt2.hashCode());
375 	}
376 
377 	@Test void a08_toString() {
378 		assertEquals("Hello {0}", fs("Hello {0}").toString());
379 	}
380 
381 	@Test void a09_toPattern() {
382 		// Literal tokens
383 		assertEquals("[L:Hello ]", fs("Hello ").toPattern());
384 		assertEquals("[L:a ][L:{0}][L: b]", fs("a '{0}' b").toPattern());  // Single quotes don't escape MessageFormat
385 
386 		// MessageFormat tokens - simple (content == null) - Line 228: content == null branch
387 		assertEquals("[L:Hello ][M:s0]", fs("Hello {0}").toPattern());
388 		assertEquals("[L:Hello ][M:s0][L: ][M:s1]", fs("Hello {0} {1}").toPattern());
389 
390 		// MessageFormat tokens - complex (content != null) - Line 228: content != null branch
391 		assertEquals("[L:Price: ][M:o0:{0,number,currency}]", fs("Price: {0,number,currency}").toPattern());
392 		assertEquals("[L:Count: ][M:o0:{0,number,integer}]", fs("Count: {0,number,integer}").toPattern());
393 		assertEquals("[L:Date: ][M:o0:{0,date,short}]", fs("Date: {0,date,short}").toPattern());
394 
395 		// StringFormat tokens - Line 406: StringFormatToken.toString()
396 		assertEquals("[L:Hello ][S:s0:%s]", fs("Hello %s").toPattern());  // Simple format: 's'
397 		assertEquals("[L:Number: ][S:d0:%d]", fs("Number: %d").toPattern());  // Simple format: 'd'
398 		assertEquals("[L:Hex: ][S:x0:%x]", fs("Hex: %x").toPattern());  // Simple format: 'x'
399 		assertEquals("[L:Float: ][S:z0:%.2f]", fs("Float: %.2f").toPattern());  // Complex format: 'z' (other)
400 		assertEquals("[L:ID: ][S:z0:%05d]", fs("ID: %05d").toPattern());  // Complex format: 'z' (other)
401 
402 		// Mixed formats
403 		assertEquals("[L:Hello ][M:s0][L:, you have ][S:d1:%d][L: items]", fs("Hello {0}, you have %d items").toPattern());
404 		assertEquals("[L:Price: ][M:o0:{0,number,currency}][L: and ][S:s1:%s]", fs("Price: {0,number,currency} and %s").toPattern());
405 
406 		// Time conversions (2-character) - Line 529: 't' or 'T' handling
407 		assertEquals("[L:Month: ][S:z0:%tm]", fs("Month: %tm").toPattern());  // %tm is 2-character time conversion
408 		assertEquals("[L:Year: ][S:z0:%tY]", fs("Year: %tY").toPattern());  // %tY is 2-character time conversion
409 		assertEquals("[L:Date: ][S:z0:%TD]", fs("Date: %TD").toPattern());  // %TD is 2-character time conversion
410 
411 		// %n doesn't consume an argument - it's handled as a LiteralToken
412 		var lineSep = System.lineSeparator();
413 		assertEquals("[L:Line 1][L:" + lineSep + "][L:Line 2]", fs("Line 1%nLine 2").toPattern());
414 		assertEquals("[S:s0:%s][L: ][L:" + lineSep + "][L: ][S:s1:%s]", fs("%s %n %s").toPattern());  // %n is a literal, so second %s gets index 1
415 	}
416 
417 	@Test void a10_veryLongPattern() {
418 		var pattern = "Start: " + IntStream.range(0, 10).mapToObj(i -> "{" + i + "}").collect(joining(" ")) + " ";
419 		var args = IntStream.range(0, 10).boxed().toArray();
420 		assertMessageFormat(pattern, args);
421 	}
422 
423 	@Test void a11_parseIndexErrors() {
424 		assertThrows(IllegalArgumentException.class, () -> fs("Hello {abc}"));
425 	}
426 
427 	//====================================================================================================
428 	// Test coverage for line 162 branches in MessageFormatToken.append()
429 	// Line 162: if (args == null || index >= args.length || index < 0)
430 	//====================================================================================================
431 	@Test void a14_messageFormatTokenBranches() {
432 		// Test args == null branch - covers line 162 (args == null)
433 		var fmt1 = StringFormat.of("Hello {0}");
434 		var result1 = fmt1.format((Object[])null);
435 		assertEquals("Hello {0}", result1);
436 		
437 		// Test with complex format and null args
438 		var fmt2 = StringFormat.of("Price: {0,number,currency}");
439 		var result2 = fmt2.format((Object[])null);
440 		assertEquals("Price: {0,number,currency}", result2);
441 		
442 		// Test locale == null branch - covers line 167 (locale == null ? Locale.getDefault() : locale)
443 		// When locale is null, should use Locale.getDefault()
444 		var fmt3 = StringFormat.of("Hello {0}");
445 		var result3 = fmt3.format((Locale)null, "World");
446 		// Should format using default locale
447 		assertEquals("Hello World", result3);
448 		
449 		// Test locale != null branch - covers line 167 (else branch)
450 		// When locale is provided, should use that locale
451 		var fmt4 = StringFormat.of("Price: {0,number,currency}");
452 		var result4 = fmt4.format(Locale.US, 19.99);
453 		// Should format using US locale (dollar sign)
454 		assertTrue(result4.contains("19.99") || result4.contains("$19.99"));
455 		
456 		var result5 = fmt4.format(Locale.FRANCE, 19.99);
457 		// Should format using France locale (different currency symbol)
458 		assertTrue(result5.contains("19.99") || result5.contains("19,99"));
459 		
460 		// Note on other branches:
461 		// - index >= args.length: This branch exists but testing it is complex because MessageFormat
462 		//   behavior with missing arguments may vary. The existing test a01_messageFormat() already
463 		//   tests patterns with missing args (line 188: assertMessageFormat("Hello {0}") with no args).
464 		// - index < 0: parseIndexMF can parse negative numbers (it uses Integer.parseInt), so technically
465 		//   a pattern like "{-1}" could create index = -1. However, MessageFormat syntax doesn't support
466 		//   negative indices, so this is a defensive check. The branch exists but is unlikely to be
467 		//   reached through normal MessageFormat patterns. Testing it would require either: (1) a pattern
468 		//   that somehow parses to a negative index, or (2) directly constructing a MessageFormatToken
469 		//   with a negative index via reflection. This may be marked as HTT (Hard To Test) if it cannot
470 		//   be reached through the public API.
471 	}
472 
473 	//====================================================================================================
474 	// Test coverage for line 217 branches in StringFormatToken.append()
475 	// Line 217: if (args == null || index >= args.length || index < 0)
476 	//====================================================================================================
477 	@Test void a15_stringFormatTokenBranches() {
478 		// Test args == null branch - covers line 217 (args == null)
479 		var fmt1 = StringFormat.of("Hello %s");
480 		assertThrows(java.util.MissingFormatArgumentException.class, () -> fmt1.format((Object[])null));
481 		
482 		// Test with complex format and null args
483 		var fmt2 = StringFormat.of("Price: %.2f");
484 		assertThrows(java.util.MissingFormatArgumentException.class, () -> fmt2.format((Object[])null));
485 		
486 		// Test with explicit index format and null args
487 		var fmt3 = StringFormat.of("First: %1$s, Second: %2$s");
488 		assertThrows(java.util.MissingFormatArgumentException.class, () -> fmt3.format((Object[])null));
489 		
490 		// Note on other branches:
491 		// - index >= args.length: This branch exists but testing it is complex because the index
492 		//   calculation depends on how sequential vs explicit indices are handled. The existing
493 		//   test a02_stringFormat() already tests patterns with missing args (line 282-283:
494 		//   assertStringFormat("Hello %s") and assertStringFormat("Hello %s and %s", "John")).
495 		//   These tests verify that MissingFormatArgumentException is thrown, which exercises
496 		//   the index >= args.length branch.
497 		// - index < 0: parseIndexSF can parse negative numbers (it uses Integer.parseInt), and the
498 		//   index is calculated as parseIndexSF(...) - 1. So if parseIndexSF returns 0, index = -1.
499 		//   However, printf-style format specifiers use 1-based indexing (e.g., %1$s), so parseIndexSF
500 		//   would return 1, making index = 0. A negative index would require parseIndexSF to return 0,
501 		//   which would mean a format specifier like %0$s. While this is technically possible to parse,
502 		//   it's invalid printf syntax (indices must be >= 1). The branch exists as a defensive check
503 		//   but is unlikely to be reached through normal printf patterns. This may be marked as HTT
504 		//   (Hard To Test) if it cannot be reached through the public API.
505 	}
506 
507 	@Test void a12_localeHandling() {
508 		// Lines 259-260: Test locale null checks and default locale detection in StringFormatToken
509 		// Line 259: var l = locale == null ? Locale.getDefault() : locale;
510 		// Line 260: var dl = locale == null || locale.equals(Locale.getDefault());
511 
512 		// Test with null locale (covers locale == null on both lines)
513 		assertStringFormat("Hello %s", (Locale)null, "John");
514 		assertStringFormat("Number: %d", (Locale)null, 42);
515 		assertStringFormat("Float: %.2f", (Locale)null, 3.14);  // Use .2f for consistent formatting
516 
517 		// Test with default locale (covers locale.equals(Locale.getDefault()) on line 260)
518 		assertStringFormat("Hello %s", Locale.getDefault(), "John");
519 		assertStringFormat("Number: %d", Locale.getDefault(), 42);
520 		assertStringFormat("Float: %.2f", Locale.getDefault(), 3.14);  // Use .2f for consistent formatting
521 
522 		// Test with non-default locale (covers else branch on line 259 and false case on line 260)
523 		assertStringFormat("Hello %s", Locale.FRANCE, "John");
524 		assertStringFormat("Number: %d", Locale.GERMANY, 42);
525 		assertStringFormat("Float: %.2f", Locale.JAPAN, 3.14);  // Use .2f for consistent formatting
526 	}
527 
528 	//====================================================================================================
529 	// format(String, Locale, Object...) - covers lines 413-415
530 	//====================================================================================================
531 	@Test void a13_format_withLocale() {
532 		// Test with empty args - covers lines 413-414 (args.length == 0, return pattern)
533 		String result1 = StringFormat.format("Hello", Locale.US);
534 		assertEquals("Hello", result1);
535 		
536 		String result2 = StringFormat.format("Test pattern", Locale.FRANCE);
537 		assertEquals("Test pattern", result2);
538 		
539 		// Test with args - covers line 415 (calls of(pattern).format(locale, args))
540 		String result3 = StringFormat.format("Hello %s", Locale.US, "World");
541 		assertEquals("Hello World", result3);
542 		
543 		String result4 = StringFormat.format("Price: {0,number,currency}", Locale.US, 19.99);
544 		assertTrue(result4.contains("19.99") || result4.contains("$19.99"));
545 		
546 		// Test with null locale and empty args - covers line 413-414
547 		String result5 = StringFormat.format("Test", (Locale)null);
548 		assertEquals("Test", result5);
549 	}
550 }