punctilio 1.3.1 → 1.4.1

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,708 @@
1
+ import {
2
+ ellipsis,
3
+ multiplication,
4
+ mathSymbols,
5
+ legalSymbols,
6
+ arrows,
7
+ degrees,
8
+ primeMarks,
9
+ fractions,
10
+ collapseSpaces,
11
+ superscriptOrdinal,
12
+ punctuationLigatures,
13
+ symbolTransform,
14
+ } from "../symbols.js"
15
+ import { UNICODE_SYMBOLS, DEFAULT_SEPARATOR } from "../constants.js"
16
+
17
+ describe("ellipsis", () => {
18
+ it.each([
19
+ ["Wait for it...", `Wait for it${UNICODE_SYMBOLS.ELLIPSIS}`],
20
+ ["Hmm... let me think", `Hmm${UNICODE_SYMBOLS.ELLIPSIS} let me think`],
21
+ ["...", UNICODE_SYMBOLS.ELLIPSIS],
22
+ ["Hello...world", `Hello${UNICODE_SYMBOLS.ELLIPSIS} world`],
23
+ ["End of sentence...", `End of sentence${UNICODE_SYMBOLS.ELLIPSIS}`],
24
+ ['..."', `${UNICODE_SYMBOLS.ELLIPSIS}"`],
25
+ ["...!", `${UNICODE_SYMBOLS.ELLIPSIS}!`],
26
+ ["...?", `${UNICODE_SYMBOLS.ELLIPSIS}?`],
27
+ ["e.g.", "e.g."],
28
+ ["U.S.A.", "U.S.A."],
29
+ ["a.b", "a.b"],
30
+ ["End....", `End${UNICODE_SYMBOLS.ELLIPSIS}.`], // 4 dots
31
+ ["......", `${UNICODE_SYMBOLS.ELLIPSIS}${UNICODE_SYMBOLS.ELLIPSIS}`], // 6 dots
32
+ ])('converts "%s" to "%s"', (input, expected) => {
33
+ expect(ellipsis(input)).toBe(expected)
34
+ })
35
+
36
+ it.each([
37
+ ["between all dots", `.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}.`, `${UNICODE_SYMBOLS.ELLIPSIS}${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}`],
38
+ ["after ellipsis", `...${DEFAULT_SEPARATOR}`, `${UNICODE_SYMBOLS.ELLIPSIS}${DEFAULT_SEPARATOR}`],
39
+ ["after first dot", `.${DEFAULT_SEPARATOR}..`, `${UNICODE_SYMBOLS.ELLIPSIS}${DEFAULT_SEPARATOR}`],
40
+ ["after second dot", `..${DEFAULT_SEPARATOR}.`, `${UNICODE_SYMBOLS.ELLIPSIS}${DEFAULT_SEPARATOR}`],
41
+ ])("preserves separators: %s", (_desc, input, expected) => {
42
+ const result = ellipsis(input, { separator: DEFAULT_SEPARATOR })
43
+ expect(result).toBe(expected)
44
+ })
45
+ })
46
+
47
+ describe("multiplication", () => {
48
+ it.each([
49
+ ["5x5", `5${UNICODE_SYMBOLS.MULTIPLICATION}5`],
50
+ ["10 x 20", `10 ${UNICODE_SYMBOLS.MULTIPLICATION} 20`],
51
+ ["10x20", `10${UNICODE_SYMBOLS.MULTIPLICATION}20`],
52
+ ["The room is 10x12 feet", `The room is 10${UNICODE_SYMBOLS.MULTIPLICATION}12 feet`],
53
+ ["2x speed", `2${UNICODE_SYMBOLS.MULTIPLICATION} speed`],
54
+ ["3x", `3${UNICODE_SYMBOLS.MULTIPLICATION}`],
55
+ ["5*3", `5${UNICODE_SYMBOLS.MULTIPLICATION}3`],
56
+ ["Resolution: 1920x1080", `Resolution: 1920${UNICODE_SYMBOLS.MULTIPLICATION}1080`],
57
+ // Test trailing multiplier before punctuation (word boundary cases)
58
+ ['2x"', `2${UNICODE_SYMBOLS.MULTIPLICATION}"`],
59
+ ["2x'", `2${UNICODE_SYMBOLS.MULTIPLICATION}'`],
60
+ ["2x.", `2${UNICODE_SYMBOLS.MULTIPLICATION}.`],
61
+ ["2x,", `2${UNICODE_SYMBOLS.MULTIPLICATION},`],
62
+ ["2x!", `2${UNICODE_SYMBOLS.MULTIPLICATION}!`],
63
+ ["2x?", `2${UNICODE_SYMBOLS.MULTIPLICATION}?`],
64
+ ["extra", "extra"],
65
+ ["complex", "complex"],
66
+ ["x-axis", "x-axis"],
67
+ ["5xword", "5xword"],
68
+ ["10xbox", "10xbox"],
69
+ ["2xLarge", "2xLarge"],
70
+ ])('converts "%s" to "%s"', (input, expected) => {
71
+ expect(multiplication(input)).toBe(expected)
72
+ })
73
+
74
+ it("handles separator characters", () => {
75
+ const sep = "\uE000"
76
+ expect(multiplication(`5${sep}x${sep}5`, { separator: sep })).toBe(`5${sep}${UNICODE_SYMBOLS.MULTIPLICATION}${sep}5`)
77
+ })
78
+
79
+ describe("marker robustness", () => {
80
+ // Tests that separators don't create false word boundaries
81
+ // e.g., "5x\uE000tra" should NOT match because the actual text is "5xtra"
82
+ const sep = DEFAULT_SEPARATOR
83
+
84
+ it.each([
85
+ // [description, input, expected]
86
+ ["false boundary before word char", `5x${sep}tra`, `5x${sep}tra`], // "5xtra" - should NOT convert
87
+ ["valid boundary before space", `5x${sep} done`, `5${UNICODE_SYMBOLS.MULTIPLICATION}${sep} done`], // should convert
88
+ ["valid boundary before punctuation", `5x${sep}.`, `5${UNICODE_SYMBOLS.MULTIPLICATION}${sep}.`], // should convert
89
+ ])("handles %s", (_desc, input, expected) => {
90
+ expect(multiplication(input, { separator: sep })).toBe(expected)
91
+ })
92
+ })
93
+ })
94
+
95
+ describe("mathSymbols", () => {
96
+ it.each([
97
+ ["x != y", `x ${UNICODE_SYMBOLS.NOT_EQUAL} y`],
98
+ ["a!=b", `a${UNICODE_SYMBOLS.NOT_EQUAL}b`],
99
+ ["1 != 2", `1 ${UNICODE_SYMBOLS.NOT_EQUAL} 2`],
100
+ ["+-5", `${UNICODE_SYMBOLS.PLUS_MINUS}5`],
101
+ ["+/-5", `${UNICODE_SYMBOLS.PLUS_MINUS}5`],
102
+ ["The answer is +- 5%", `The answer is ${UNICODE_SYMBOLS.PLUS_MINUS} 5%`],
103
+ ["tolerance: +/-0.5", `tolerance: ${UNICODE_SYMBOLS.PLUS_MINUS}0.5`],
104
+ ["a <= b", `a ${UNICODE_SYMBOLS.LESS_EQUAL} b`],
105
+ ["x >= y", `x ${UNICODE_SYMBOLS.GREATER_EQUAL} y`],
106
+ ["~= 5", `${UNICODE_SYMBOLS.APPROXIMATE} 5`],
107
+ ["=~ 5", `${UNICODE_SYMBOLS.APPROXIMATE} 5`],
108
+ ])('converts "%s" to "%s"', (input, expected) => {
109
+ expect(mathSymbols(input)).toBe(expected)
110
+ })
111
+ })
112
+
113
+ describe("legalSymbols", () => {
114
+ it.each([
115
+ ["Copyright (c) 2024", `Copyright ${UNICODE_SYMBOLS.COPYRIGHT} 2024`],
116
+ ["(C) Acme Inc", `${UNICODE_SYMBOLS.COPYRIGHT} Acme Inc`],
117
+ ["Brand(r)", `Brand${UNICODE_SYMBOLS.REGISTERED}`],
118
+ ["Product (R)", `Product ${UNICODE_SYMBOLS.REGISTERED}`],
119
+ ["Name(tm)", `Name${UNICODE_SYMBOLS.TRADEMARK}`],
120
+ ["Name (TM)", `Name ${UNICODE_SYMBOLS.TRADEMARK}`],
121
+ ["(c) 2024 (r) Brand(tm)", `${UNICODE_SYMBOLS.COPYRIGHT} 2024 ${UNICODE_SYMBOLS.REGISTERED} Brand${UNICODE_SYMBOLS.TRADEMARK}`],
122
+ ])('converts "%s" to "%s"', (input, expected) => {
123
+ expect(legalSymbols(input)).toBe(expected)
124
+ })
125
+ })
126
+
127
+ describe("arrows", () => {
128
+ it.each([
129
+ ["A -> B", `A ${UNICODE_SYMBOLS.ARROW_RIGHT} B`],
130
+ ["A <- B", `A ${UNICODE_SYMBOLS.ARROW_LEFT} B`],
131
+ ["A <-> B", `A ${UNICODE_SYMBOLS.ARROW_LEFT_RIGHT} B`],
132
+ ["A --> B", `A ${UNICODE_SYMBOLS.ARROW_RIGHT} B`],
133
+ ["A <-- B", `A ${UNICODE_SYMBOLS.ARROW_LEFT} B`],
134
+ ["A <--> B", `A ${UNICODE_SYMBOLS.ARROW_LEFT_RIGHT} B`],
135
+ ["start -> middle -> end", `start ${UNICODE_SYMBOLS.ARROW_RIGHT} middle ${UNICODE_SYMBOLS.ARROW_RIGHT} end`],
136
+ // Pointer-style arrows preserved (no spaces)
137
+ ["function->call", "function->call"],
138
+ ["array[0]->value", "array[0]->value"],
139
+ ])('converts "%s" to "%s"', (input, expected) => {
140
+ expect(arrows(input)).toBe(expected)
141
+ })
142
+ })
143
+
144
+ describe("degrees", () => {
145
+ it.each([
146
+ ["20 C", `20 ${UNICODE_SYMBOLS.DEGREE}C`],
147
+ ["20C", `20 ${UNICODE_SYMBOLS.DEGREE}C`],
148
+ ["68 F", `68 ${UNICODE_SYMBOLS.DEGREE}F`],
149
+ ["68F", `68 ${UNICODE_SYMBOLS.DEGREE}F`],
150
+ ["Water boils at 100 C", `Water boils at 100 ${UNICODE_SYMBOLS.DEGREE}C`],
151
+ ["Room temperature: 72F", `Room temperature: 72 ${UNICODE_SYMBOLS.DEGREE}F`],
152
+ // Test degrees before punctuation (word boundary cases)
153
+ ["20C.", `20 ${UNICODE_SYMBOLS.DEGREE}C.`],
154
+ ["68F!", `68 ${UNICODE_SYMBOLS.DEGREE}F!`],
155
+ ["100C,", `100 ${UNICODE_SYMBOLS.DEGREE}C,`],
156
+ ["20 km", "20 km"],
157
+ ["Section C", "Section C"],
158
+ ["100 C", `100 ${UNICODE_SYMBOLS.DEGREE}C`],
159
+ ["212F", `212 ${UNICODE_SYMBOLS.DEGREE}F`],
160
+ ["-40 C", `-40 ${UNICODE_SYMBOLS.DEGREE}C`],
161
+ // Case-sensitive: lowercase c/f should NOT be converted
162
+ ["20 c", "20 c"],
163
+ ["68 f", "68 f"],
164
+ ["20c", "20c"],
165
+ ["68f", "68f"],
166
+ ])('converts "%s" to "%s"', (input, expected) => {
167
+ expect(degrees(input)).toBe(expected)
168
+ })
169
+
170
+ it("handles separator characters", () => {
171
+ const sep = "\uE000"
172
+ expect(degrees(`20${sep}C`, { separator: sep })).toBe(
173
+ `20${sep} ${UNICODE_SYMBOLS.DEGREE}C`
174
+ )
175
+ })
176
+
177
+ describe("marker robustness", () => {
178
+ // Tests that separators don't create false word boundaries
179
+ const sep = DEFAULT_SEPARATOR
180
+
181
+ it.each([
182
+ // [description, input, expected]
183
+ ["false boundary before word char", `20C${sep}elsius`, `20C${sep}elsius`], // "20Celsius" - should NOT convert
184
+ ["valid boundary before space", `20C${sep} today`, `20 ${UNICODE_SYMBOLS.DEGREE}C${sep} today`], // should convert
185
+ ["valid boundary before punctuation", `68F${sep}.`, `68 ${UNICODE_SYMBOLS.DEGREE}F${sep}.`], // should convert
186
+ ])("handles %s", (_desc, input, expected) => {
187
+ expect(degrees(input, { separator: sep })).toBe(expected)
188
+ })
189
+ })
190
+ })
191
+
192
+ describe("primeMarks", () => {
193
+ it.each([
194
+ ['5\'10"', `5${UNICODE_SYMBOLS.PRIME}10${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
195
+ ['He is 6\'2" tall', `He is 6${UNICODE_SYMBOLS.PRIME}2${UNICODE_SYMBOLS.DOUBLE_PRIME} tall`],
196
+ ["The board is 8' long", `The board is 8${UNICODE_SYMBOLS.PRIME} long`],
197
+ ['12"', `12${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
198
+ ["Location: 45° 30' 15\"", `Location: 45° 30${UNICODE_SYMBOLS.PRIME} 15${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
199
+ ["don't", "don't"],
200
+ ["it's", "it's"],
201
+ ["'Twas the night", "'Twas the night"],
202
+ ["'hello'", "'hello'"],
203
+ ['"test"', '"test"'],
204
+ ['The ceiling is 9\'6" high', `The ceiling is 9${UNICODE_SYMBOLS.PRIME}6${UNICODE_SYMBOLS.DOUBLE_PRIME} high`],
205
+ ["40° 44' 54\" N", `40° 44${UNICODE_SYMBOLS.PRIME} 54${UNICODE_SYMBOLS.DOUBLE_PRIME} N`],
206
+ ['The board is 12" wide', `The board is 12${UNICODE_SYMBOLS.DOUBLE_PRIME} wide`],
207
+ ['12" long', `12${UNICODE_SYMBOLS.DOUBLE_PRIME} long`],
208
+ ])('converts "%s" to "%s"', (input, expected) => {
209
+ expect(primeMarks(input)).toBe(expected)
210
+ })
211
+
212
+ // Quote balancing tests: simple (\d)['"]) patterns would incorrectly convert these
213
+ it.each([
214
+ // Double quote balancing
215
+ ['"Term 1".', '"Term 1".'],
216
+ ['"Number 5"', '"Number 5"'],
217
+ ['"Item 3", "Item 4"', '"Item 3", "Item 4"'],
218
+ ['She said "Chapter 5" was good', 'She said "Chapter 5" was good'],
219
+ ['The file "test_v2" exists', 'The file "test_v2" exists'],
220
+ ['"Room 101" is famous', '"Room 101" is famous'],
221
+ // Single quote balancing
222
+ ["'Term 1'", "'Term 1'"],
223
+ ["'Item 3'", "'Item 3'"],
224
+ ["She said 'Chapter 5' was good", "She said 'Chapter 5' was good"],
225
+ ])('preserves closing quotes via quote balancing: "%s"', (input, expected) => {
226
+ expect(primeMarks(input)).toBe(expected)
227
+ })
228
+
229
+ it("handles separator characters", () => {
230
+ const sep = "\uE000"
231
+ expect(primeMarks(`5${sep}'${sep}10${sep}"`, { separator: sep })).toBe(
232
+ `5${sep}${UNICODE_SYMBOLS.PRIME}${sep}10${sep}${UNICODE_SYMBOLS.DOUBLE_PRIME}`
233
+ )
234
+ })
235
+ })
236
+
237
+ describe("fractions", () => {
238
+ it.each([
239
+ ["1/2", UNICODE_SYMBOLS.FRACTION_1_2],
240
+ ["1/4", UNICODE_SYMBOLS.FRACTION_1_4],
241
+ ["3/4", UNICODE_SYMBOLS.FRACTION_3_4],
242
+ ["1/3", UNICODE_SYMBOLS.FRACTION_1_3],
243
+ ["2/3", UNICODE_SYMBOLS.FRACTION_2_3],
244
+ ["Add 1/2 cup", `Add ${UNICODE_SYMBOLS.FRACTION_1_2} cup`],
245
+ ["About 3/4 done", `About ${UNICODE_SYMBOLS.FRACTION_3_4} done`],
246
+ ["1/8 teaspoon", `${UNICODE_SYMBOLS.FRACTION_1_8} teaspoon`],
247
+ // Test fractions before punctuation
248
+ ["1/2.", `${UNICODE_SYMBOLS.FRACTION_1_2}.`],
249
+ ["3/4!", `${UNICODE_SYMBOLS.FRACTION_3_4}!`],
250
+ ["1/4,", `${UNICODE_SYMBOLS.FRACTION_1_4},`],
251
+ ["21/4", "21/4"],
252
+ ["page 1/25", "page 1/25"],
253
+ ["1/7", "1/7"],
254
+ ["5/9", "5/9"],
255
+ // Edge cases: fractions in paths/URLs should not match
256
+ ["a/1/4", "a/1/4"],
257
+ ["1/4/b", "1/4/b"],
258
+ ["x/1/2", "x/1/2"],
259
+ // Edge cases: fractions adjacent to decimals
260
+ ["3.1/4", "3.1/4"],
261
+ ["1/4.5", "1/4.5"],
262
+ ])('converts "%s" to "%s"', (input, expected) => {
263
+ expect(fractions(input)).toBe(expected)
264
+ })
265
+
266
+ it("handles separator characters", () => {
267
+ const sep = "\uE000"
268
+ expect(fractions(`1${sep}/${sep}2`, { separator: sep })).toBe(
269
+ `${sep}${UNICODE_SYMBOLS.FRACTION_1_2}${sep}`
270
+ )
271
+ })
272
+ })
273
+
274
+ describe("collapseSpaces", () => {
275
+ const { NBSP } = UNICODE_SYMBOLS
276
+
277
+ it.each([
278
+ // Multiple regular spaces → single space
279
+ ["hello world", "hello world"],
280
+ ["a b", "a b"],
281
+ ["x y", "x y"],
282
+ // Multiple nbsp → single nbsp
283
+ [`foo${NBSP}${NBSP}bar`, `foo${NBSP}bar`],
284
+ [`a${NBSP}${NBSP}${NBSP}b`, `a${NBSP}b`],
285
+ // Mixed: any nbsp present → prefer nbsp (more likely intentional)
286
+ [`a ${NBSP}b`, `a${NBSP}b`],
287
+ [`x ${NBSP}y`, `x${NBSP}y`],
288
+ [`a${NBSP} b`, `a${NBSP}b`],
289
+ [`x${NBSP} y`, `x${NBSP}y`],
290
+ [`a ${NBSP} b`, `a${NBSP}b`],
291
+ [`x${NBSP} ${NBSP}y`, `x${NBSP}y`],
292
+ // Single spaces unchanged
293
+ ["hello world", "hello world"],
294
+ [`foo${NBSP}bar`, `foo${NBSP}bar`],
295
+ // Multiple groups of spaces
296
+ ["a b c", "a b c"],
297
+ [`x${NBSP}${NBSP}y z`, `x${NBSP}y z`],
298
+ // Edge cases
299
+ ["", ""],
300
+ [" ", " "],
301
+ [`${NBSP}${NBSP}`, NBSP],
302
+ ["no spaces", "no spaces"],
303
+ ])('converts "%s" to "%s"', (input, expected) => {
304
+ expect(collapseSpaces(input)).toBe(expected)
305
+ })
306
+ })
307
+
308
+ describe("superscriptOrdinal", () => {
309
+ it.each([
310
+ // Basic ordinals
311
+ ["1st", `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
312
+ ["2nd", `2${UNICODE_SYMBOLS.SUPERSCRIPT_ND}`],
313
+ ["3rd", `3${UNICODE_SYMBOLS.SUPERSCRIPT_RD}`],
314
+ ["4th", `4${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`],
315
+ // Larger numbers
316
+ ["21st", `21${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
317
+ ["22nd", `22${UNICODE_SYMBOLS.SUPERSCRIPT_ND}`],
318
+ ["23rd", `23${UNICODE_SYMBOLS.SUPERSCRIPT_RD}`],
319
+ ["30th", `30${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`],
320
+ ["100th", `100${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`],
321
+ // In sentences
322
+ ["The 1st place winner", `The 1${UNICODE_SYMBOLS.SUPERSCRIPT_ST} place winner`],
323
+ ["Born on the 30th of June", `Born on the 30${UNICODE_SYMBOLS.SUPERSCRIPT_TH} of June`],
324
+ ["The 21st century", `The 21${UNICODE_SYMBOLS.SUPERSCRIPT_ST} century`],
325
+ // Before punctuation
326
+ ["1st.", `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}.`],
327
+ ["2nd,", `2${UNICODE_SYMBOLS.SUPERSCRIPT_ND},`],
328
+ ["3rd!", `3${UNICODE_SYMBOLS.SUPERSCRIPT_RD}!`],
329
+ // Case insensitive
330
+ ["1ST", `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
331
+ ["2ND", `2${UNICODE_SYMBOLS.SUPERSCRIPT_ND}`],
332
+ ["3RD", `3${UNICODE_SYMBOLS.SUPERSCRIPT_RD}`],
333
+ ["4TH", `4${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`],
334
+ // Should not match non-ordinal text
335
+ ["first", "first"],
336
+ ["stand", "stand"],
337
+ ["thunder", "thunder"],
338
+ ["rand", "rand"],
339
+ ])('converts "%s" to "%s"', (input, expected) => {
340
+ expect(superscriptOrdinal(input)).toBe(expected)
341
+ })
342
+
343
+ it("handles separator characters", () => {
344
+ const sep = "\uE000"
345
+ expect(superscriptOrdinal(`30${sep}th`, { separator: sep })).toBe(
346
+ `30${sep}${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`
347
+ )
348
+ })
349
+
350
+ describe("marker robustness", () => {
351
+ // Tests that separators don't create false word boundaries
352
+ const sep = DEFAULT_SEPARATOR
353
+
354
+ it.each([
355
+ // [description, input, expected]
356
+ ["false boundary (1stly)", `1st${sep}ly`, `1st${sep}ly`], // "1stly" - should NOT convert
357
+ ["false boundary (2ndary)", `2nd${sep}ary`, `2nd${sep}ary`], // "2ndary" - should NOT convert
358
+ ["false boundary (3rdly)", `3rd${sep}ly`, `3rd${sep}ly`], // "3rdly" - should NOT convert
359
+ ["valid boundary before space", `1st${sep} place`, `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}${sep} place`], // should convert
360
+ ["valid boundary before punctuation", `2nd${sep}.`, `2${UNICODE_SYMBOLS.SUPERSCRIPT_ND}${sep}.`], // should convert
361
+ ])("handles %s", (_desc, input, expected) => {
362
+ expect(superscriptOrdinal(input, { separator: sep })).toBe(expected)
363
+ })
364
+ })
365
+ })
366
+
367
+ describe("punctuationLigatures", () => {
368
+ it.each([
369
+ // Double/multiple question marks squashed to ligature
370
+ ["What??", `What${UNICODE_SYMBOLS.DOUBLE_QUESTION}`],
371
+ ["Really???", `Really${UNICODE_SYMBOLS.DOUBLE_QUESTION}`],
372
+ ["Huh????", `Huh${UNICODE_SYMBOLS.DOUBLE_QUESTION}`],
373
+ ["??", UNICODE_SYMBOLS.DOUBLE_QUESTION],
374
+ // Question + exclamation(s) → ⁈
375
+ ["Really?!", `Really${UNICODE_SYMBOLS.QUESTION_EXCLAMATION}`],
376
+ ["What?!!", `What${UNICODE_SYMBOLS.QUESTION_EXCLAMATION}`],
377
+ ["?!", UNICODE_SYMBOLS.QUESTION_EXCLAMATION],
378
+ // Exclamation + question(s) → ⁉
379
+ ["No way!?", `No way${UNICODE_SYMBOLS.EXCLAMATION_QUESTION}`],
380
+ ["What!??", `What${UNICODE_SYMBOLS.EXCLAMATION_QUESTION}`],
381
+ ["!?", UNICODE_SYMBOLS.EXCLAMATION_QUESTION],
382
+ // Double/multiple exclamation marks squashed to single
383
+ ["Wow!!", "Wow!"],
384
+ ["Amazing!!!", "Amazing!"],
385
+ ["Yes!!!!", "Yes!"],
386
+ ["!!", "!"],
387
+ // Single punctuation unchanged
388
+ ["What?", "What?"],
389
+ ["Wow!", "Wow!"],
390
+ // Text without punctuation unchanged
391
+ ["Hello world", "Hello world"],
392
+ ])('converts "%s" to "%s"', (input, expected) => {
393
+ expect(punctuationLigatures(input)).toBe(expected)
394
+ })
395
+
396
+ it.each([
397
+ ["??", `${UNICODE_SYMBOLS.DOUBLE_QUESTION}${DEFAULT_SEPARATOR}`],
398
+ ["?!", `${UNICODE_SYMBOLS.QUESTION_EXCLAMATION}${DEFAULT_SEPARATOR}`],
399
+ ["!?", `${UNICODE_SYMBOLS.EXCLAMATION_QUESTION}${DEFAULT_SEPARATOR}`],
400
+ ["!!", `!${DEFAULT_SEPARATOR}`],
401
+ ])("preserves separator in %s", (marks, expected) => {
402
+ const input = marks[0] + DEFAULT_SEPARATOR + marks[1]
403
+ expect(punctuationLigatures(input, { separator: DEFAULT_SEPARATOR })).toBe(expected)
404
+ })
405
+
406
+ it("handles multiple ligatures in same text", () => {
407
+ const input = "What?? Really?! No way!? Wow!!"
408
+ const expected = `What${UNICODE_SYMBOLS.DOUBLE_QUESTION} Really${UNICODE_SYMBOLS.QUESTION_EXCLAMATION} No way${UNICODE_SYMBOLS.EXCLAMATION_QUESTION} Wow!`
409
+ expect(punctuationLigatures(input)).toBe(expected)
410
+ })
411
+ })
412
+
413
+ describe("hexadecimal preservation", () => {
414
+ it.each([
415
+ "0x5F3759DF",
416
+ "0xff",
417
+ "0X1A2B",
418
+ "The magic number is 0x5F3759DF",
419
+ "test 0x",
420
+ "value: 0X",
421
+ ])('preserves "%s"', (input) => {
422
+ expect(multiplication(input)).toBe(input)
423
+ })
424
+ })
425
+
426
+ // Tests derived from competitor libraries and typography guidelines
427
+ describe("competitor-derived edge cases", () => {
428
+ // From retext-smartypants: ellipsis variations
429
+ describe("ellipsis patterns", () => {
430
+ it.each([
431
+ // Ellipsis at boundaries
432
+ ["...start", `${UNICODE_SYMBOLS.ELLIPSIS} start`],
433
+ ["end...", `end${UNICODE_SYMBOLS.ELLIPSIS}`],
434
+ // Four dots = ellipsis + period
435
+ ["text....", `text${UNICODE_SYMBOLS.ELLIPSIS}.`],
436
+ // Six dots = two ellipses
437
+ ["text......", `text${UNICODE_SYMBOLS.ELLIPSIS}${UNICODE_SYMBOLS.ELLIPSIS}`],
438
+ ])('handles ellipsis pattern: "%s"', (input, expected) => {
439
+ expect(ellipsis(input)).toBe(expected)
440
+ })
441
+
442
+ it("converts spaced periods to ellipsis", () => {
443
+ expect(ellipsis("text. . . more")).toBe(`text${UNICODE_SYMBOLS.ELLIPSIS} more`)
444
+ expect(ellipsis(". . .")).toBe(UNICODE_SYMBOLS.ELLIPSIS)
445
+ // Also works with non-breaking spaces
446
+ expect(ellipsis(`.${UNICODE_SYMBOLS.NBSP}.${UNICODE_SYMBOLS.NBSP}.`)).toBe(UNICODE_SYMBOLS.ELLIPSIS)
447
+ })
448
+
449
+ it("preserves non-ellipsis spaced periods", () => {
450
+ // Only exactly three spaced dots are converted
451
+ expect(ellipsis("a . b . c")).toBe("a . b . c")
452
+ })
453
+ })
454
+
455
+ // From smartquotes.js: prime marks for height
456
+ describe("height measurements", () => {
457
+ it.each([
458
+ ['6\'2"', `6${UNICODE_SYMBOLS.PRIME}2${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
459
+ ['5\'10"', `5${UNICODE_SYMBOLS.PRIME}10${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
460
+ ["8' boards", `8${UNICODE_SYMBOLS.PRIME} boards`],
461
+ ['cut 12" wide', `cut 12${UNICODE_SYMBOLS.DOUBLE_PRIME} wide`],
462
+ ])('converts measurement: "%s"', (input, expected) => {
463
+ expect(primeMarks(input)).toBe(expected)
464
+ })
465
+ })
466
+
467
+ // From Standard Ebooks: coordinates
468
+ describe("coordinate notation", () => {
469
+ it.each([
470
+ ["40° 44' 54\" N", `40° 44${UNICODE_SYMBOLS.PRIME} 54${UNICODE_SYMBOLS.DOUBLE_PRIME} N`],
471
+ ["74° 0' 21\" W", `74° 0${UNICODE_SYMBOLS.PRIME} 21${UNICODE_SYMBOLS.DOUBLE_PRIME} W`],
472
+ ["51° 30' N", `51° 30${UNICODE_SYMBOLS.PRIME} N`],
473
+ ])('converts coordinates: "%s"', (input, expected) => {
474
+ expect(primeMarks(input)).toBe(expected)
475
+ })
476
+ })
477
+
478
+ // From typograf: math with spaces
479
+ describe("math symbol spacing", () => {
480
+ it.each([
481
+ ["a != b", `a ${UNICODE_SYMBOLS.NOT_EQUAL} b`],
482
+ ["x!=y", `x${UNICODE_SYMBOLS.NOT_EQUAL}y`],
483
+ ["5 <= 10", `5 ${UNICODE_SYMBOLS.LESS_EQUAL} 10`],
484
+ ["10 >= 5", `10 ${UNICODE_SYMBOLS.GREATER_EQUAL} 5`],
485
+ ])('handles math symbols: "%s"', (input, expected) => {
486
+ expect(mathSymbols(input)).toBe(expected)
487
+ })
488
+ })
489
+
490
+ // From tipograph: arrow preservation in code
491
+ describe("arrow context sensitivity", () => {
492
+ it.each([
493
+ // Should convert (spaced)
494
+ ["input -> output", `input ${UNICODE_SYMBOLS.ARROW_RIGHT} output`],
495
+ ["a <-> b", `a ${UNICODE_SYMBOLS.ARROW_LEFT_RIGHT} b`],
496
+ // Should NOT convert (no spaces - code-like)
497
+ ["obj->method", "obj->method"],
498
+ ["ptr->value", "ptr->value"],
499
+ ["this->that", "this->that"],
500
+ ])('handles arrows contextually: "%s"', (input, expected) => {
501
+ expect(arrows(input)).toBe(expected)
502
+ })
503
+ })
504
+ })
505
+
506
+ describe("complex real-world patterns", () => {
507
+ describe("mixed measurements", () => {
508
+ it.each([
509
+ // Simple foot measurements
510
+ ["The board is 8' long", `The board is 8${UNICODE_SYMBOLS.PRIME} long`],
511
+ ['12" wide', `12${UNICODE_SYMBOLS.DOUBLE_PRIME} wide`],
512
+ // Height notation
513
+ ['6\'2"', `6${UNICODE_SYMBOLS.PRIME}2${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
514
+ ])('handles measurement: "%s"', (input, expected) => {
515
+ expect(primeMarks(input)).toBe(expected)
516
+ })
517
+
518
+ })
519
+
520
+ describe("temperatures in context", () => {
521
+ it.each([
522
+ ["Set oven to 350 F", `Set oven to 350 ${UNICODE_SYMBOLS.DEGREE}F`],
523
+ ["Water freezes at 0 C", `Water freezes at 0 ${UNICODE_SYMBOLS.DEGREE}C`],
524
+ ["Room temperature: 72 F", `Room temperature: 72 ${UNICODE_SYMBOLS.DEGREE}F`],
525
+ ])('handles temperature: "%s"', (input, expected) => {
526
+ expect(degrees(input)).toBe(expected)
527
+ })
528
+ })
529
+
530
+ describe("legal text", () => {
531
+ it.each([
532
+ ["(c) 2024 Company", `${UNICODE_SYMBOLS.COPYRIGHT} 2024 Company`],
533
+ ["Brand(r) is a registered trademark", `Brand${UNICODE_SYMBOLS.REGISTERED} is a registered trademark`],
534
+ ["Product(tm) - buy now!", `Product${UNICODE_SYMBOLS.TRADEMARK} - buy now!`],
535
+ ])('handles legal symbol: "%s"', (input, expected) => {
536
+ expect(legalSymbols(input)).toBe(expected)
537
+ })
538
+ })
539
+
540
+ describe("fractions in recipes", () => {
541
+ it.each([
542
+ ["Add 1/2 cup flour", `Add ${UNICODE_SYMBOLS.FRACTION_1_2} cup flour`],
543
+ ["1/4 teaspoon salt", `${UNICODE_SYMBOLS.FRACTION_1_4} teaspoon salt`],
544
+ ["about 3/4 done", `about ${UNICODE_SYMBOLS.FRACTION_3_4} done`],
545
+ ["1/3 of the mixture", `${UNICODE_SYMBOLS.FRACTION_1_3} of the mixture`],
546
+ ])('handles fraction: "%s"', (input, expected) => {
547
+ expect(fractions(input)).toBe(expected)
548
+ })
549
+ })
550
+ })
551
+
552
+ describe("idempotency", () => {
553
+ it.each([
554
+ UNICODE_SYMBOLS.ELLIPSIS,
555
+ `5${UNICODE_SYMBOLS.MULTIPLICATION}5`,
556
+ `a ${UNICODE_SYMBOLS.NOT_EQUAL} b`,
557
+ UNICODE_SYMBOLS.COPYRIGHT,
558
+ `5${UNICODE_SYMBOLS.PRIME}10${UNICODE_SYMBOLS.DOUBLE_PRIME}`,
559
+ `20 ${UNICODE_SYMBOLS.DEGREE}C`,
560
+ UNICODE_SYMBOLS.FRACTION_1_2,
561
+ `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`,
562
+ ])('is idempotent for: "%s"', (input) => {
563
+ expect(ellipsis(input)).toBe(input)
564
+ expect(multiplication(input)).toBe(input)
565
+ })
566
+ })
567
+
568
+ describe("symbolTransform", () => {
569
+ it.each([
570
+ ["Wait... 5x5 != 20 (c) 2024", `Wait${UNICODE_SYMBOLS.ELLIPSIS} 5${UNICODE_SYMBOLS.MULTIPLICATION}5 ${UNICODE_SYMBOLS.NOT_EQUAL} 20 ${UNICODE_SYMBOLS.COPYRIGHT} 2024`],
571
+ ["-2 x 3", `-2 ${UNICODE_SYMBOLS.MULTIPLICATION} 3`],
572
+ ["5 x 5", `5 ${UNICODE_SYMBOLS.MULTIPLICATION} 5`],
573
+ ])('converts "%s" to "%s"', (input, expected) => {
574
+ expect(symbolTransform(input)).toBe(expected)
575
+ })
576
+
577
+ it("handles complex text with multiple symbols", () => {
578
+ const input = "Product(tm) v2.0 - Size: 10x20 cm, tolerance +- 5%"
579
+ const expected = `Product${UNICODE_SYMBOLS.TRADEMARK} v2.0 - Size: 10${UNICODE_SYMBOLS.MULTIPLICATION}20 cm, tolerance ${UNICODE_SYMBOLS.PLUS_MINUS} 5%`
580
+ expect(symbolTransform(input)).toEqual(expected)
581
+ })
582
+
583
+ it("respects separator option", () => {
584
+ const sep = "\uE000"
585
+ const input = `5${sep}x${sep}5`
586
+ expect(symbolTransform(input, { separator: sep })).toEqual(`5${sep}${UNICODE_SYMBOLS.MULTIPLICATION}${sep}5`)
587
+ })
588
+
589
+ it("includes arrows by default", () => {
590
+ expect(symbolTransform("A -> B")).toBe(`A ${UNICODE_SYMBOLS.ARROW_RIGHT} B`)
591
+ })
592
+
593
+ it("can disable arrows with includeArrows: false", () => {
594
+ expect(symbolTransform("A -> B", { includeArrows: false })).toBe("A -> B")
595
+ })
596
+ })
597
+
598
+ describe("multiplication edge cases", () => {
599
+ it.each([
600
+ ["1000000x2000000", `1000000${UNICODE_SYMBOLS.MULTIPLICATION}2000000`],
601
+ ["01x02", `01${UNICODE_SYMBOLS.MULTIPLICATION}02`],
602
+ ["2x", `2${UNICODE_SYMBOLS.MULTIPLICATION}`],
603
+ ])('handles multiplication edge: "%s"', (input, expected) => {
604
+ expect(multiplication(input)).toBe(expected)
605
+ })
606
+ })
607
+
608
+ describe("multiplication - preserve words containing x", () => {
609
+ it.each([
610
+ ["extra", "extra"],
611
+ ["index", "index"],
612
+ ["2xtra", "2xtra"],
613
+ ["hex", "hex"],
614
+ ["next", "next"],
615
+ ["text", "text"],
616
+ ["approximately", "approximately"],
617
+ ])('does NOT transform word: "%s"', (input, expected) => {
618
+ expect(multiplication(input)).toBe(expected)
619
+ })
620
+ })
621
+
622
+ describe("ellipsis edge cases", () => {
623
+ it.each([
624
+ ["One... Two... Three...", `One${UNICODE_SYMBOLS.ELLIPSIS} Two${UNICODE_SYMBOLS.ELLIPSIS} Three${UNICODE_SYMBOLS.ELLIPSIS}`],
625
+ ["...5 items", `${UNICODE_SYMBOLS.ELLIPSIS} 5 items`],
626
+ ["End of sentence....", `End of sentence${UNICODE_SYMBOLS.ELLIPSIS}.`],
627
+ ['"Wait..."', `"Wait${UNICODE_SYMBOLS.ELLIPSIS}"`],
628
+ ])('handles ellipsis edge: "%s"', (input, expected) => {
629
+ expect(ellipsis(input)).toBe(expected)
630
+ })
631
+ })
632
+
633
+ describe("ellipsis - preserve abbreviations", () => {
634
+ it.each([
635
+ ["e.g.", "e.g."],
636
+ ["i.e.", "i.e."],
637
+ ["a.m.", "a.m."],
638
+ ["p.m.", "p.m."],
639
+ ["Dr.", "Dr."],
640
+ ["Mr.", "Mr."],
641
+ ["etc.", "etc."],
642
+ ])('preserves abbreviation: "%s"', (input, expected) => {
643
+ expect(ellipsis(input)).toBe(expected)
644
+ })
645
+ })
646
+
647
+ describe("prime marks edge cases", () => {
648
+ it.each([
649
+ ['100\'50"', `100${UNICODE_SYMBOLS.PRIME}50${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
650
+ ["5' boards", `5${UNICODE_SYMBOLS.PRIME} boards`],
651
+ ['12" pipe', `12${UNICODE_SYMBOLS.DOUBLE_PRIME} pipe`],
652
+ ["5', 10'", `5${UNICODE_SYMBOLS.PRIME}, 10'`],
653
+ ])('handles prime mark edge: "%s"', (input, expected) => {
654
+ expect(primeMarks(input)).toBe(expected)
655
+ })
656
+ })
657
+
658
+ describe("degrees edge temperatures", () => {
659
+ it.each([
660
+ ["-40 C", `-40 ${UNICODE_SYMBOLS.DEGREE}C`],
661
+ ["-40 F", `-40 ${UNICODE_SYMBOLS.DEGREE}F`],
662
+ ["0 C", `0 ${UNICODE_SYMBOLS.DEGREE}C`],
663
+ ["0 F", `0 ${UNICODE_SYMBOLS.DEGREE}F`],
664
+ ["1000 C", `1000 ${UNICODE_SYMBOLS.DEGREE}C`],
665
+ ["1000 F", `1000 ${UNICODE_SYMBOLS.DEGREE}F`],
666
+ ])('handles temperature edge: "%s"', (input, expected) => {
667
+ expect(degrees(input)).toBe(expected)
668
+ })
669
+ })
670
+
671
+ describe("arrow edge cases", () => {
672
+ it.each([
673
+ ["A -> B -> C", `A ${UNICODE_SYMBOLS.ARROW_RIGHT} B ${UNICODE_SYMBOLS.ARROW_RIGHT} C`],
674
+ ["A <- B <- C", `A ${UNICODE_SYMBOLS.ARROW_LEFT} B ${UNICODE_SYMBOLS.ARROW_LEFT} C`],
675
+ ["A ---> B", `A ${UNICODE_SYMBOLS.ARROW_RIGHT} B`],
676
+ ["A <--- B", `A ${UNICODE_SYMBOLS.ARROW_LEFT} B`],
677
+ ["-> output", `${UNICODE_SYMBOLS.ARROW_RIGHT} output`],
678
+ ["input ->", `input ${UNICODE_SYMBOLS.ARROW_RIGHT}`],
679
+ ])('handles arrow edge: "%s"', (input, expected) => {
680
+ expect(arrows(input)).toBe(expected)
681
+ })
682
+ })
683
+
684
+ describe("fractions edge cases", () => {
685
+ it.each([
686
+ ["1/2 and 1/4", `${UNICODE_SYMBOLS.FRACTION_1_2} and ${UNICODE_SYMBOLS.FRACTION_1_4}`],
687
+ ["Add 1/2.", `Add ${UNICODE_SYMBOLS.FRACTION_1_2}.`],
688
+ ["(1/2)", `(${UNICODE_SYMBOLS.FRACTION_1_2})`],
689
+ ["5/7", "5/7"],
690
+ ["11/12", "11/12"],
691
+ ])('handles fraction edge: "%s"', (input, expected) => {
692
+ expect(fractions(input)).toBe(expected)
693
+ })
694
+ })
695
+
696
+ describe("chained multiplications", () => {
697
+ const M = UNICODE_SYMBOLS.MULTIPLICATION
698
+ it.each([
699
+ ["5x5x5", `5${M}5${M}5`, "tight x"],
700
+ ["5 x 5 x 5", `5 ${M} 5 ${M} 5`, "spaced x"],
701
+ ["5*5*5", `5${M}5${M}5`, "tight *"],
702
+ ["5 * 5 * 5", `5 ${M} 5 ${M} 5`, "spaced *"],
703
+ ["10x10x10", `10${M}10${M}10`, "multi-digit"],
704
+ ])("converts %s → %s (%s)", (input, expected) => {
705
+ expect(multiplication(input)).toBe(expected)
706
+ })
707
+ })
708
+