punctilio 1.2.10 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,528 @@
1
+ import { transform, DEFAULT_SEPARATOR, countSeparators } from "../index.js"
2
+ import { ellipsis } from "../symbols.js"
3
+ import { UNICODE_SYMBOLS, REGEX_SPECIAL_CHARS } from "../constants.js"
4
+
5
+ const {
6
+ LEFT_DOUBLE_QUOTE,
7
+ RIGHT_DOUBLE_QUOTE,
8
+ RIGHT_SINGLE_QUOTE,
9
+ EM_DASH,
10
+ EN_DASH,
11
+ ELLIPSIS,
12
+ MULTIPLICATION,
13
+ NOT_EQUAL,
14
+ COPYRIGHT,
15
+ NBSP,
16
+ PRIME,
17
+ DOUBLE_PRIME,
18
+ TRADEMARK,
19
+ REGISTERED,
20
+ DEGREE,
21
+ FRACTION_1_2,
22
+ SUPERSCRIPT_ST,
23
+ } = UNICODE_SYMBOLS
24
+
25
+ describe("transform", () => {
26
+ it("applies both quote and dash transformations", () => {
27
+ const input = '"Hello," she said - "it\'s pages 1-5."'
28
+ const expected = `${LEFT_DOUBLE_QUOTE}Hello,${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}it${RIGHT_SINGLE_QUOTE}s pages 1${EN_DASH}5.${RIGHT_DOUBLE_QUOTE}`
29
+ expect(transform(input)).toBe(expected)
30
+ })
31
+
32
+ it("handles complex mixed content", () => {
33
+ const input = 'I was born in \'99 - "the best year" - and pages 10-20 are my favorite.'
34
+ const expected = `I was born in ${RIGHT_SINGLE_QUOTE}99${EM_DASH}${LEFT_DOUBLE_QUOTE}the best year${RIGHT_DOUBLE_QUOTE}${EM_DASH}and pages 10${EN_DASH}20 are my favorite.`
35
+ expect(transform(input)).toBe(expected)
36
+ })
37
+
38
+ it("preserves separator character", () => {
39
+ const sep = DEFAULT_SEPARATOR
40
+ const input = `"Hello${sep}" - test`
41
+ expect(transform(input, { separator: sep })).toBe(`${LEFT_DOUBLE_QUOTE}Hello${sep}${RIGHT_DOUBLE_QUOTE}${EM_DASH}test`)
42
+ })
43
+
44
+ describe("symbol transforms", () => {
45
+ it("applies symbol transforms by default", () => {
46
+ expect(transform('Wait... 5x5 != 25 (c) 2024')).toBe(`Wait${ELLIPSIS} 5${MULTIPLICATION}5 ${NOT_EQUAL} 25 ${COPYRIGHT} 2024`)
47
+ })
48
+
49
+ it("can disable symbol transforms", () => {
50
+ expect(transform('Wait... 5x5 != 25', { symbols: false })).toBe('Wait... 5x5 != 25')
51
+ })
52
+ })
53
+
54
+ describe("optional transforms", () => {
55
+ it.each([
56
+ ["fractions disabled", "1/2", {}, "1/2"],
57
+ ["fractions enabled", "1/2", { fractions: true }, UNICODE_SYMBOLS.FRACTION_1_2],
58
+ ["degrees disabled", "20 C", {}, "20 C"],
59
+ ["degrees enabled", "20 C", { degrees: true }, `20 ${UNICODE_SYMBOLS.DEGREE}C`],
60
+ ["superscript disabled", "1st", {}, "1st"],
61
+ ["superscript enabled", "1st", { superscript: true }, `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
62
+ ["ligatures disabled", "??", {}, "??"],
63
+ ["ligatures enabled", "??", { ligatures: true }, UNICODE_SYMBOLS.DOUBLE_QUESTION],
64
+ ] as const)("%s: %s → %s", (_desc, input, options, expected) => {
65
+ expect(transform(input, options)).toBe(expected)
66
+ })
67
+ })
68
+
69
+ describe("punctuationStyle option", () => {
70
+ const periodOutside = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}.`
71
+ const periodInside = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE}`
72
+
73
+ it.each([
74
+ ['"Hello".', undefined, periodInside, "american (default)"],
75
+ ['"Hello."', "british", periodOutside, "british"],
76
+ ['"Hello".', "none", periodOutside, "none"],
77
+ ] as const)("handles %s with %s style", (input, style, expected) => {
78
+ expect(transform(input, style ? { punctuationStyle: style } : {})).toBe(expected)
79
+ })
80
+ })
81
+
82
+ describe("collapseSpaces option", () => {
83
+ it.each([
84
+ ["hello world", "hello world", "multiple spaces"],
85
+ [`foo${NBSP}${NBSP}bar`, `foo${NBSP}bar`, "multiple nbsp"],
86
+ [`a ${NBSP}b`, `a${NBSP}b`, "mixed spaces prefer nbsp"],
87
+ [`a${NBSP} b`, `a${NBSP}b`, "mixed spaces prefer nbsp"],
88
+ ])("collapses %s by default", (input, expected) => {
89
+ expect(transform(input)).toBe(expected)
90
+ })
91
+
92
+ it.each([
93
+ ["hello world", "hello world", "multiple spaces"],
94
+ [`foo${NBSP}${NBSP}bar`, `foo${NBSP}${NBSP}bar`, "multiple nbsp"],
95
+ ])("preserves %s when disabled", (input, expected) => {
96
+ expect(transform(input, { collapseSpaces: false })).toBe(expected)
97
+ })
98
+ })
99
+
100
+ describe("separator preservation", () => {
101
+ it.each([
102
+ [`Wait.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}. for it`, 2],
103
+ [`"Hello${DEFAULT_SEPARATOR}" - ${DEFAULT_SEPARATOR}she${DEFAULT_SEPARATOR} said`, 3],
104
+ [`.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}.`, 2],
105
+ ])('preserves %i separators in "%s"', (input, expectedCount) => {
106
+ expect(() => transform(input, { separator: DEFAULT_SEPARATOR })).not.toThrow()
107
+ expect(countSeparators(transform(input, { separator: DEFAULT_SEPARATOR }), DEFAULT_SEPARATOR)).toBe(expectedCount)
108
+ })
109
+
110
+ it("preserves separator in ellipsis", () => {
111
+ const input = `.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}.`
112
+ const result = ellipsis(input, { separator: DEFAULT_SEPARATOR })
113
+ expect(result.split(DEFAULT_SEPARATOR).length - 1).toBe(2)
114
+ })
115
+
116
+ it("preserves consecutive separators", () => {
117
+ const input = `a${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}b`
118
+ expect(transform(input)).toBe(input)
119
+ })
120
+ })
121
+
122
+ describe("Unicode edge cases", () => {
123
+ it.each([
124
+ '"café"',
125
+ '"Hello 👋 world"',
126
+ '"日本語"',
127
+ '"שלום"',
128
+ "a\u200Bb",
129
+ ])('preserves content in "%s"', (input) => {
130
+ expect(transform(input)).toBe(transform(input)) // idempotent
131
+ })
132
+ })
133
+
134
+ // Tests derived from competitor libraries and benchmark cases
135
+ describe("competitor-derived comprehensive tests", () => {
136
+ // These tests document where punctilio excels over competitors
137
+ describe("punctilio advantages (benchmark cases)", () => {
138
+ it.each([
139
+ // Leading apostrophes - smartypants/tipograph fail these
140
+ ["'SUP", `${RIGHT_SINGLE_QUOTE}SUP`],
141
+ ["Rock 'n' Roll", `Rock ${RIGHT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} Roll`],
142
+ ["I was born in '99", `I was born in ${RIGHT_SINGLE_QUOTE}99`],
143
+ // Em dashes from surrounded hyphens - smartquotes fails
144
+ ["This is a - hyphen.", `This is a${EM_DASH}hyphen.`],
145
+ // Number ranges - smartypants/smartquotes/typograf fail
146
+ ["Pages 1-5", `Pages 1${EN_DASH}5`],
147
+ ["2000-2020", `2000${EN_DASH}2020`],
148
+ // Date ranges - all competitors fail
149
+ ["January-March", `January${EN_DASH}March`],
150
+ // Minus signs - most competitors fail
151
+ ["-5", `−5`],
152
+ ["(-5)", `(−5)`],
153
+ // Prime marks - smartypants/typograf fail
154
+ ['5\'10"', `5′10″`],
155
+ ['He is 6\'2" tall', `He is 6′2″ tall`],
156
+ ])('correctly transforms: "%s"', (input, expected) => {
157
+ expect(transform(input, { symbols: true, degrees: true })).toBe(expected)
158
+ })
159
+ })
160
+
161
+ // Compound word preservation (all libraries should preserve these)
162
+ describe("compound word preservation", () => {
163
+ it.each([
164
+ "well-known",
165
+ "a browser- or OS-specific fashion",
166
+ "re-read",
167
+ "self-aware",
168
+ "high-quality",
169
+ ])('preserves compound word: "%s"', (input) => {
170
+ expect(transform(input)).toBe(input)
171
+ })
172
+ })
173
+
174
+ // Model name preservation (punctilio's unique strength)
175
+ describe("model name preservation", () => {
176
+ it.each([
177
+ "Llama-2-7B",
178
+ "Llama-3-8B-Instruct",
179
+ "GPT-4",
180
+ "Claude-3-Opus",
181
+ "Qwen1.5-1.8B",
182
+ ])('preserves model name: "%s"', (input) => {
183
+ expect(transform(input)).toBe(input)
184
+ })
185
+ })
186
+ })
187
+
188
+ describe("complex real-world text", () => {
189
+ it("handles dialogue with dashes and quotes", () => {
190
+ const input = '"Wait," she said -- "I don\'t think that\'s right."'
191
+ const expected = `${LEFT_DOUBLE_QUOTE}Wait,${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}I don${RIGHT_SINGLE_QUOTE}t think that${RIGHT_SINGLE_QUOTE}s right.${RIGHT_DOUBLE_QUOTE}`
192
+ expect(transform(input)).toEqual(expected)
193
+ })
194
+
195
+ it("handles technical documentation", () => {
196
+ const input = 'The API returns x != y when a <= b and c >= d. Error tolerance: +-5%.'
197
+ const expected = `The API returns x ${NOT_EQUAL} y when a ≤ b and c ≥ d. Error tolerance: ±5%.`
198
+ expect(transform(input)).toBe(expected)
199
+ })
200
+
201
+ it("handles measurement text", () => {
202
+ const input = 'He is 6\'2" tall.'
203
+ const expected = `He is 6${PRIME}2${DOUBLE_PRIME} tall.`
204
+ expect(transform(input, { symbols: true })).toEqual(expected)
205
+ })
206
+
207
+ it("handles copyright notices", () => {
208
+ const input = '(c) 2024 Company(tm). All rights reserved(r).'
209
+ const expected = `${COPYRIGHT} 2024 Company${TRADEMARK}. All rights reserved${REGISTERED}.`
210
+ expect(transform(input)).toEqual(expected)
211
+ })
212
+ })
213
+
214
+ describe("known limitations", () => {
215
+ // These document current behavior for edge cases
216
+ it("documents prime mark vs closing quote ambiguity", () => {
217
+ // When there's an open quote before a number, the quote after should close
218
+ // not become a prime mark
219
+ const input = '"Number 5"'
220
+ const result = transform(input)
221
+ // The 5" should be a closing quote, not a prime
222
+ expect(result).toBe(`${LEFT_DOUBLE_QUOTE}Number 5${RIGHT_DOUBLE_QUOTE}`)
223
+ })
224
+ })
225
+
226
+ describe("complex combined transformations", () => {
227
+ it.each([
228
+ [
229
+ '"Wait..." she said - "it\'s pages 1-5."',
230
+ `${LEFT_DOUBLE_QUOTE}Wait…${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}it${RIGHT_SINGLE_QUOTE}s pages 1–5.${RIGHT_DOUBLE_QUOTE}`,
231
+ ],
232
+ [
233
+ "(c) 2024 - Room is 10x12, set to 72 F",
234
+ `${COPYRIGHT} 2024${EM_DASH}Room is 10×12, set to 72 °F`,
235
+ ],
236
+ ])('handles complex transform: "%s"', (input, expected) => {
237
+ expect(transform(input, { degrees: true })).toBe(expected)
238
+ })
239
+ })
240
+
241
+ describe("empty and whitespace inputs", () => {
242
+ it.each([
243
+ ["", ""],
244
+ [" ", " "],
245
+ ["\n", "\n"],
246
+ ["\t", "\t"],
247
+ [" ", " "],
248
+ ])('handles empty/whitespace: "%s"', (input, expected) => {
249
+ expect(transform(input)).toBe(expected)
250
+ })
251
+ })
252
+
253
+ describe("very long inputs", () => {
254
+ it("handles long repeated patterns", () => {
255
+ const input = '"Hello" '.repeat(100)
256
+ const expected = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE} `.repeat(100)
257
+ expect(transform(input)).toEqual(expected)
258
+ })
259
+
260
+ it("handles long continuous text", () => {
261
+ const input = "word ".repeat(1000)
262
+ const result = transform(input)
263
+ expect(result).toBe(input.trim() + " ")
264
+ })
265
+
266
+ it("converts ALL quote pairs in repeated pattern", () => {
267
+ const count = 50
268
+ const input = '"Hello" '.repeat(count)
269
+ const result = transform(input)
270
+ const leftCount = (result.match(new RegExp(LEFT_DOUBLE_QUOTE, "g")) || []).length
271
+ const rightCount = (result.match(new RegExp(RIGHT_DOUBLE_QUOTE, "g")) || []).length
272
+ expect(leftCount).toBe(count)
273
+ expect(rightCount).toBe(count)
274
+ })
275
+
276
+ it("converts ALL dashes in repeated pattern", () => {
277
+ const count = 50
278
+ const input = "pages 1-5 ".repeat(count)
279
+ const result = transform(input)
280
+ const enDashCount = (result.match(/–/g) || []).length
281
+ expect(enDashCount).toBe(count)
282
+ })
283
+
284
+ it("converts ALL ellipses in repeated pattern", () => {
285
+ const count = 50
286
+ const input = "Wait... ".repeat(count)
287
+ const result = transform(input)
288
+ const ellipsisCount = (result.match(/…/g) || []).length
289
+ expect(ellipsisCount).toBe(count)
290
+ })
291
+ })
292
+
293
+ describe("Unicode edge cases", () => {
294
+ it.each([
295
+ ['"test\u200Bword"', `${LEFT_DOUBLE_QUOTE}test\u200Bword${RIGHT_DOUBLE_QUOTE}`],
296
+ ['"test\u200Fword"', `${LEFT_DOUBLE_QUOTE}test\u200Fword${RIGHT_DOUBLE_QUOTE}`],
297
+ ['"caf\u0065\u0301"', `${LEFT_DOUBLE_QUOTE}caf\u0065\u0301${RIGHT_DOUBLE_QUOTE}`],
298
+ ])('handles Unicode edge case', (input, expected) => {
299
+ expect(transform(input)).toBe(expected)
300
+ })
301
+ })
302
+
303
+ describe("option combinations", () => {
304
+ it("applies all optional transforms together", () => {
305
+ const input = "1st place: 1/2 at 72 F - wow!!"
306
+ const expected = `1${SUPERSCRIPT_ST} place: ${FRACTION_1_2} at 72 ${DEGREE}F${EM_DASH}wow!`
307
+ expect(transform(input, {
308
+ fractions: true,
309
+ degrees: true,
310
+ superscript: true,
311
+ ligatures: true,
312
+ })).toEqual(expected)
313
+ })
314
+
315
+ it("respects disabled transforms", () => {
316
+ const input = "1/2 at 72 F"
317
+ const expected = `${FRACTION_1_2} at 72 F`
318
+ expect(transform(input, {
319
+ fractions: true,
320
+ degrees: false,
321
+ })).toEqual(expected)
322
+ })
323
+ })
324
+
325
+ describe("separator robustness", () => {
326
+ const sep = DEFAULT_SEPARATOR
327
+
328
+ it.each([
329
+ [`${sep}"test"${sep}`, `${sep}${LEFT_DOUBLE_QUOTE}test${RIGHT_DOUBLE_QUOTE}${sep}`],
330
+ [`"test${sep}word"`, `${LEFT_DOUBLE_QUOTE}test${sep}word${RIGHT_DOUBLE_QUOTE}`],
331
+ [`${sep}${sep}"test"${sep}${sep}`, `${sep}${sep}${LEFT_DOUBLE_QUOTE}test${RIGHT_DOUBLE_QUOTE}${sep}${sep}`],
332
+ ])('handles separator in quotes', (input, expected) => {
333
+ expect(transform(input, { separator: sep })).toBe(expected)
334
+ })
335
+
336
+ it.each([
337
+ [`word${sep} - ${sep}word`, `word${sep}${EM_DASH}${sep}word`],
338
+ [`1${sep}-${sep}5`, `1${sep}–${sep}5`],
339
+ ])('handles separator in dashes', (input, expected) => {
340
+ expect(transform(input, { separator: sep })).toBe(expected)
341
+ })
342
+
343
+ it("preserves exact separator count through transform", () => {
344
+ const input = `${sep}text${sep}more${sep}text${sep}`
345
+ const result = transform(input, { separator: sep })
346
+ const inputCount = (input.match(new RegExp(sep, "g")) || []).length
347
+ const resultCount = (result.match(new RegExp(sep, "g")) || []).length
348
+ expect(resultCount).toBe(inputCount)
349
+ })
350
+
351
+ it("handles input containing separator character", () => {
352
+ const input = `Text with ${sep} in it`
353
+ expect(() => transform(input, { separator: sep })).not.toThrow()
354
+ })
355
+ })
356
+
357
+ describe("additional idempotency", () => {
358
+ it.each([
359
+ '"Hello" - she said, "it\'s pages 1-5..."',
360
+ 'Product(tm) 5x5 at 72 F (c) 2024',
361
+ "Add 1/2 cup - that's about 3/4 done",
362
+ '1st place winner said "congrats!!"',
363
+ ])('is idempotent: "%s"', (input) => {
364
+ const first = transform(input, { fractions: true, degrees: true, superscript: true, ligatures: true })
365
+ const second = transform(first, { fractions: true, degrees: true, superscript: true, ligatures: true })
366
+ expect(second).toBe(first)
367
+ })
368
+
369
+ it.each([
370
+ `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`,
371
+ `word${EM_DASH}word`,
372
+ `1–5`,
373
+ `Wait…`,
374
+ `5×5`,
375
+ `20 °C`,
376
+ `${COPYRIGHT} 2024`,
377
+ ])('stable for: "%s"', (input) => {
378
+ expect(transform(input)).toBe(input)
379
+ })
380
+ })
381
+
382
+ describe("style combinations", () => {
383
+ it("applies American conventions throughout", () => {
384
+ const input = '"Hello." - word - "World."'
385
+ const expected = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE}${EM_DASH}word${EM_DASH}${LEFT_DOUBLE_QUOTE}World.${RIGHT_DOUBLE_QUOTE}`
386
+ expect(transform(input, { punctuationStyle: "american", dashStyle: "american" })).toEqual(expected)
387
+ })
388
+
389
+ it("applies British conventions throughout", () => {
390
+ const input = '"Hello." - word - "World."'
391
+ const expected = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}. ${EN_DASH} word ${EN_DASH} ${LEFT_DOUBLE_QUOTE}World${RIGHT_DOUBLE_QUOTE}.`
392
+ expect(transform(input, { punctuationStyle: "british", dashStyle: "british" })).toEqual(expected)
393
+ })
394
+
395
+ it("applies American punctuation with British dashes", () => {
396
+ const input = '"Hello." - word'
397
+ const expected = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE} ${EN_DASH} word`
398
+ expect(transform(input, { punctuationStyle: "american", dashStyle: "british" })).toEqual(expected)
399
+ })
400
+ })
401
+
402
+ describe("regex performance", () => {
403
+ it("handles pathological quote patterns efficiently", () => {
404
+ const input = '"a"'.repeat(50) + "'"
405
+ const start = Date.now()
406
+ transform(input)
407
+ const duration = Date.now() - start
408
+ expect(duration).toBeLessThan(1000)
409
+ })
410
+
411
+ it("handles pathological dash patterns efficiently", () => {
412
+ const input = "1-2-3-4-5-6-7-8-9-10-11-12-13-14-15"
413
+ const start = Date.now()
414
+ transform(input)
415
+ const duration = Date.now() - start
416
+ expect(duration).toBeLessThan(1000)
417
+ })
418
+
419
+ it("handles 1000 unbalanced single quotes efficiently", () => {
420
+ const input = "'".repeat(1000) + "a"
421
+ const start = performance.now()
422
+ transform(input)
423
+ const duration = performance.now() - start
424
+ expect(duration).toBeLessThan(500)
425
+ })
426
+
427
+ it("handles 500 chained number patterns efficiently", () => {
428
+ const input = "1" + "-1".repeat(500)
429
+ const start = performance.now()
430
+ transform(input)
431
+ const duration = performance.now() - start
432
+ expect(duration).toBeLessThan(500)
433
+ })
434
+ })
435
+
436
+ describe("special regex characters in input", () => {
437
+ it.each([
438
+ ['"test [bracket]"', `${LEFT_DOUBLE_QUOTE}test [bracket]${RIGHT_DOUBLE_QUOTE}`],
439
+ ['"test (paren)"', `${LEFT_DOUBLE_QUOTE}test (paren)${RIGHT_DOUBLE_QUOTE}`],
440
+ ['"test {brace}"', `${LEFT_DOUBLE_QUOTE}test {brace}${RIGHT_DOUBLE_QUOTE}`],
441
+ ['"test $dollar"', `${LEFT_DOUBLE_QUOTE}test $dollar${RIGHT_DOUBLE_QUOTE}`],
442
+ ['"test ^caret"', `${LEFT_DOUBLE_QUOTE}test ^caret${RIGHT_DOUBLE_QUOTE}`],
443
+ ['"test .dot"', `${LEFT_DOUBLE_QUOTE}test .dot${RIGHT_DOUBLE_QUOTE}`],
444
+ ['"test |pipe"', `${LEFT_DOUBLE_QUOTE}test |pipe${RIGHT_DOUBLE_QUOTE}`],
445
+ ])('handles regex special chars: "%s"', (input, expected) => {
446
+ expect(transform(input)).toBe(expected)
447
+ })
448
+ })
449
+
450
+ describe("boundary conditions", () => {
451
+ it.each([
452
+ ['"start', `${LEFT_DOUBLE_QUOTE}start`],
453
+ ["'start", `${RIGHT_SINGLE_QUOTE}start`],
454
+ ["-5 start", `−5 start`],
455
+ ["...start", `… start`],
456
+ ['end"', `end${RIGHT_DOUBLE_QUOTE}`],
457
+ ["end'", `end${RIGHT_SINGLE_QUOTE}`],
458
+ ["end...", `end…`],
459
+ ["5x", `5×`],
460
+ ])('handles boundary: "%s"', (input, expected) => {
461
+ expect(transform(input)).toBe(expected)
462
+ })
463
+ })
464
+
465
+ describe("separator validation", () => {
466
+ const invalidSeparators = [
467
+ ["🎉", "emoji (multi-codepoint)"],
468
+ ["ab", "multi-character"],
469
+ ["", "empty string"],
470
+ ] as const
471
+
472
+ it.each(invalidSeparators)("rejects %s separator (%s)", (sep) => {
473
+ expect(() => transform('"Hello"', { separator: sep })).toThrow(
474
+ /Invalid separator.*must be a single character/
475
+ )
476
+ })
477
+
478
+ const validSeparators = ["\uE000", "|", "\u2603"] // Default, pipe, snowman
479
+
480
+ it.each(validSeparators)("accepts '%s' as separator", (sep) => {
481
+ expect(() => transform('"Hello"', { separator: sep })).not.toThrow()
482
+ })
483
+ })
484
+
485
+ describe("regex-special separator characters", () => {
486
+ it.each(REGEX_SPECIAL_CHARS)("handles '%s' as separator", (sep) => {
487
+ expect(transform('"Hello"', { separator: sep })).toEqual(
488
+ `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`
489
+ )
490
+ })
491
+ })
492
+
493
+ describe("Unicode preservation", () => {
494
+ it.each([
495
+ ["emoji", '"Hello 👋 World"', `${LEFT_DOUBLE_QUOTE}Hello 👋 World${RIGHT_DOUBLE_QUOTE}`],
496
+ ["RTL text", '"שלום"', `${LEFT_DOUBLE_QUOTE}שלום${RIGHT_DOUBLE_QUOTE}`],
497
+ ["zero-width space", '"Hello\u200BWorld"', `${LEFT_DOUBLE_QUOTE}Hello\u200BWorld${RIGHT_DOUBLE_QUOTE}`],
498
+ ] as const)("preserves %s in input", (_, input, expected) => {
499
+ expect(transform(input)).toEqual(expected)
500
+ })
501
+ })
502
+
503
+ describe("large input handling", () => {
504
+ it("handles 1MB of text", () => {
505
+ const input = "Hello, world! ".repeat(70000)
506
+ const start = performance.now()
507
+ transform(input)
508
+ expect(performance.now() - start).toBeLessThan(2000)
509
+ })
510
+ })
511
+
512
+ describe("ReDoS resistance", () => {
513
+ const MAX_TIMEOUT_MS = 100
514
+
515
+ it.each([
516
+ ["10000 single quotes", "'".repeat(10000)],
517
+ ["10000 double quotes", '"'.repeat(10000)],
518
+ ["1000 unbalanced quotes", '"Hello '.repeat(1000)],
519
+ ["10000 hyphens", "-".repeat(10000)],
520
+ ["alternating digits/hyphens", "1-2-3-4-5-6-7-8-9-0".repeat(500)],
521
+ ["10000 dots", ".".repeat(10000)],
522
+ ] as const)("handles %s", (_, input) => {
523
+ const start = performance.now()
524
+ transform(input)
525
+ expect(performance.now() - start).toBeLessThan(MAX_TIMEOUT_MS)
526
+ })
527
+ })
528
+ })