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