punctilio 3.7.2 → 3.7.4

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.
@@ -1,920 +0,0 @@
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
- import { assertLinearScaling } from "./test-helpers.js"
17
-
18
- describe("ellipsis", () => {
19
- it.each([
20
- ["Wait for it...", `Wait for it${UNICODE_SYMBOLS.ELLIPSIS}`],
21
- ["Hmm... let me think", `Hmm${UNICODE_SYMBOLS.ELLIPSIS} let me think`],
22
- ["...", UNICODE_SYMBOLS.ELLIPSIS],
23
- ["Hello...world", `Hello${UNICODE_SYMBOLS.ELLIPSIS} world`],
24
- ["End of sentence...", `End of sentence${UNICODE_SYMBOLS.ELLIPSIS}`],
25
- ['..."', `${UNICODE_SYMBOLS.ELLIPSIS}"`],
26
- ["...!", `${UNICODE_SYMBOLS.ELLIPSIS}!`],
27
- ["...?", `${UNICODE_SYMBOLS.ELLIPSIS}?`],
28
- ["e.g.", "e.g."],
29
- ["U.S.A.", "U.S.A."],
30
- ["a.b", "a.b"],
31
- ["End....", `End${UNICODE_SYMBOLS.ELLIPSIS}.`], // 4 dots
32
- ["......", `${UNICODE_SYMBOLS.ELLIPSIS}${UNICODE_SYMBOLS.ELLIPSIS}`], // 6 dots
33
- ])('converts "%s" to "%s"', (input, expected) => {
34
- expect(ellipsis(input)).toBe(expected)
35
- })
36
-
37
- it.each([
38
- ["between all dots", `.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}.`, `${UNICODE_SYMBOLS.ELLIPSIS}${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}`],
39
- ["after ellipsis", `...${DEFAULT_SEPARATOR}`, `${UNICODE_SYMBOLS.ELLIPSIS}${DEFAULT_SEPARATOR}`],
40
- ["after first dot", `.${DEFAULT_SEPARATOR}..`, `${UNICODE_SYMBOLS.ELLIPSIS}${DEFAULT_SEPARATOR}`],
41
- ["after second dot", `..${DEFAULT_SEPARATOR}.`, `${UNICODE_SYMBOLS.ELLIPSIS}${DEFAULT_SEPARATOR}`],
42
- ])("preserves separators: %s", (_desc, input, expected) => {
43
- const result = ellipsis(input, { separator: DEFAULT_SEPARATOR })
44
- expect(result).toBe(expected)
45
- })
46
- })
47
-
48
- describe("multiplication", () => {
49
- it.each([
50
- ["5x5", `5${UNICODE_SYMBOLS.MULTIPLICATION}5`],
51
- ["10 x 20", `10 ${UNICODE_SYMBOLS.MULTIPLICATION} 20`],
52
- ["10x20", `10${UNICODE_SYMBOLS.MULTIPLICATION}20`],
53
- ["The room is 10x12 feet", `The room is 10${UNICODE_SYMBOLS.MULTIPLICATION}12 feet`],
54
- ["2x speed", `2${UNICODE_SYMBOLS.MULTIPLICATION} speed`],
55
- ["3x", `3${UNICODE_SYMBOLS.MULTIPLICATION}`],
56
- ["5*3", `5${UNICODE_SYMBOLS.MULTIPLICATION}3`],
57
- ["Resolution: 1920x1080", `Resolution: 1920${UNICODE_SYMBOLS.MULTIPLICATION}1080`],
58
- // Test trailing multiplier before punctuation (word boundary cases)
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
- ["2x?", `2${UNICODE_SYMBOLS.MULTIPLICATION}?`],
65
- ["extra", "extra"],
66
- ["complex", "complex"],
67
- ["x-axis", "x-axis"],
68
- ["5xword", "5xword"],
69
- ["10xbox", "10xbox"],
70
- ["2xLarge", "2xLarge"],
71
- // Dimensions with prime marks already attached (post primeMarks pass)
72
- [`10′ x 12′`, `10′ ${UNICODE_SYMBOLS.MULTIPLICATION} 12′`],
73
- [`Room is 10′ x 12′`, `Room is 10′ ${UNICODE_SYMBOLS.MULTIPLICATION} 12′`],
74
- [`5′ x 6′`, `5′ ${UNICODE_SYMBOLS.MULTIPLICATION} 6′`],
75
- // Mixed feet/inches and feet
76
- [`5′10″ x 6′`, `5′10″ ${UNICODE_SYMBOLS.MULTIPLICATION} 6′`],
77
- // Dimensions with length units attached to both sides, unspaced
78
- [`5m x 5m`, `5m ${UNICODE_SYMBOLS.MULTIPLICATION} 5m`],
79
- [`10cm x 20cm`, `10cm ${UNICODE_SYMBOLS.MULTIPLICATION} 20cm`],
80
- [`210mm x 297mm`, `210mm ${UNICODE_SYMBOLS.MULTIPLICATION} 297mm`],
81
- [`5km x 10km`, `5km ${UNICODE_SYMBOLS.MULTIPLICATION} 10km`],
82
- [`1920px x 1080px`, `1920px ${UNICODE_SYMBOLS.MULTIPLICATION} 1080px`],
83
- [`5ft x 10ft`, `5ft ${UNICODE_SYMBOLS.MULTIPLICATION} 10ft`],
84
- [`5in x 7in`, `5in ${UNICODE_SYMBOLS.MULTIPLICATION} 7in`],
85
- // Dimensions with length units and spaces between number and unit
86
- [`5 m x 5 m`, `5 m ${UNICODE_SYMBOLS.MULTIPLICATION} 5 m`],
87
- [`10 cm x 20 cm`, `10 cm ${UNICODE_SYMBOLS.MULTIPLICATION} 20 cm`],
88
- [`210 mm x 297 mm`, `210 mm ${UNICODE_SYMBOLS.MULTIPLICATION} 297 mm`],
89
- // Mixed units across sides (rare but valid)
90
- [`5m x 10cm`, `5m ${UNICODE_SYMBOLS.MULTIPLICATION} 10cm`],
91
- // Three-dimensional chains
92
- [`desk 120 cm x 60 cm x 75 cm`, `desk 120 cm ${UNICODE_SYMBOLS.MULTIPLICATION} 60 cm ${UNICODE_SYMBOLS.MULTIPLICATION} 75 cm`],
93
- // Unit at end followed by noun (don't over-consume past the unit)
94
- [`210mm x 297mm paper`, `210mm ${UNICODE_SYMBOLS.MULTIPLICATION} 297mm paper`],
95
- // Unit-like prefix of a larger word must not match (mold ≠ m)
96
- [`5mold x 10 mold`, `5mold x 10 mold`],
97
- ])('converts "%s" to "%s"', (input, expected) => {
98
- expect(multiplication(input)).toBe(expected)
99
- })
100
-
101
- it("handles separator characters", () => {
102
- const sep = "\uE000"
103
- expect(multiplication(`5${sep}x${sep}5`, { separator: sep })).toBe(`5${sep}${UNICODE_SYMBOLS.MULTIPLICATION}${sep}5`)
104
- })
105
-
106
- describe("marker robustness", () => {
107
- // Tests that separators don't create false word boundaries
108
- // e.g., "5x\uE000tra" should NOT match because the actual text is "5xtra"
109
- const sep = DEFAULT_SEPARATOR
110
-
111
- it.each([
112
- // [description, input, expected]
113
- ["false boundary before word char", `5x${sep}tra`, `5x${sep}tra`], // "5xtra" - should NOT convert
114
- ["valid boundary before space", `5x${sep} done`, `5${UNICODE_SYMBOLS.MULTIPLICATION}${sep} done`], // should convert
115
- ["valid boundary before punctuation", `5x${sep}.`, `5${UNICODE_SYMBOLS.MULTIPLICATION}${sep}.`], // should convert
116
- ])("handles %s", (_desc, input, expected) => {
117
- expect(multiplication(input, { separator: sep })).toBe(expected)
118
- })
119
- })
120
- })
121
-
122
- describe("mathSymbols", () => {
123
- it.each([
124
- ["x != y", `x ${UNICODE_SYMBOLS.NOT_EQUAL} y`],
125
- ["a!=b", `a${UNICODE_SYMBOLS.NOT_EQUAL}b`],
126
- ["1 != 2", `1 ${UNICODE_SYMBOLS.NOT_EQUAL} 2`],
127
- ["+-5", `${UNICODE_SYMBOLS.PLUS_MINUS}5`],
128
- ["+/-5", `${UNICODE_SYMBOLS.PLUS_MINUS}5`],
129
- ["The answer is +- 5%", `The answer is ${UNICODE_SYMBOLS.PLUS_MINUS} 5%`],
130
- ["tolerance: +/-0.5", `tolerance: ${UNICODE_SYMBOLS.PLUS_MINUS}0.5`],
131
- ["a <= b", `a ${UNICODE_SYMBOLS.LESS_EQUAL} b`],
132
- ["x >= y", `x ${UNICODE_SYMBOLS.GREATER_EQUAL} y`],
133
- ["~= 5", `${UNICODE_SYMBOLS.APPROXIMATE} 5`],
134
- ["=~ 5", `${UNICODE_SYMBOLS.APPROXIMATE} 5`],
135
- ])('converts "%s" to "%s"', (input, expected) => {
136
- expect(mathSymbols(input)).toBe(expected)
137
- })
138
-
139
- it.each([
140
- ["!==", "!=="],
141
- ["x !== y", "x !== y"],
142
- ["a !== b !== c", "a !== b !== c"],
143
- ["<==", "<=="],
144
- [">==", ">=="],
145
- ])('preserves multi-char operator "%s"', (input, expected) => {
146
- expect(mathSymbols(input)).toBe(expected)
147
- })
148
-
149
- it.each([
150
- [`!${DEFAULT_SEPARATOR}=`, UNICODE_SYMBOLS.NOT_EQUAL],
151
- [`<${DEFAULT_SEPARATOR}=`, UNICODE_SYMBOLS.LESS_EQUAL],
152
- [`>${DEFAULT_SEPARATOR}=`, UNICODE_SYMBOLS.GREATER_EQUAL],
153
- [`~${DEFAULT_SEPARATOR}=`, UNICODE_SYMBOLS.APPROXIMATE],
154
- [`=${DEFAULT_SEPARATOR}~`, UNICODE_SYMBOLS.APPROXIMATE],
155
- ])('converts across separator boundary: "%s"', (input, expected) => {
156
- expect(mathSymbols(input)).toBe(expected)
157
- })
158
- })
159
-
160
- describe("legalSymbols", () => {
161
- it.each([
162
- // (c) with year → copyright
163
- ["Copyright (c) 2024", `Copyright ${UNICODE_SYMBOLS.COPYRIGHT} 2024`],
164
- ["(c) 2024 (r) Brand(tm)", `${UNICODE_SYMBOLS.COPYRIGHT} 2024 ${UNICODE_SYMBOLS.REGISTERED} Brand${UNICODE_SYMBOLS.TRADEMARK}`],
165
- ["(C) 1999 Company", `${UNICODE_SYMBOLS.COPYRIGHT} 1999 Company`],
166
- // (c) preceded by "copyright" → copyright
167
- ["Copyright (c) Company", `Copyright ${UNICODE_SYMBOLS.COPYRIGHT} Company`],
168
- ["copyright (C) by Author", `copyright ${UNICODE_SYMBOLS.COPYRIGHT} by Author`],
169
- // (r) converts in trademark context
170
- ["Brand(r)", `Brand${UNICODE_SYMBOLS.REGISTERED}`],
171
- ["Product (R)", `Product ${UNICODE_SYMBOLS.REGISTERED}`],
172
- ["Name(tm)", `Name${UNICODE_SYMBOLS.TRADEMARK}`],
173
- ["Name (TM)", `Name ${UNICODE_SYMBOLS.TRADEMARK}`],
174
- ])('converts "%s" to "%s"', (input, expected) => {
175
- expect(legalSymbols(input)).toBe(expected)
176
- })
177
-
178
- it.each([
179
- // Enumerations
180
- ["(a), (b), (c), (d)", "(a), (b), (c), (d)"],
181
- ["(a), (b), (C), (d)", "(a), (b), (C), (d)"],
182
- ["options (A) and (B) and (C)", "options (A) and (B) and (C)"],
183
- // Legal subsections
184
- ["paragraph (c)(2)(A)", "paragraph (c)(2)(A)"],
185
- ["section 5(c)(1)", "section 5(c)(1)"],
186
- // Standalone references without copyright context
187
- ["subsection (c)", "subsection (c)"],
188
- ["the (c) symbol", "the (c) symbol"],
189
- ["discussed in (c) above", "discussed in (c) above"],
190
- ["(C) Acme Inc", "(C) Acme Inc"],
191
- ])('preserves (c) in non-copyright context "%s"', (input, expected) => {
192
- expect(legalSymbols(input)).toBe(expected)
193
- })
194
-
195
- it.each([
196
- // (r) in enumerations
197
- ["(p), (q), (r), (s)", "(p), (q), (r), (s)"],
198
- ["(Q); (R); (S)", "(Q); (R); (S)"],
199
- // (r) in legal citations
200
- ["See (r)(1)(A)", "See (r)(1)(A)"],
201
- ])('preserves (r) in non-trademark context "%s"', (input, expected) => {
202
- expect(legalSymbols(input)).toBe(expected)
203
- })
204
-
205
- it.each([
206
- // Legal symbols in URL/path contexts
207
- ["example.com/path(c) 2024", "example.com/path(c) 2024"],
208
- ["example.com/path(tm)", "example.com/path(tm)"],
209
- ["example.com/path(r)", "example.com/path(r)"],
210
- ["/usr/local/bin(r)", "/usr/local/bin(r)"],
211
- ])('preserves legal symbols in path context "%s"', (input, expected) => {
212
- expect(legalSymbols(input)).toBe(expected)
213
- })
214
-
215
- it("detects copyright year across separator boundary", () => {
216
- const sep = DEFAULT_SEPARATOR
217
- expect(legalSymbols(`(c)${sep} 2024`, { separator: sep })).toBe(`${UNICODE_SYMBOLS.COPYRIGHT}${sep} 2024`)
218
- })
219
- })
220
-
221
- describe("arrows", () => {
222
- it.each([
223
- ["A -> B", `A ${UNICODE_SYMBOLS.ARROW_RIGHT} B`],
224
- ["A <- B", `A ${UNICODE_SYMBOLS.ARROW_LEFT} B`],
225
- ["A <-> B", `A ${UNICODE_SYMBOLS.ARROW_LEFT_RIGHT} B`],
226
- ["A --> B", `A ${UNICODE_SYMBOLS.ARROW_RIGHT} B`],
227
- ["A <-- B", `A ${UNICODE_SYMBOLS.ARROW_LEFT} B`],
228
- ["A <--> B", `A ${UNICODE_SYMBOLS.ARROW_LEFT_RIGHT} B`],
229
- ["start -> middle -> end", `start ${UNICODE_SYMBOLS.ARROW_RIGHT} middle ${UNICODE_SYMBOLS.ARROW_RIGHT} end`],
230
- // Pointer-style arrows preserved (no spaces)
231
- ["function->call", "function->call"],
232
- ["array[0]->value", "array[0]->value"],
233
- ])('converts "%s" to "%s"', (input, expected) => {
234
- expect(arrows(input)).toBe(expected)
235
- })
236
- })
237
-
238
- describe("degrees", () => {
239
- it.each([
240
- ["20 C", `20 ${UNICODE_SYMBOLS.DEGREE}C`],
241
- ["20C", `20 ${UNICODE_SYMBOLS.DEGREE}C`],
242
- ["68 F", `68 ${UNICODE_SYMBOLS.DEGREE}F`],
243
- ["68F", `68 ${UNICODE_SYMBOLS.DEGREE}F`],
244
- ["Water boils at 100 C", `Water boils at 100 ${UNICODE_SYMBOLS.DEGREE}C`],
245
- ["Room temperature: 72F", `Room temperature: 72 ${UNICODE_SYMBOLS.DEGREE}F`],
246
- // Test degrees before punctuation (word boundary cases)
247
- ["20C.", `20 ${UNICODE_SYMBOLS.DEGREE}C.`],
248
- ["68F!", `68 ${UNICODE_SYMBOLS.DEGREE}F!`],
249
- ["100C,", `100 ${UNICODE_SYMBOLS.DEGREE}C,`],
250
- ["20 km", "20 km"],
251
- ["Section C", "Section C"],
252
- ["100 C", `100 ${UNICODE_SYMBOLS.DEGREE}C`],
253
- ["212F", `212 ${UNICODE_SYMBOLS.DEGREE}F`],
254
- ["-40 C", `-40 ${UNICODE_SYMBOLS.DEGREE}C`],
255
- // Case-sensitive: lowercase c/f should NOT be converted
256
- ["20 c", "20 c"],
257
- ["68 f", "68 f"],
258
- ["20c", "20c"],
259
- ["68f", "68f"],
260
- // Compound suffixes: C/F followed by hyphen+letter or +/# are not temperatures
261
- ["20 C-compiler", "20 C-compiler"],
262
- ["5 F-score", "5 F-score"],
263
- ["20 C++", "20 C++"],
264
- ["20 C#", "20 C#"],
265
- ["100 F#", "100 F#"],
266
- ])('converts "%s" to "%s"', (input, expected) => {
267
- expect(degrees(input)).toBe(expected)
268
- })
269
-
270
- it("handles separator characters", () => {
271
- const sep = "\uE000"
272
- expect(degrees(`20${sep}C`, { separator: sep })).toBe(
273
- `20${sep} ${UNICODE_SYMBOLS.DEGREE}C`
274
- )
275
- })
276
-
277
- describe("marker robustness", () => {
278
- // Tests that separators don't create false word boundaries
279
- const sep = DEFAULT_SEPARATOR
280
-
281
- it.each([
282
- // [description, input, expected]
283
- ["false boundary before word char", `20C${sep}elsius`, `20C${sep}elsius`], // "20Celsius" - should NOT convert
284
- ["valid boundary before space", `20C${sep} today`, `20 ${UNICODE_SYMBOLS.DEGREE}C${sep} today`], // should convert
285
- ["valid boundary before punctuation", `68F${sep}.`, `68 ${UNICODE_SYMBOLS.DEGREE}F${sep}.`], // should convert
286
- ])("handles %s", (_desc, input, expected) => {
287
- expect(degrees(input, { separator: sep })).toBe(expected)
288
- })
289
- })
290
- })
291
-
292
- describe("primeMarks", () => {
293
- it.each([
294
- ['5\'10"', `5${UNICODE_SYMBOLS.PRIME}10${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
295
- ['He is 6\'2" tall', `He is 6${UNICODE_SYMBOLS.PRIME}2${UNICODE_SYMBOLS.DOUBLE_PRIME} tall`],
296
- ["The board is 8' long", `The board is 8${UNICODE_SYMBOLS.PRIME} long`],
297
- ['12"', `12${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
298
- [`Location: 45${UNICODE_SYMBOLS.DEGREE} 30' 15"`, `Location: 45${UNICODE_SYMBOLS.DEGREE} 30${UNICODE_SYMBOLS.PRIME} 15${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
299
- ["don't", "don't"],
300
- ["it's", "it's"],
301
- ["'Twas the night", "'Twas the night"],
302
- ["'hello'", "'hello'"],
303
- ['"test"', '"test"'],
304
- ['The ceiling is 9\'6" high', `The ceiling is 9${UNICODE_SYMBOLS.PRIME}6${UNICODE_SYMBOLS.DOUBLE_PRIME} high`],
305
- [`40${UNICODE_SYMBOLS.DEGREE} 44' 54" N`, `40${UNICODE_SYMBOLS.DEGREE} 44${UNICODE_SYMBOLS.PRIME} 54${UNICODE_SYMBOLS.DOUBLE_PRIME} N`],
306
- ['The board is 12" wide', `The board is 12${UNICODE_SYMBOLS.DOUBLE_PRIME} wide`],
307
- ['12" long', `12${UNICODE_SYMBOLS.DOUBLE_PRIME} long`],
308
- // Multiple primes in one string
309
- ["10' x 12'", `10${UNICODE_SYMBOLS.PRIME} x 12${UNICODE_SYMBOLS.PRIME}`],
310
- ["The room is 10' x 12' x 8' tall", `The room is 10${UNICODE_SYMBOLS.PRIME} x 12${UNICODE_SYMBOLS.PRIME} x 8${UNICODE_SYMBOLS.PRIME} tall`],
311
- ['10" x 12"', `10${UNICODE_SYMBOLS.DOUBLE_PRIME} x 12${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
312
- [`5' and 8' boards`, `5${UNICODE_SYMBOLS.PRIME} and 8${UNICODE_SYMBOLS.PRIME} boards`],
313
- // Contractions before primes
314
- ["it's 5' long", `it's 5${UNICODE_SYMBOLS.PRIME} long`],
315
- ["don't measure 8'", `don't measure 8${UNICODE_SYMBOLS.PRIME}`],
316
- ["she's 5'10\"", `she's 5${UNICODE_SYMBOLS.PRIME}10${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
317
- ["O'Brien's 6' fence", `O'Brien's 6${UNICODE_SYMBOLS.PRIME} fence`],
318
- // Possessive plurals before primes
319
- ["the dogs' 5' leashes", `the dogs' 5${UNICODE_SYMBOLS.PRIME} leashes`],
320
- ["the cats' and dogs' 5' run", `the cats' and dogs' 5${UNICODE_SYMBOLS.PRIME} run`],
321
- ])('converts "%s" to "%s"', (input, expected) => {
322
- expect(primeMarks(input)).toBe(expected)
323
- })
324
-
325
- // Quote balancing tests: simple (\d)['"]) patterns would incorrectly convert these
326
- it.each([
327
- // Double quote balancing
328
- ['"Term 1".', '"Term 1".'],
329
- ['"Number 5"', '"Number 5"'],
330
- ['"Item 3", "Item 4"', '"Item 3", "Item 4"'],
331
- ['She said "Chapter 5" was good', 'She said "Chapter 5" was good'],
332
- ['The file "test_v2" exists', 'The file "test_v2" exists'],
333
- ['"Room 101" is famous', '"Room 101" is famous'],
334
- // Single quote balancing
335
- ["'Term 1'", "'Term 1'"],
336
- ["'Item 3'", "'Item 3'"],
337
- ["She said 'Chapter 5' was good", "She said 'Chapter 5' was good"],
338
- ])('preserves closing quotes via quote balancing: "%s"', (input, expected) => {
339
- expect(primeMarks(input)).toBe(expected)
340
- })
341
-
342
- // Feet-inches inside balanced quotes: the balance tracker detects ′ + digits
343
- // before a " prime candidate and converts it to ″ regardless of quote balance
344
- it.each([
345
- ['"He is 5\'10" tall"', `"He is 5${UNICODE_SYMBOLS.PRIME}10${UNICODE_SYMBOLS.DOUBLE_PRIME} tall"`],
346
- ['"The shelf is 5\'11" wide"', `"The shelf is 5${UNICODE_SYMBOLS.PRIME}11${UNICODE_SYMBOLS.DOUBLE_PRIME} wide"`],
347
- ])('feet-inches inside balanced quotes converts "%s"', (input, expected) => {
348
- expect(primeMarks(input)).toBe(expected)
349
- })
350
-
351
- it("handles separator characters", () => {
352
- const sep = "\uE000"
353
- expect(primeMarks(`5${sep}'${sep}10${sep}"`, { separator: sep })).toBe(
354
- `5${sep}${UNICODE_SYMBOLS.PRIME}${sep}10${sep}${UNICODE_SYMBOLS.DOUBLE_PRIME}`
355
- )
356
- })
357
-
358
- // Separator-aware contractions: the quote classification pattern must recognize
359
- // contractions even when separators split the word across HTML element boundaries
360
- it.each([
361
- // Contraction with separator before the apostrophe: it<sep>'s
362
- [`it${"\uE000"}'${"\uE000"}s 5'`, `it${"\uE000"}'${"\uE000"}s 5${UNICODE_SYMBOLS.PRIME}`],
363
- // Contraction with separator after the apostrophe: don't
364
- [`don${"\uE000"}'t measure 8'`, `don${"\uE000"}'t measure 8${UNICODE_SYMBOLS.PRIME}`],
365
- // Trailing apostrophe with separator: dogs<sep>'
366
- [`the dogs${"\uE000"}' 5' leashes`, `the dogs${"\uE000"}' 5${UNICODE_SYMBOLS.PRIME} leashes`],
367
- ])('separator-aware contraction/trailing in "%s"', (input, expected) => {
368
- expect(primeMarks(input, { separator: "\uE000" })).toBe(expected)
369
- })
370
- })
371
-
372
- describe("fractions", () => {
373
- it.each([
374
- ["1/2", UNICODE_SYMBOLS.FRACTION_1_2],
375
- ["1/4", UNICODE_SYMBOLS.FRACTION_1_4],
376
- ["3/4", UNICODE_SYMBOLS.FRACTION_3_4],
377
- ["1/3", UNICODE_SYMBOLS.FRACTION_1_3],
378
- ["2/3", UNICODE_SYMBOLS.FRACTION_2_3],
379
- ["Add 1/2 cup", `Add ${UNICODE_SYMBOLS.FRACTION_1_2} cup`],
380
- ["About 3/4 done", `About ${UNICODE_SYMBOLS.FRACTION_3_4} done`],
381
- ["1/8 teaspoon", `${UNICODE_SYMBOLS.FRACTION_1_8} teaspoon`],
382
- // Test fractions before punctuation
383
- ["1/2.", `${UNICODE_SYMBOLS.FRACTION_1_2}.`],
384
- ["3/4!", `${UNICODE_SYMBOLS.FRACTION_3_4}!`],
385
- ["1/4,", `${UNICODE_SYMBOLS.FRACTION_1_4},`],
386
- ["21/4", "21/4"],
387
- ["page 1/25", "page 1/25"],
388
- ["1/7", "1/7"],
389
- ["5/9", "5/9"],
390
- // Edge cases: fractions in paths/URLs should not match
391
- ["a/1/4", "a/1/4"],
392
- ["1/4/b", "1/4/b"],
393
- ["x/1/2", "x/1/2"],
394
- // Edge cases: fractions adjacent to decimals
395
- ["3.1/4", "3.1/4"],
396
- ["1/4.5", "1/4.5"],
397
- ])('converts "%s" to "%s"', (input, expected) => {
398
- expect(fractions(input)).toBe(expected)
399
- })
400
-
401
- it("handles separator characters", () => {
402
- const sep = "\uE000"
403
- expect(fractions(`1${sep}/${sep}2`, { separator: sep })).toBe(
404
- `${sep}${UNICODE_SYMBOLS.FRACTION_1_2}${sep}`
405
- )
406
- })
407
- })
408
-
409
- describe("collapseSpaces", () => {
410
- const { NBSP } = UNICODE_SYMBOLS
411
-
412
- it.each([
413
- // Multiple regular spaces → single space
414
- ["hello world", "hello world"],
415
- ["a b", "a b"],
416
- ["x y", "x y"],
417
- // Multiple nbsp → single nbsp
418
- [`foo${NBSP}${NBSP}bar`, `foo${NBSP}bar`],
419
- [`a${NBSP}${NBSP}${NBSP}b`, `a${NBSP}b`],
420
- // Mixed: any nbsp present → prefer nbsp (more likely intentional)
421
- [`a ${NBSP}b`, `a${NBSP}b`],
422
- [`x ${NBSP}y`, `x${NBSP}y`],
423
- [`a${NBSP} b`, `a${NBSP}b`],
424
- [`x${NBSP} y`, `x${NBSP}y`],
425
- [`a ${NBSP} b`, `a${NBSP}b`],
426
- [`x${NBSP} ${NBSP}y`, `x${NBSP}y`],
427
- // Single spaces unchanged
428
- ["hello world", "hello world"],
429
- [`foo${NBSP}bar`, `foo${NBSP}bar`],
430
- // Multiple groups of spaces
431
- ["a b c", "a b c"],
432
- [`x${NBSP}${NBSP}y z`, `x${NBSP}y z`],
433
- // Tabs
434
- ["hello\t\tworld", "hello world"],
435
- ["a\t\t\tb", "a b"],
436
- // Mixed tabs and spaces
437
- ["a\t b", "a b"],
438
- ["a \tb", "a b"],
439
- [`a\t${NBSP}b`, `a${NBSP}b`],
440
- // Edge cases
441
- ["", ""],
442
- [" ", " "],
443
- [`${NBSP}${NBSP}`, NBSP],
444
- ["no spaces", "no spaces"],
445
- // Single tab unchanged
446
- ["hello\tworld", "hello\tworld"],
447
- ])('converts "%s" to "%s"', (input, expected) => {
448
- expect(collapseSpaces(input)).toBe(expected)
449
- })
450
- })
451
-
452
- describe("superscriptOrdinal", () => {
453
- it.each([
454
- // Basic ordinals
455
- ["1st", `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
456
- ["2nd", `2${UNICODE_SYMBOLS.SUPERSCRIPT_ND}`],
457
- ["3rd", `3${UNICODE_SYMBOLS.SUPERSCRIPT_RD}`],
458
- ["4th", `4${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`],
459
- // Larger numbers
460
- ["21st", `21${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
461
- ["22nd", `22${UNICODE_SYMBOLS.SUPERSCRIPT_ND}`],
462
- ["23rd", `23${UNICODE_SYMBOLS.SUPERSCRIPT_RD}`],
463
- ["30th", `30${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`],
464
- ["100th", `100${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`],
465
- // In sentences
466
- ["The 1st place winner", `The 1${UNICODE_SYMBOLS.SUPERSCRIPT_ST} place winner`],
467
- ["Born on the 30th of June", `Born on the 30${UNICODE_SYMBOLS.SUPERSCRIPT_TH} of June`],
468
- ["The 21st century", `The 21${UNICODE_SYMBOLS.SUPERSCRIPT_ST} century`],
469
- // Before punctuation
470
- ["1st.", `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}.`],
471
- ["2nd,", `2${UNICODE_SYMBOLS.SUPERSCRIPT_ND},`],
472
- ["3rd!", `3${UNICODE_SYMBOLS.SUPERSCRIPT_RD}!`],
473
- // Case insensitive
474
- ["1ST", `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
475
- ["2ND", `2${UNICODE_SYMBOLS.SUPERSCRIPT_ND}`],
476
- ["3RD", `3${UNICODE_SYMBOLS.SUPERSCRIPT_RD}`],
477
- ["4TH", `4${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`],
478
- // Should not match non-ordinal text
479
- ["first", "first"],
480
- ["stand", "stand"],
481
- ["thunder", "thunder"],
482
- ["rand", "rand"],
483
- ])('converts "%s" to "%s"', (input, expected) => {
484
- expect(superscriptOrdinal(input)).toBe(expected)
485
- })
486
-
487
- it("handles separator characters", () => {
488
- const sep = "\uE000"
489
- expect(superscriptOrdinal(`30${sep}th`, { separator: sep })).toBe(
490
- `30${sep}${UNICODE_SYMBOLS.SUPERSCRIPT_TH}`
491
- )
492
- })
493
-
494
- describe("marker robustness", () => {
495
- // Tests that separators don't create false word boundaries
496
- const sep = DEFAULT_SEPARATOR
497
-
498
- it.each([
499
- // [description, input, expected]
500
- ["false boundary (1stly)", `1st${sep}ly`, `1st${sep}ly`], // "1stly" - should NOT convert
501
- ["false boundary (2ndary)", `2nd${sep}ary`, `2nd${sep}ary`], // "2ndary" - should NOT convert
502
- ["false boundary (3rdly)", `3rd${sep}ly`, `3rd${sep}ly`], // "3rdly" - should NOT convert
503
- ["valid boundary before space", `1st${sep} place`, `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}${sep} place`], // should convert
504
- ["valid boundary before punctuation", `2nd${sep}.`, `2${UNICODE_SYMBOLS.SUPERSCRIPT_ND}${sep}.`], // should convert
505
- ])("handles %s", (_desc, input, expected) => {
506
- expect(superscriptOrdinal(input, { separator: sep })).toBe(expected)
507
- })
508
- })
509
- })
510
-
511
- describe("punctuationLigatures", () => {
512
- it.each([
513
- // Double/multiple question marks squashed to ligature
514
- ["What??", `What${UNICODE_SYMBOLS.DOUBLE_QUESTION}`],
515
- ["Really???", `Really${UNICODE_SYMBOLS.DOUBLE_QUESTION}`],
516
- ["Huh????", `Huh${UNICODE_SYMBOLS.DOUBLE_QUESTION}`],
517
- ["??", UNICODE_SYMBOLS.DOUBLE_QUESTION],
518
- // Question + exclamation(s) → ⁈
519
- ["Really?!", `Really${UNICODE_SYMBOLS.QUESTION_EXCLAMATION}`],
520
- ["What?!!", `What${UNICODE_SYMBOLS.QUESTION_EXCLAMATION}`],
521
- ["?!", UNICODE_SYMBOLS.QUESTION_EXCLAMATION],
522
- // Exclamation + question(s) → ⁉
523
- ["No way!?", `No way${UNICODE_SYMBOLS.EXCLAMATION_QUESTION}`],
524
- ["What!??", `What${UNICODE_SYMBOLS.EXCLAMATION_QUESTION}`],
525
- ["!?", UNICODE_SYMBOLS.EXCLAMATION_QUESTION],
526
- // Double/multiple exclamation marks squashed to single
527
- ["Wow!!", "Wow!"],
528
- ["Amazing!!!", "Amazing!"],
529
- ["Yes!!!!", "Yes!"],
530
- ["!!", "!"],
531
- // Single punctuation unchanged
532
- ["What?", "What?"],
533
- ["Wow!", "Wow!"],
534
- // Text without punctuation unchanged
535
- ["Hello world", "Hello world"],
536
- ])('converts "%s" to "%s"', (input, expected) => {
537
- expect(punctuationLigatures(input)).toBe(expected)
538
- })
539
-
540
- it.each([
541
- ["??", `${UNICODE_SYMBOLS.DOUBLE_QUESTION}${DEFAULT_SEPARATOR}`],
542
- ["?!", `${UNICODE_SYMBOLS.QUESTION_EXCLAMATION}${DEFAULT_SEPARATOR}`],
543
- ["!?", `${UNICODE_SYMBOLS.EXCLAMATION_QUESTION}${DEFAULT_SEPARATOR}`],
544
- ["!!", `!${DEFAULT_SEPARATOR}`],
545
- ])("preserves separator in %s", (marks, expected) => {
546
- const input = marks[0] + DEFAULT_SEPARATOR + marks[1]
547
- expect(punctuationLigatures(input, { separator: DEFAULT_SEPARATOR })).toBe(expected)
548
- })
549
-
550
- it("handles multiple ligatures in same text", () => {
551
- const input = "What?? Really?! No way!? Wow!!"
552
- const expected = `What${UNICODE_SYMBOLS.DOUBLE_QUESTION} Really${UNICODE_SYMBOLS.QUESTION_EXCLAMATION} No way${UNICODE_SYMBOLS.EXCLAMATION_QUESTION} Wow!`
553
- expect(punctuationLigatures(input)).toBe(expected)
554
- })
555
- })
556
-
557
- describe("hexadecimal preservation", () => {
558
- it.each([
559
- "0x5F3759DF",
560
- "0xff",
561
- "0X1A2B",
562
- "The magic number is 0x5F3759DF",
563
- "test 0x",
564
- "value: 0X",
565
- ])('preserves "%s"', (input) => {
566
- expect(multiplication(input)).toBe(input)
567
- })
568
- })
569
-
570
- describe("model name / identifier preservation", () => {
571
- it.each([
572
- "Surface5x3",
573
- "iPhone5x case",
574
- "RTX3060x2",
575
- "RX580x4",
576
- "Pixel8x",
577
- ])('preserves "%s"', (input) => {
578
- expect(multiplication(input)).toBe(input)
579
- })
580
- })
581
-
582
- describe("scientific notation preservation", () => {
583
- it.each([
584
- "1e5x3",
585
- "3.5e10x2",
586
- "1E5x3",
587
- "2.5E8x100",
588
- "The value 1e5x is unchanged",
589
- ])('preserves unsigned-exponent "%s"', (input) => {
590
- expect(multiplication(input)).toBe(input)
591
- })
592
-
593
- it("preserves unspaced chains attached to scientific notation", () => {
594
- expect(multiplication("1e5x3x2")).toBe("1e5x3x2")
595
- })
596
-
597
- it("converts spaced operands after scientific notation prefix", () => {
598
- // Spaced form: "3" is preceded by space, so it starts a new chain
599
- expect(multiplication("1e5 x 3 x 2")).toBe(`1e5 x 3 ${UNICODE_SYMBOLS.MULTIPLICATION} 2`)
600
- })
601
-
602
- // Signed exponents (e+/e-/E+/E-) are unambiguously scientific notation:
603
- // model names and SKUs essentially never use signed exponents, so the
604
- // ambiguity that protects "1e5x3" doesn't apply here.
605
- it.each([
606
- ["1e-5x3", `1e-5${UNICODE_SYMBOLS.MULTIPLICATION}3`],
607
- ["1e+5x3", `1e+5${UNICODE_SYMBOLS.MULTIPLICATION}3`],
608
- ["3.5E-10x2", `3.5E-10${UNICODE_SYMBOLS.MULTIPLICATION}2`],
609
- ["2.5E+8x100", `2.5E+8${UNICODE_SYMBOLS.MULTIPLICATION}100`],
610
- ])('converts signed-exponent "%s" → "%s"', (input, expected) => {
611
- expect(multiplication(input)).toBe(expected)
612
- })
613
- })
614
-
615
- // Tests derived from competitor libraries and typography guidelines
616
- describe("competitor-derived edge cases", () => {
617
- // From retext-smartypants: ellipsis variations
618
- describe("ellipsis patterns", () => {
619
- it.each([
620
- // Ellipsis at boundaries
621
- ["...start", `${UNICODE_SYMBOLS.ELLIPSIS} start`],
622
- ["end...", `end${UNICODE_SYMBOLS.ELLIPSIS}`],
623
- // Four dots = ellipsis + period
624
- ["text....", `text${UNICODE_SYMBOLS.ELLIPSIS}.`],
625
- // Six dots = two ellipses
626
- ["text......", `text${UNICODE_SYMBOLS.ELLIPSIS}${UNICODE_SYMBOLS.ELLIPSIS}`],
627
- ])('handles ellipsis pattern: "%s"', (input, expected) => {
628
- expect(ellipsis(input)).toBe(expected)
629
- })
630
-
631
- it("converts spaced periods to ellipsis", () => {
632
- expect(ellipsis("text. . . more")).toBe(`text${UNICODE_SYMBOLS.ELLIPSIS} more`)
633
- expect(ellipsis(". . .")).toBe(UNICODE_SYMBOLS.ELLIPSIS)
634
- // Also works with non-breaking spaces
635
- expect(ellipsis(`.${UNICODE_SYMBOLS.NBSP}.${UNICODE_SYMBOLS.NBSP}.`)).toBe(UNICODE_SYMBOLS.ELLIPSIS)
636
- })
637
-
638
- it("preserves non-ellipsis spaced periods", () => {
639
- // Only exactly three spaced dots are converted
640
- expect(ellipsis("a . b . c")).toBe("a . b . c")
641
- })
642
- })
643
-
644
- // From smartquotes.js: prime marks for height
645
- describe("height measurements", () => {
646
- it.each([
647
- ['6\'2"', `6${UNICODE_SYMBOLS.PRIME}2${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
648
- ['5\'10"', `5${UNICODE_SYMBOLS.PRIME}10${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
649
- ["8' boards", `8${UNICODE_SYMBOLS.PRIME} boards`],
650
- ['cut 12" wide', `cut 12${UNICODE_SYMBOLS.DOUBLE_PRIME} wide`],
651
- ])('converts measurement: "%s"', (input, expected) => {
652
- expect(primeMarks(input)).toBe(expected)
653
- })
654
- })
655
-
656
- // From Standard Ebooks: coordinates
657
- describe("coordinate notation", () => {
658
- it.each([
659
- [`40${UNICODE_SYMBOLS.DEGREE} 44' 54" N`, `40${UNICODE_SYMBOLS.DEGREE} 44${UNICODE_SYMBOLS.PRIME} 54${UNICODE_SYMBOLS.DOUBLE_PRIME} N`],
660
- [`74${UNICODE_SYMBOLS.DEGREE} 0' 21" W`, `74${UNICODE_SYMBOLS.DEGREE} 0${UNICODE_SYMBOLS.PRIME} 21${UNICODE_SYMBOLS.DOUBLE_PRIME} W`],
661
- [`51${UNICODE_SYMBOLS.DEGREE} 30' N`, `51${UNICODE_SYMBOLS.DEGREE} 30${UNICODE_SYMBOLS.PRIME} N`],
662
- ])('converts coordinates: "%s"', (input, expected) => {
663
- expect(primeMarks(input)).toBe(expected)
664
- })
665
- })
666
-
667
- // From typograf: math with spaces
668
- describe("math symbol spacing", () => {
669
- it.each([
670
- ["a != b", `a ${UNICODE_SYMBOLS.NOT_EQUAL} b`],
671
- ["x!=y", `x${UNICODE_SYMBOLS.NOT_EQUAL}y`],
672
- ["5 <= 10", `5 ${UNICODE_SYMBOLS.LESS_EQUAL} 10`],
673
- ["10 >= 5", `10 ${UNICODE_SYMBOLS.GREATER_EQUAL} 5`],
674
- ])('handles math symbols: "%s"', (input, expected) => {
675
- expect(mathSymbols(input)).toBe(expected)
676
- })
677
- })
678
-
679
- // From tipograph: arrow preservation in code
680
- describe("arrow context sensitivity", () => {
681
- it.each([
682
- // Should convert (spaced)
683
- ["input -> output", `input ${UNICODE_SYMBOLS.ARROW_RIGHT} output`],
684
- ["a <-> b", `a ${UNICODE_SYMBOLS.ARROW_LEFT_RIGHT} b`],
685
- // Should NOT convert (no spaces - code-like)
686
- ["obj->method", "obj->method"],
687
- ["ptr->value", "ptr->value"],
688
- ["this->that", "this->that"],
689
- ])('handles arrows contextually: "%s"', (input, expected) => {
690
- expect(arrows(input)).toBe(expected)
691
- })
692
- })
693
- })
694
-
695
- describe("complex real-world patterns", () => {
696
- describe("mixed measurements", () => {
697
- it.each([
698
- // Simple foot measurements
699
- ["The board is 8' long", `The board is 8${UNICODE_SYMBOLS.PRIME} long`],
700
- ['12" wide', `12${UNICODE_SYMBOLS.DOUBLE_PRIME} wide`],
701
- // Height notation
702
- ['6\'2"', `6${UNICODE_SYMBOLS.PRIME}2${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
703
- ])('handles measurement: "%s"', (input, expected) => {
704
- expect(primeMarks(input)).toBe(expected)
705
- })
706
-
707
- })
708
-
709
- describe("temperatures in context", () => {
710
- it.each([
711
- ["Set oven to 350 F", `Set oven to 350 ${UNICODE_SYMBOLS.DEGREE}F`],
712
- ["Water freezes at 0 C", `Water freezes at 0 ${UNICODE_SYMBOLS.DEGREE}C`],
713
- ["Room temperature: 72 F", `Room temperature: 72 ${UNICODE_SYMBOLS.DEGREE}F`],
714
- ])('handles temperature: "%s"', (input, expected) => {
715
- expect(degrees(input)).toBe(expected)
716
- })
717
- })
718
-
719
- describe("legal text", () => {
720
- it.each([
721
- ["(c) 2024 Company", `${UNICODE_SYMBOLS.COPYRIGHT} 2024 Company`],
722
- ["Brand(r) is a registered trademark", `Brand${UNICODE_SYMBOLS.REGISTERED} is a registered trademark`],
723
- ["Product(tm) - buy now!", `Product${UNICODE_SYMBOLS.TRADEMARK} - buy now!`],
724
- ])('handles legal symbol: "%s"', (input, expected) => {
725
- expect(legalSymbols(input)).toBe(expected)
726
- })
727
- })
728
-
729
- describe("fractions in recipes", () => {
730
- it.each([
731
- ["Add 1/2 cup flour", `Add ${UNICODE_SYMBOLS.FRACTION_1_2} cup flour`],
732
- ["1/4 teaspoon salt", `${UNICODE_SYMBOLS.FRACTION_1_4} teaspoon salt`],
733
- ["about 3/4 done", `about ${UNICODE_SYMBOLS.FRACTION_3_4} done`],
734
- ["1/3 of the mixture", `${UNICODE_SYMBOLS.FRACTION_1_3} of the mixture`],
735
- ])('handles fraction: "%s"', (input, expected) => {
736
- expect(fractions(input)).toBe(expected)
737
- })
738
- })
739
- })
740
-
741
- describe("idempotency", () => {
742
- it.each([
743
- UNICODE_SYMBOLS.ELLIPSIS,
744
- `5${UNICODE_SYMBOLS.MULTIPLICATION}5`,
745
- `a ${UNICODE_SYMBOLS.NOT_EQUAL} b`,
746
- UNICODE_SYMBOLS.COPYRIGHT,
747
- `5${UNICODE_SYMBOLS.PRIME}10${UNICODE_SYMBOLS.DOUBLE_PRIME}`,
748
- `20 ${UNICODE_SYMBOLS.DEGREE}C`,
749
- UNICODE_SYMBOLS.FRACTION_1_2,
750
- `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`,
751
- ])('is idempotent for: "%s"', (input) => {
752
- expect(ellipsis(input)).toBe(input)
753
- expect(multiplication(input)).toBe(input)
754
- })
755
- })
756
-
757
- describe("symbolTransform", () => {
758
- it.each([
759
- ["Wait... 5x5 != 20 (c) 2024", `Wait${UNICODE_SYMBOLS.ELLIPSIS} 5${UNICODE_SYMBOLS.MULTIPLICATION}5 ${UNICODE_SYMBOLS.NOT_EQUAL} 20 ${UNICODE_SYMBOLS.COPYRIGHT} 2024`],
760
- ["-2 x 3", `-2 ${UNICODE_SYMBOLS.MULTIPLICATION} 3`],
761
- ["5 x 5", `5 ${UNICODE_SYMBOLS.MULTIPLICATION} 5`],
762
- ])('converts "%s" to "%s"', (input, expected) => {
763
- expect(symbolTransform(input)).toBe(expected)
764
- })
765
-
766
- it("handles complex text with multiple symbols", () => {
767
- const input = "Product(tm) v2.0 - Size: 10x20 cm, tolerance +- 5%"
768
- const expected = `Product${UNICODE_SYMBOLS.TRADEMARK} v2.0 - Size: 10${UNICODE_SYMBOLS.MULTIPLICATION}20 cm, tolerance ${UNICODE_SYMBOLS.PLUS_MINUS} 5%`
769
- expect(symbolTransform(input)).toEqual(expected)
770
- })
771
-
772
- it("respects separator option", () => {
773
- const sep = "\uE000"
774
- const input = `5${sep}x${sep}5`
775
- expect(symbolTransform(input, { separator: sep })).toEqual(`5${sep}${UNICODE_SYMBOLS.MULTIPLICATION}${sep}5`)
776
- })
777
-
778
- it("includes arrows by default", () => {
779
- expect(symbolTransform("A -> B")).toBe(`A ${UNICODE_SYMBOLS.ARROW_RIGHT} B`)
780
- })
781
-
782
- it("can disable arrows with includeArrows: false", () => {
783
- expect(symbolTransform("A -> B", { includeArrows: false })).toBe("A -> B")
784
- })
785
- })
786
-
787
- describe("multiplication ReDoS regression", () => {
788
- it("scales linearly for long digit strings", () => {
789
- // Before fix: quadratic (~100x for 10x input). After fix: linear.
790
- assertLinearScaling(multiplication, (n) => "1".repeat(n), 5_000)
791
- })
792
- })
793
-
794
- describe("multiplication edge cases", () => {
795
- it.each([
796
- ["1000000x2000000", `1000000${UNICODE_SYMBOLS.MULTIPLICATION}2000000`],
797
- ["01x02", `01${UNICODE_SYMBOLS.MULTIPLICATION}02`],
798
- ["2x", `2${UNICODE_SYMBOLS.MULTIPLICATION}`],
799
- ])('handles multiplication edge: "%s"', (input, expected) => {
800
- expect(multiplication(input)).toBe(expected)
801
- })
802
- })
803
-
804
- describe("multiplication - preserve words containing x", () => {
805
- it.each([
806
- ["extra", "extra"],
807
- ["index", "index"],
808
- ["2xtra", "2xtra"],
809
- ["hex", "hex"],
810
- ["next", "next"],
811
- ["text", "text"],
812
- ["approximately", "approximately"],
813
- ])('does NOT transform word: "%s"', (input, expected) => {
814
- expect(multiplication(input)).toBe(expected)
815
- })
816
- })
817
-
818
- describe("ellipsis edge cases", () => {
819
- it.each([
820
- ["One... Two... Three...", `One${UNICODE_SYMBOLS.ELLIPSIS} Two${UNICODE_SYMBOLS.ELLIPSIS} Three${UNICODE_SYMBOLS.ELLIPSIS}`],
821
- ["...5 items", `${UNICODE_SYMBOLS.ELLIPSIS} 5 items`],
822
- ["End of sentence....", `End of sentence${UNICODE_SYMBOLS.ELLIPSIS}.`],
823
- ['"Wait..."', `"Wait${UNICODE_SYMBOLS.ELLIPSIS}"`],
824
- ])('handles ellipsis edge: "%s"', (input, expected) => {
825
- expect(ellipsis(input)).toBe(expected)
826
- })
827
- })
828
-
829
- describe("ellipsis - preserve abbreviations", () => {
830
- it.each([
831
- ["e.g.", "e.g."],
832
- ["i.e.", "i.e."],
833
- ["a.m.", "a.m."],
834
- ["p.m.", "p.m."],
835
- ["Dr.", "Dr."],
836
- ["Mr.", "Mr."],
837
- ["etc.", "etc."],
838
- ])('preserves abbreviation: "%s"', (input, expected) => {
839
- expect(ellipsis(input)).toBe(expected)
840
- })
841
- })
842
-
843
- describe("prime marks edge cases", () => {
844
- it.each([
845
- ['100\'50"', `100${UNICODE_SYMBOLS.PRIME}50${UNICODE_SYMBOLS.DOUBLE_PRIME}`],
846
- ["5' boards", `5${UNICODE_SYMBOLS.PRIME} boards`],
847
- ['12" pipe', `12${UNICODE_SYMBOLS.DOUBLE_PRIME} pipe`],
848
- ["5', 10'", `5${UNICODE_SYMBOLS.PRIME}, 10${UNICODE_SYMBOLS.PRIME}`],
849
- ])('handles prime mark edge: "%s"', (input, expected) => {
850
- expect(primeMarks(input)).toBe(expected)
851
- })
852
- })
853
-
854
- describe("degrees edge temperatures", () => {
855
- it.each([
856
- ["-40 C", `-40 ${UNICODE_SYMBOLS.DEGREE}C`],
857
- ["-40 F", `-40 ${UNICODE_SYMBOLS.DEGREE}F`],
858
- ["0 C", `0 ${UNICODE_SYMBOLS.DEGREE}C`],
859
- ["0 F", `0 ${UNICODE_SYMBOLS.DEGREE}F`],
860
- ["1000 C", `1000 ${UNICODE_SYMBOLS.DEGREE}C`],
861
- ["1000 F", `1000 ${UNICODE_SYMBOLS.DEGREE}F`],
862
- ])('handles temperature edge: "%s"', (input, expected) => {
863
- expect(degrees(input)).toBe(expected)
864
- })
865
- })
866
-
867
- describe("arrow edge cases", () => {
868
- it.each([
869
- ["A -> B -> C", `A ${UNICODE_SYMBOLS.ARROW_RIGHT} B ${UNICODE_SYMBOLS.ARROW_RIGHT} C`],
870
- ["A <- B <- C", `A ${UNICODE_SYMBOLS.ARROW_LEFT} B ${UNICODE_SYMBOLS.ARROW_LEFT} C`],
871
- ["A ---> B", `A ${UNICODE_SYMBOLS.ARROW_RIGHT} B`],
872
- ["A <--- B", `A ${UNICODE_SYMBOLS.ARROW_LEFT} B`],
873
- ["-> output", `${UNICODE_SYMBOLS.ARROW_RIGHT} output`],
874
- ["input ->", `input ${UNICODE_SYMBOLS.ARROW_RIGHT}`],
875
- ])('handles arrow edge: "%s"', (input, expected) => {
876
- expect(arrows(input)).toBe(expected)
877
- })
878
- })
879
-
880
- describe("fractions edge cases", () => {
881
- it.each([
882
- ["1/2 and 1/4", `${UNICODE_SYMBOLS.FRACTION_1_2} and ${UNICODE_SYMBOLS.FRACTION_1_4}`],
883
- ["Add 1/2.", `Add ${UNICODE_SYMBOLS.FRACTION_1_2}.`],
884
- ["(1/2)", `(${UNICODE_SYMBOLS.FRACTION_1_2})`],
885
- ["5/7", "5/7"],
886
- ["11/12", "11/12"],
887
- ["2/2", "2/2"],
888
- ["4/3", "4/3"],
889
- ])('handles fraction edge: "%s"', (input, expected) => {
890
- expect(fractions(input)).toBe(expected)
891
- })
892
- })
893
-
894
- describe("chained multiplications", () => {
895
- const M = UNICODE_SYMBOLS.MULTIPLICATION
896
- it.each([
897
- ["5x5x5", `5${M}5${M}5`, "tight x"],
898
- ["5 x 5 x 5", `5 ${M} 5 ${M} 5`, "spaced x"],
899
- ["5*5*5", `5${M}5${M}5`, "tight *"],
900
- ["5 * 5 * 5", `5 ${M} 5 ${M} 5`, "spaced *"],
901
- ["10x10x10", `10${M}10${M}10`, "multi-digit"],
902
- ])("converts %s → %s (%s)", (input, expected) => {
903
- expect(multiplication(input)).toBe(expected)
904
- })
905
- })
906
-
907
- describe("symbol stress tests", () => {
908
- it("scales linearly for ellipsis patterns", () => {
909
- assertLinearScaling(ellipsis, (n) => "wait... ".repeat(n))
910
- })
911
-
912
- it("scales linearly for fraction patterns", () => {
913
- assertLinearScaling(fractions, (n) => "add 1/2 cup ".repeat(n))
914
- })
915
-
916
- it("scales linearly for consecutive single quotes (ReDoS prevention)", () => {
917
- assertLinearScaling(symbolTransform, (n) => "'".repeat(n))
918
- })
919
- })
920
-