punctilio 3.7.3 → 3.7.5

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,736 +0,0 @@
1
- import { hyphenReplace, enDashNumberRange, enDashDateRange, minusReplace, numberRangeDisallowedPrefixes } from "../dashes.js"
2
- import { DEFAULT_SEPARATOR, UNICODE_SYMBOLS } from "../constants.js"
3
- import { assertLinearScaling } from "./test-helpers.js"
4
-
5
- const {
6
- LEFT_DOUBLE_QUOTE,
7
- RIGHT_DOUBLE_QUOTE,
8
- LEFT_SINGLE_QUOTE,
9
- RIGHT_SINGLE_QUOTE,
10
- EM_DASH,
11
- EN_DASH,
12
- MINUS,
13
- ELLIPSIS,
14
- } = UNICODE_SYMBOLS
15
-
16
- describe("hyphenReplace", () => {
17
- describe("em dashes from surrounded hyphens", () => {
18
- it.each([
19
- ["This is a - hyphen.", `This is a${EM_DASH}hyphen.`],
20
- // Parenthetical dash before a digit (not math subtraction)
21
- ["Safari) - 9 combinations", `Safari)${EM_DASH}9 combinations`],
22
- [`This is an ${EM_DASH} em dash.`, `This is an${EM_DASH}em dash.`],
23
- [`word ${EM_DASH} word`, `word${EM_DASH}word`],
24
- ["word ---", `word${EM_DASH}`],
25
- [`word${EM_DASH} word`, `word${EM_DASH}word`],
26
- [`word ${EM_DASH}word`, `word${EM_DASH}word`],
27
- ['"I love dogs." - Me', `"I love dogs."${EM_DASH}Me`],
28
- ["- Me", `${EM_DASH} Me`],
29
- ["-- Me", `${EM_DASH} Me`],
30
- ["Hi-- what do you think?", `Hi${EM_DASH}what do you think?`],
31
- [
32
- `${EM_DASH}such behaviors still have to be retrodicted`,
33
- `${EM_DASH}such behaviors still have to be retrodicted`,
34
- ],
35
- [`emphasis" ${EM_DASH}`, `emphasis"${EM_DASH}`],
36
- ["- Intro text\n then more", `${EM_DASH} Intro text\n then more`],
37
- [
38
- `reward${ELLIPSIS} ${EM_DASH} [Model-based RL, Desires, Brains, Wireheading](https://www.alignmentforum.org/posts/K5ikTdaNymfWXQHFb/model-based-rl-desires-brains-wireheading#Self_aware_desires_1__wireheading)`,
39
- `reward${ELLIPSIS}${EM_DASH}[Model-based RL, Desires, Brains, Wireheading](https://www.alignmentforum.org/posts/K5ikTdaNymfWXQHFb/model-based-rl-desires-brains-wireheading#Self_aware_desires_1__wireheading)`,
40
- ],
41
- ["a browser- or OS-specific fashion", "a browser- or OS-specific fashion"],
42
- // Suspended/hanging hyphens: prefix shared with a preceding compound
43
- ["Yes-men and -women", "Yes-men and -women"],
44
- ["the over- and -supply of widgets", "the over- and -supply of widgets"],
45
- ["since--as you know", `since${EM_DASH}as you know`],
46
- // Arrow patterns should be preserved (not converted to em-dashes)
47
- ["word -> arrow", "word -> arrow"],
48
- ["word --> arrow", "word --> arrow"],
49
- ["-> start", "-> start"],
50
- ["--> start", "--> start"],
51
- ])('should replace hyphens in "%s"', (input, expected) => {
52
- expect(hyphenReplace(input)).toBe(expected)
53
- })
54
- })
55
-
56
- describe("multiple dashes within words", () => {
57
- it.each([
58
- ["Since--as you know", `Since${EM_DASH}as you know`, "double dashes"],
59
- ["word---another", `word${EM_DASH}another`, "triple dashes"],
60
- ])("%s → %s (%s)", (input, expected) => {
61
- expect(hyphenReplace(input)).toBe(expected)
62
- })
63
- })
64
-
65
- describe("dashes at start of line", () => {
66
- it.each([
67
- ["- Author", `${EM_DASH} Author`],
68
- ["--- Author attribution", `${EM_DASH} Author attribution`],
69
- ["Quote\n- Author", `Quote\n${EM_DASH} Author`],
70
- ])('handles "%s"', (input, expected) => {
71
- expect(hyphenReplace(input)).toBe(expected)
72
- })
73
- })
74
-
75
- describe("spaces around em dashes", () => {
76
- it.each([
77
- [`word ${EM_DASH} another`, `word${EM_DASH}another`],
78
- [`word${EM_DASH} another`, `word${EM_DASH}another`],
79
- [`word ${EM_DASH}another`, `word${EM_DASH}another`],
80
- ])('removes spaces in "%s"', (input, expected) => {
81
- expect(hyphenReplace(input)).toBe(expected)
82
- })
83
- })
84
-
85
- describe("quote-to-quote em dash (Chicago: no spaces)", () => {
86
- it.each([
87
- // Chicago style: no spaces around em-dashes, even between quotes
88
- `"Hello."${EM_DASH}"World"`,
89
- `'Hi.'${EM_DASH}'There'`,
90
- `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE}${EM_DASH}${LEFT_DOUBLE_QUOTE}World${RIGHT_DOUBLE_QUOTE}`,
91
- `${LEFT_SINGLE_QUOTE}Hi.${RIGHT_SINGLE_QUOTE}${EM_DASH}${LEFT_SINGLE_QUOTE}There${RIGHT_SINGLE_QUOTE}`,
92
- ])('preserves unspaced em-dash in "%s"', (input) => {
93
- expect(hyphenReplace(input)).toBe(input)
94
- })
95
- })
96
-
97
- describe("em dashes at start of line", () => {
98
- it.each([
99
- [`${EM_DASH}Start of line`, `${EM_DASH} Start of line`],
100
- [`Line 1\n${EM_DASH}Line 2`, `Line 1\n${EM_DASH} Line 2`],
101
- [`${EM_DASH} Already correct`, `${EM_DASH} Already correct`],
102
- ])('handles "%s"', (input, expected) => {
103
- expect(hyphenReplace(input)).toBe(expected)
104
- })
105
- })
106
-
107
- describe("number ranges to en dashes", () => {
108
- it.each([
109
- ["Pages 1-5", `Pages 1${EN_DASH}5`],
110
- ["5--10", `5${EN_DASH}10`],
111
- ["2000-2020", `2000${EN_DASH}2020`],
112
- ["2018-2021. Then 1-3", `2018${EN_DASH}2021. Then 1${EN_DASH}3`],
113
- ["p.10-15", `p.10${EN_DASH}15`],
114
- ])('converts "%s"', (input, expected) => {
115
- expect(hyphenReplace(input)).toBe(expected)
116
- })
117
- })
118
-
119
- describe("month ranges to en dashes", () => {
120
- it.each([
121
- ["Jan-Mar", `Jan${EN_DASH}Mar`],
122
- ["Mar - Nov", `Mar${EN_DASH}Nov`],
123
- ["January - March", `January${EN_DASH}March`],
124
- ["October 2012 - December 2014", `October 2012${EN_DASH}December 2014`],
125
- ["Jan 2000 - Feb", `Jan 2000${EN_DASH}Feb`],
126
- ["March - April 2025", `March${EN_DASH}April 2025`],
127
- ])('converts "%s"', (input, expected) => {
128
- expect(hyphenReplace(input)).toBe(expected)
129
- })
130
- })
131
-
132
- describe("tilde preservation", () => {
133
- it.each([
134
- ["word ~ word", "word ~ word"],
135
- ["word ~~ word", "word ~~ word"],
136
- ["the ~5% range", "the ~5% range"],
137
- ["~approximately", "~approximately"],
138
- ])('preserves tilde in "%s"', (input) => {
139
- expect(hyphenReplace(input)).toBe(input)
140
- })
141
- })
142
-
143
- describe("with separator character", () => {
144
- const sep = "\uE000"
145
- it.each([
146
- [`word${sep} - ${sep}another`, `word${sep}${EM_DASH}${sep}another`, "em dash context"],
147
- [`pages 1${sep}-${sep}5`, `pages 1${sep}${EN_DASH}${sep}5`, "number ranges"],
148
- // American em-dash (Chicago style) consumes surrounding spaces, even across separator boundaries
149
- [`text ${EM_DASH} ${sep} more text`, `text${EM_DASH}${sep}more text`, "consumes space after separator for em-dash"],
150
- [`text - ${sep} more`, `text${EM_DASH}${sep}more`, "consumes space after separator with hyphen"],
151
- // Separator-only before dash (no space between word and separator): e.g., link text followed by dash
152
- [`word${sep}${EN_DASH} rest`, `word${sep}${EM_DASH}rest`, "en-dash after separator without preceding space"],
153
- [`word${sep}- rest`, `word${sep}${EM_DASH}rest`, "hyphen after separator without preceding space"],
154
- // Spaced hyphen with separator after: "word -<sep> another" is a parenthetical dash, not suspended
155
- [`word -${sep} another`, `word${EM_DASH}${sep}another`, "hyphen before separator-space is parenthetical"],
156
- // Suspended hyphen across separator boundary: hyphen attached to word through separator
157
- [`and -${sep}women`, `and -${sep}women`, "suspended hyphen across separator preserved"],
158
- ])("%s → %s (%s)", (input, expected) => {
159
- expect(hyphenReplace(input, { separator: sep })).toBe(expected)
160
- })
161
-
162
- it.each([
163
- // British en-dash (spaced) preserves trailing space after separator
164
- [`text - ${sep} more`, `text ${EN_DASH} ${sep} more`, "preserves space after separator for en-dash"],
165
- [`word${sep}${EN_DASH} rest`, `word${sep} ${EN_DASH} rest`, "en-dash after separator british style"],
166
- ])("%s → %s (%s)", (input, expected) => {
167
- expect(hyphenReplace(input, { separator: sep, dashStyle: "british" })).toBe(expected)
168
- })
169
- })
170
- })
171
-
172
- describe("enDashNumberRange", () => {
173
- it.each([
174
- ["1-2", `1${EN_DASH}2`],
175
- ["10-20", `10${EN_DASH}20`],
176
- ["100-200", `100${EN_DASH}200`],
177
- ["1000-2000", `1000${EN_DASH}2000`],
178
- ["1,000-2,000", `1,000${EN_DASH}2,000`],
179
- ["1.000-2.000", `1.000${EN_DASH}2.000`],
180
- ["1-2 and 3-4", `1${EN_DASH}2 and 3${EN_DASH}4`],
181
- ["from 5-10 to 15-20", `from 5${EN_DASH}10 to 15${EN_DASH}20`],
182
- ["1-2-3", "1-2-3"], // Multi-segment patterns are not ranges
183
- ["a-b", "a-b"], // Don't replace non-numeric ranges
184
- ["1a-2b", "1a-2b"], // Don't replace if not purely numeric
185
- ["a1-2b", "a1-2b"], // Don't replace if not purely numeric
186
- ["p. 206-207)", `p. 206${EN_DASH}207)`],
187
- ["Qwen1.5-1.8", "Qwen1.5-1.8"], // Don't replace if there's a decimal
188
- ["$100-$200", `$100${EN_DASH}$200`],
189
- ["$1.50-$3.50", `$1.50${EN_DASH}$3.50`],
190
- ["$1-3", `$1${EN_DASH}3`],
191
- ["5--10", `5${EN_DASH}10`], // Double dash as range separator
192
- ["5---10", `5${EN_DASH}10`], // Triple dash as range separator
193
- ["$100--$200", `$100${EN_DASH}$200`], // Double dash with currency
194
- ["1 - 2", "1 - 2"], // Spaced ranges should not change
195
- // Multiplier suffixes
196
- ["1-10x", `1${EN_DASH}10x`], // lowercase x for multiplier
197
- ["1-10K", `1${EN_DASH}10K`], // uppercase K for kilo
198
- ["1-10M", `1${EN_DASH}10M`], // uppercase M for million
199
- ["1-10B", `1${EN_DASH}10B`], // uppercase B for billion
200
- ["1-10T", `1${EN_DASH}10T`], // uppercase T for trillion
201
- ["1-10X", "1-10X"], // uppercase X should NOT match (only lowercase)
202
- ["1-10k", "1-10k"], // lowercase k should NOT match (only uppercase)
203
- ])('should convert "%s" to "%s"', (input, expected) => {
204
- expect(enDashNumberRange(input)).toBe(expected)
205
- })
206
-
207
- describe("disallowed prefix characters", () => {
208
- it.each(numberRangeDisallowedPrefixes)(
209
- 'should not convert number ranges preceded by "%s"',
210
- (prefix) => {
211
- const input = `foo${prefix}2-7B`
212
- expect(enDashNumberRange(input)).toBe(input)
213
- }
214
- )
215
-
216
- it.each([
217
- // Model names with version numbers should NOT be converted
218
- ["Llama-2-7B-chat", "Llama-2-7B-chat"],
219
- ["Llama-2-7B", "Llama-2-7B"],
220
- ["Llama-3-8B-Instruct", "Llama-3-8B-Instruct"],
221
- ["ReLU-2-4", "ReLU-2-4"],
222
- ])('should not convert "%s"', (input, expected) => {
223
- expect(enDashNumberRange(input)).toBe(expected)
224
- })
225
- })
226
-
227
- describe("marker robustness", () => {
228
- // Tests that separators don't create false word boundaries
229
- const sep = DEFAULT_SEPARATOR
230
-
231
- it.each([
232
- // [description, input, expected]
233
- ["false leading boundary", `x${sep}1-10`, `x${sep}1-10`], // "x1-10" - should NOT convert
234
- ["multiplier suffix with separator", `1-10${sep}x`, `1${EN_DASH}10${sep}x`], // range of multipliers - should convert
235
- ["false trailing boundary (non-suffix)", `1-10${sep}y`, `1-10${sep}y`], // "y" is not a valid suffix
236
- ["valid boundaries with space", `pages ${sep}1-10${sep} total`, `pages ${sep}1${EN_DASH}10${sep} total`], // should convert
237
- ])("handles %s", (_desc, input, expected) => {
238
- expect(enDashNumberRange(input, { separator: sep })).toBe(expected)
239
- })
240
- })
241
- })
242
-
243
- describe("enDashDateRange", () => {
244
- describe("american style (default, unspaced)", () => {
245
- it.each([
246
- ["January-March", `January${EN_DASH}March`],
247
- ["Jan-Mar", `Jan${EN_DASH}Mar`],
248
- ["February-April 2024", `February${EN_DASH}April 2024`],
249
- ["May-June", `May${EN_DASH}June`],
250
- ["Sep-Nov", `Sep${EN_DASH}Nov`],
251
- ["December-January", `December${EN_DASH}January`],
252
- ["October 2012 - December 2014", `October 2012${EN_DASH}December 2014`],
253
- ["Oct 2012 - Dec 2014", `Oct 2012${EN_DASH}Dec 2014`],
254
- ["January 2020 - March 2020", `January 2020${EN_DASH}March 2020`],
255
- ["May 2019-June 2020", `May 2019${EN_DASH}June 2020`],
256
- ["Jan 2000 - Feb", `Jan 2000${EN_DASH}Feb`],
257
- ["March - April 2025", `March${EN_DASH}April 2025`],
258
- ])('should convert "%s" to "%s"', (input, expected) => {
259
- expect(enDashDateRange(input)).toBe(expected)
260
- })
261
- })
262
-
263
- describe("british style (spaced)", () => {
264
- it.each([
265
- ["January-March", `January ${EN_DASH} March`],
266
- ["Jan-Mar", `Jan ${EN_DASH} Mar`],
267
- ["October 2012 - December 2014", `October 2012 ${EN_DASH} December 2014`],
268
- ["Oct 2012 - Dec 2014", `Oct 2012 ${EN_DASH} Dec 2014`],
269
- ["May 2019-June 2020", `May 2019 ${EN_DASH} June 2020`],
270
- ])('should convert "%s" to "%s"', (input, expected) => {
271
- expect(enDashDateRange(input, { dashStyle: "british" })).toBe(expected)
272
- })
273
- })
274
-
275
- describe("none style (skip entirely)", () => {
276
- it.each([
277
- ["January-March"],
278
- ["January - March"],
279
- ["October 2012 - December 2014"],
280
- ["Oct 2012-Dec 2014"],
281
- ])("skips all date range transforms: %s", (input) => {
282
- expect(enDashDateRange(input, { dashStyle: "none" })).toBe(input)
283
- })
284
- })
285
-
286
- it("should not convert non-month words", () => {
287
- expect(enDashDateRange("hello-world")).toBe("hello-world")
288
- expect(enDashDateRange("Mon-Fri")).toBe("Mon-Fri") // Days, not months
289
- })
290
-
291
- describe("marker robustness", () => {
292
- // Tests that separators don't create false word boundaries
293
- const sep = DEFAULT_SEPARATOR
294
-
295
- it.each([
296
- // [description, input, expected]
297
- ["false leading boundary", `x${sep}January-March`, `x${sep}January-March`], // "xJanuary-March" - should NOT convert
298
- ["false trailing boundary", `January-March${sep}x`, `January-March${sep}x`], // "January-Marchx" - should NOT convert
299
- ["valid boundaries with space", `from ${sep}January-March${sep} period`, `from ${sep}January${EN_DASH}March${sep} period`], // should convert
300
- ])("handles %s", (_desc, input, expected) => {
301
- expect(enDashDateRange(input, { separator: sep })).toBe(expected)
302
- })
303
- })
304
- })
305
-
306
- describe("minusReplace", () => {
307
- it.each([
308
- ["-5", `${MINUS}5`],
309
- ["-5.5", `${MINUS}5.5`],
310
- ["(-5)", `(${MINUS}5)`],
311
- ["The value is -10", `The value is ${MINUS}10`],
312
- [" -3", ` ${MINUS}3`],
313
- ['"-5"', `"${MINUS}5"`],
314
- // Spaced math subtraction (digit before)
315
- ["5 - 3", `5 ${MINUS} 3`],
316
- ["10 - 5 = 5", `10 ${MINUS} 5 = 5`],
317
- ["(100 - 50)", `(100 ${MINUS} 50)`],
318
- ])('should convert "%s" to use minus sign', (input, expected) => {
319
- expect(minusReplace(input)).toBe(expected)
320
- })
321
-
322
- it("should not convert hyphens in other contexts", () => {
323
- expect(minusReplace("well-known")).toBe("well-known")
324
- expect(minusReplace("re-read")).toBe("re-read")
325
- })
326
-
327
- it("should not convert spaced hyphens without digit before (parenthetical)", () => {
328
- // These should be left for em-dash conversion, not minus
329
- expect(minusReplace("Safari) - 9 combinations")).toBe("Safari) - 9 combinations")
330
- expect(minusReplace("word - 5")).toBe("word - 5")
331
- })
332
-
333
- it("should handle subtraction of negative numbers", () => {
334
- expect(minusReplace("5 - -3")).toBe(`5 ${MINUS} ${MINUS}3`)
335
- })
336
- })
337
-
338
- describe("enDashNumberRange preserves", () => {
339
- it.each([
340
- "555-123-4567", "+1-555-123-4567", "(555)-123-4567", // phones
341
- "978-3-16-148410-0", "0-13-468599-1", // ISBNs
342
- "2024-01-15", "2024-01", "1999-12", // ISO dates
343
- "192-168-1-1", "12-34-5678", // IPs
344
- ])('"%s"', (input) => {
345
- expect(enDashNumberRange(input)).toBe(input)
346
- })
347
- })
348
-
349
- describe("negative number ranges", () => {
350
- it.each([
351
- ["-5--2", `${MINUS}5${EN_DASH}${MINUS}2`],
352
- ["-5-5", `${MINUS}5${EN_DASH}5`],
353
- ["-5-3-7", `${MINUS}5-3-7`], // multi-segment preserved
354
- ])('converts "%s" to "%s"', (input, expected) => {
355
- expect(enDashNumberRange(minusReplace(input))).toBe(expected)
356
- })
357
- })
358
-
359
- // Tests derived from competitor libraries and typography guidelines
360
- describe("competitor-derived edge cases", () => {
361
- // From retext-smartypants: oldschool dash handling
362
- describe("double/triple dash patterns", () => {
363
- it.each([
364
- // From retext-smartypants oldschool mode
365
- ["word--word", `word${EM_DASH}word`],
366
- ["word---word", `word${EM_DASH}word`],
367
- ["start-- end", `start${EM_DASH}end`],
368
- ])('converts "%s"', (input, expected) => {
369
- expect(hyphenReplace(input)).toBe(expected)
370
- })
371
-
372
- it("handles double dash after quote", () => {
373
- const input = '"Hello"-- she said'
374
- const result = hyphenReplace(input)
375
- expect(result).toBe(`"Hello"${EM_DASH}she said`)
376
- })
377
- })
378
-
379
- // Phone numbers and ISBNs - documented via enDashNumberRange tests above
380
- // These multi-segment patterns are correctly preserved
381
- describe("multi-segment pattern preservation", () => {
382
- it.each([
383
- // Already tested in enDashNumberRange preserves section
384
- "555-123-4567", // phone
385
- "978-3-16-148410-0", // ISBN
386
- "2024-01-15", // ISO date
387
- ])('preserves multi-segment pattern: "%s"', (input) => {
388
- expect(enDashNumberRange(input)).toBe(input)
389
- })
390
- })
391
-
392
- // From tipograph: dash after quoted text
393
- describe("dashes with quotes", () => {
394
- it.each([
395
- // Em dash after closing quote with space
396
- [`${RIGHT_DOUBLE_QUOTE} - author`, `${RIGHT_DOUBLE_QUOTE}${EM_DASH}author`],
397
- [`${RIGHT_SINGLE_QUOTE} -- source`, `${RIGHT_SINGLE_QUOTE}${EM_DASH}source`],
398
- ])('handles spaced dashes after quotes: "%s"', (input, expected) => {
399
- expect(hyphenReplace(input)).toBe(expected)
400
- })
401
-
402
- it("handles unspaced double dash between quotes", () => {
403
- const input = '"first"--"second"'
404
- const result = hyphenReplace(input)
405
- expect(result).toBe(`"first"${EM_DASH}"second"`)
406
- })
407
- })
408
- })
409
-
410
- describe("complex real-world patterns", () => {
411
- describe("year ranges", () => {
412
- it.each([
413
- ["2020-2024", `2020${EN_DASH}2024`],
414
- ["the 1990-1999 decade", `the 1990${EN_DASH}1999 decade`],
415
- ["(2010-2015)", `(2010${EN_DASH}2015)`],
416
- ])('handles year range: "%s"', (input, expected) => {
417
- expect(hyphenReplace(input)).toBe(expected)
418
- })
419
- })
420
-
421
- describe("score ranges", () => {
422
- it.each([
423
- ["won 3-2", `won 3${EN_DASH}2`],
424
- ["final score 21-17", `final score 21${EN_DASH}17`],
425
- ["led 14-7", `led 14${EN_DASH}7`],
426
- ])('handles score: "%s"', (input, expected) => {
427
- expect(hyphenReplace(input)).toBe(expected)
428
- })
429
- })
430
-
431
- describe("price ranges", () => {
432
- it.each([
433
- // Dollar sign is supported
434
- ["$10-$20", `$10${EN_DASH}$20`],
435
- ])('handles USD price range: "%s"', (input, expected) => {
436
- expect(hyphenReplace(input)).toBe(expected)
437
- })
438
-
439
- it.each([
440
- ["€5-€10", `€5${EN_DASH}€10`],
441
- ["£100-£200", `£100${EN_DASH}£200`],
442
- ["¥1000-¥2000", `¥1000${EN_DASH}¥2000`],
443
- ])('handles non-USD currency range: "%s"', (input, expected) => {
444
- expect(hyphenReplace(input)).toBe(expected)
445
- })
446
- })
447
-
448
- describe("mixed content", () => {
449
- it("handles multiple em dash transformations", () => {
450
- const input = 'The meeting -- scheduled for today -- covers important topics.'
451
- const expected = `The meeting${EM_DASH}scheduled for today${EM_DASH}covers important topics.`
452
- expect(hyphenReplace(input)).toBe(expected)
453
- })
454
-
455
- it("handles page ranges", () => {
456
- const input = 'See pages 10-20 for details.'
457
- const expected = `See pages 10${EN_DASH}20 for details.`
458
- expect(hyphenReplace(input)).toBe(expected)
459
- })
460
-
461
- it("handles em dash before URL-like text", () => {
462
- const input = 'Visit -- https://example.com'
463
- const expected = `Visit${EM_DASH}https://example.com`
464
- expect(hyphenReplace(input)).toBe(expected)
465
- })
466
-
467
- it.each([
468
- ["2-3pm", `2${EN_DASH}3pm`],
469
- ["9-10am", `9${EN_DASH}10am`],
470
- ["2-3PM", `2${EN_DASH}3PM`],
471
- ["9-10AM", `9${EN_DASH}10AM`],
472
- ])('handles time range: "%s"', (input, expected) => {
473
- expect(hyphenReplace(input)).toBe(expected)
474
- })
475
- })
476
- })
477
-
478
- describe("idempotency", () => {
479
- it.each([
480
- `word${EM_DASH}word`,
481
- `1${EN_DASH}5`,
482
- `${MINUS}10`,
483
- `January${EN_DASH}March`,
484
- ])('is idempotent for: "%s"', (input) => {
485
- expect(hyphenReplace(input)).toBe(input)
486
- expect(hyphenReplace(hyphenReplace(input))).toBe(input)
487
- })
488
- })
489
-
490
- describe("dashStyle option", () => {
491
- describe('when "american" (default)', () => {
492
- it.each([
493
- ["word - word", `word${EM_DASH}word`],
494
- ["word -- word", `word${EM_DASH}word`],
495
- ["since--as you know", `since${EM_DASH}as you know`],
496
- ])("uses unspaced em dash: %s", (input, expected) => {
497
- expect(hyphenReplace(input, { dashStyle: "american" })).toBe(expected)
498
- })
499
-
500
- it("is the default behavior", () => {
501
- expect(hyphenReplace("word - word")).toBe(`word${EM_DASH}word`)
502
- })
503
- })
504
-
505
- describe('when "british" (Oxford style)', () => {
506
- it.each([
507
- ["word - word", `word ${EN_DASH} word`],
508
- ["word -- word", `word ${EN_DASH} word`],
509
- ["since--as you know", `since ${EN_DASH} as you know`],
510
- ])("uses spaced en dash: %s", (input, expected) => {
511
- expect(hyphenReplace(input, { dashStyle: "british" })).toBe(expected)
512
- })
513
-
514
- it("still converts number ranges to en dashes", () => {
515
- expect(hyphenReplace("pages 1-5", { dashStyle: "british" })).toBe(`pages 1${EN_DASH}5`)
516
- })
517
-
518
- it("converts existing em-dashes to spaced en-dashes", () => {
519
- // Oxford style uses spaced en-dashes, so em-dashes get converted
520
- expect(hyphenReplace(`word ${EM_DASH} word`, { dashStyle: "british" })).toBe(`word ${EN_DASH} word`)
521
- })
522
-
523
- it("converts date ranges with spaced en dash", () => {
524
- expect(hyphenReplace("January-March", { dashStyle: "british" })).toBe(`January ${EN_DASH} March`)
525
- })
526
- })
527
-
528
- describe('when "none"', () => {
529
- it.each([
530
- ["word - word"],
531
- ["word -- word"],
532
- ["pages 1-5"],
533
- ["-5"],
534
- ["January-March"],
535
- ])("skips all dash transforms: %s", (input) => {
536
- expect(hyphenReplace(input, { dashStyle: "none" })).toBe(input)
537
- })
538
- })
539
-
540
- describe("style conversion consistency", () => {
541
- const testCases = [
542
- "word - word",
543
- "word -- word",
544
- "since--as you know",
545
- "Hello. -- Author",
546
- `word${EM_DASH}word`,
547
- `"Quote."${EM_DASH}Author`,
548
- ]
549
-
550
- it.each(testCases)(
551
- "American → British equals direct British: %s",
552
- (input) => {
553
- const directBritish = hyphenReplace(input, { dashStyle: "british" })
554
- const viaAmerican = hyphenReplace(
555
- hyphenReplace(input, { dashStyle: "american" }),
556
- { dashStyle: "british" }
557
- )
558
- expect(viaAmerican).toBe(directBritish)
559
- }
560
- )
561
-
562
- it.each(testCases)(
563
- "British → American equals direct American: %s",
564
- (input) => {
565
- const directAmerican = hyphenReplace(input, { dashStyle: "american" })
566
- const viaBritish = hyphenReplace(
567
- hyphenReplace(input, { dashStyle: "british" }),
568
- { dashStyle: "american" }
569
- )
570
- expect(viaBritish).toBe(directAmerican)
571
- }
572
- )
573
- })
574
- })
575
-
576
- describe("technical patterns preservation", () => {
577
- it.each([
578
- ["https://example-site.com", "https://example-site.com"],
579
- ["http://sub-domain.example.com/path-to-file", "http://sub-domain.example.com/path-to-file"],
580
- ["user-name@example.com", "user-name@example.com"],
581
- ["550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440000"],
582
- ["commit 1a2b3c4d-5e6f", "commit 1a2b3c4d-5e6f"],
583
- ])('preserves technical pattern: "%s"', (input, expected) => {
584
- expect(hyphenReplace(input)).toBe(expected)
585
- })
586
- })
587
-
588
- describe("scientific notation", () => {
589
- it.each([
590
- ["1e-10", "1e-10"],
591
- ["5.5e-3", "5.5e-3"],
592
- ["1E-5", "1E-5"],
593
- ["3.14e+10", "3.14e+10"],
594
- ])('preserves scientific notation: "%s"', (input, expected) => {
595
- expect(hyphenReplace(input)).toBe(expected)
596
- })
597
- })
598
-
599
- describe("version numbers", () => {
600
- it.each([
601
- ["v1.0.0-beta", "v1.0.0-beta"],
602
- ["1.0.0-rc.1", "1.0.0-rc.1"],
603
- ["2.0.0-alpha.1", "2.0.0-alpha.1"],
604
- ["1.0.0-beta.1-hotfix", "1.0.0-beta.1-hotfix"],
605
- ])('preserves version number: "%s"', (input, expected) => {
606
- expect(hyphenReplace(input)).toBe(expected)
607
- })
608
- })
609
-
610
- describe("edge number ranges", () => {
611
- it.each([
612
- ["1000000-2000000", `1000000${EN_DASH}2000000`],
613
- ["1.5-2.5", `1.5${EN_DASH}2.5`],
614
- ["pp. 100-200", `pp. 100${EN_DASH}200`],
615
- ["I-V", "I-V"],
616
- ["i-v", "i-v"],
617
- ["Chapter I-III", "Chapter I-III"],
618
- ])('handles number range edge case: "%s"', (input, expected) => {
619
- expect(hyphenReplace(input)).toBe(expected)
620
- })
621
- })
622
-
623
- describe("social media patterns", () => {
624
- it.each([
625
- ["#my-hashtag", "#my-hashtag"],
626
- ["@user-name", "@user-name"],
627
- ])('preserves social pattern: "%s"', (input, expected) => {
628
- expect(hyphenReplace(input)).toBe(expected)
629
- })
630
- })
631
-
632
- describe("mixed dash types", () => {
633
- it.each([
634
- [`pages 1${EN_DASH}5`, `pages 1${EN_DASH}5`],
635
- [`word${EM_DASH}word`, `word${EM_DASH}word`],
636
- [`pages 1-5 and word${EM_DASH}word`, `pages 1${EN_DASH}5 and word${EM_DASH}word`],
637
- ])('handles mixed dashes: "%s"', (input, expected) => {
638
- expect(hyphenReplace(input)).toBe(expected)
639
- })
640
- })
641
-
642
- describe("negative temperatures", () => {
643
- it.each([
644
- ["-5 to -10", `${MINUS}5 to ${MINUS}10`],
645
- ["High: 5, Low: -10", `High: 5, Low: ${MINUS}10`],
646
- ])('handles negative temperatures: "%s"', (input, expected) => {
647
- expect(hyphenReplace(input)).toBe(expected)
648
- })
649
- })
650
-
651
- describe("phone number preservation", () => {
652
- it.each([
653
- // Preserved phone patterns
654
- ["555-123-4567", "555-123-4567", "full phone with area code"],
655
- ["(555) 123-4567", "(555) 123-4567", "area code in parens"],
656
- ["1-800", "1-800", "toll-free prefix 800"],
657
- ["1-888", "1-888", "toll-free prefix 888"],
658
- ["1-877", "1-877", "toll-free prefix 877"],
659
- ["1-866", "1-866", "toll-free prefix 866"],
660
- ["1-855", "1-855", "toll-free prefix 855"],
661
- ["1-844", "1-844", "toll-free prefix 844"],
662
- ["1-833", "1-833", "toll-free prefix 833"],
663
- ["Call 1-800-...", "Call 1-800-...", "truncated toll-free"],
664
- ["1-800-555-1234", "1-800-555-1234", "full toll-free"],
665
- ["1-888-555-1234", "1-888-555-1234", "full toll-free 888"],
666
- ["+1-800-555-1234", "+1-800-555-1234", "international US"],
667
- ["+44-20-7946-0958", "+44-20-7946-0958", "international UK"],
668
- ["+1 (800) 555-1234", "+1 (800) 555-1234", "international with parens"],
669
- // International short patterns (country code + city code, no further segments)
670
- ["+44-20", "+44-20", "UK country + city code"],
671
- ["+49-30", "+49-30", "German country + city code"],
672
- ["+33-1", "+33-1", "French country + city code"],
673
- ["+61-2", "+61-2", "Australian country + city code"],
674
- ["+91-22", "+91-22", "Indian country + city code"],
675
- // Phone-shaped 3+4 bare digits preserve; thousands-grouped form converts
676
- ["555-1234", "555-1234", "standalone 3+4 looks phone, preserve"],
677
- ["555-1,234", `555${EN_DASH}1,234`, "3+4 with thousands comma converts"],
678
- ["555-1.234", `555${EN_DASH}1.234`, "3+4 with thousands period converts"],
679
- ["1-5", `1${EN_DASH}5`, "simple range"],
680
- ["1-50", `1${EN_DASH}50`, "range to two digits"],
681
- ["1-99", `1${EN_DASH}99`, "range to 99"],
682
- ["1-100", `1${EN_DASH}100`, "range to 100 (not a toll-free prefix)"],
683
- ["1-200", `1${EN_DASH}200`, "range to 200 (not a toll-free prefix)"],
684
- ["1-500", `1${EN_DASH}500`, "range to 500 (not a toll-free prefix)"],
685
- ["1-999", `1${EN_DASH}999`, "range to 999 (not a toll-free prefix)"],
686
- ["2-800", `2${EN_DASH}800`, "non-US country code pattern"],
687
- ])("%s → %s (%s)", (input, expected) => {
688
- expect(hyphenReplace(input)).toBe(expected)
689
- })
690
- })
691
-
692
- describe("minusReplace regex-special separators", () => {
693
- const regexSpecialChars = [".", "*", "+", "?", "^", "$", "[", "]", "\\", "|", "(", ")"]
694
-
695
- it.each(regexSpecialChars)("handles '%s' as separator", (sep) => {
696
- expect(() => minusReplace("-5", { separator: sep })).not.toThrow()
697
- })
698
- })
699
-
700
- const sep = DEFAULT_SEPARATOR
701
-
702
- describe("minusReplace separator boundary", () => {
703
- it.each([
704
- ["genuine negative at element start", `${sep}-5${sep}`, `${sep}${MINUS}5${sep}`],
705
- ["negative in parens across boundary", `(${sep}-5${sep})`, `(${sep}${MINUS}5${sep})`],
706
- ])("%s", (_desc, input, expected) => {
707
- expect(minusReplace(input, { separator: sep })).toBe(expected)
708
- })
709
-
710
- it.each([
711
- ["no false negative after digit+sep", `2${sep}-3`],
712
- ["no false negative after letter+sep", `GPT${sep}-3`],
713
- ["multi-segment 1-2-3 with seps", `1-${sep}2${sep}-3`],
714
- ])("%s", (_desc, input) => {
715
- expect(minusReplace(input, { separator: sep })).toBe(input)
716
- })
717
- })
718
-
719
- describe("hyphenReplace preserves multi-segment numbers across separators", () => {
720
- it.each([
721
- ["1-2-3 across elements", `1-${sep}2${sep}-3`],
722
- ["model name across elements", `${sep}GPT${sep}-3`],
723
- ])("%s", (_desc, input) => {
724
- expect(hyphenReplace(input, { separator: sep })).toBe(input)
725
- })
726
- })
727
-
728
- describe("dash stress tests", () => {
729
- it("scales linearly for parenthetical dashes", () => {
730
- assertLinearScaling(hyphenReplace, (n) => "word - word ".repeat(n))
731
- })
732
-
733
- it("scales linearly for number ranges", () => {
734
- assertLinearScaling(hyphenReplace, (n) => "pages 1-2 ".repeat(n))
735
- })
736
- })