punctilio 1.3.1 → 1.4.0

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