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.
- package/README.md +32 -25
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +5 -0
- package/dist/constants.js.map +1 -1
- package/dist/dashes.d.ts.map +1 -1
- package/dist/dashes.js +26 -8
- package/dist/dashes.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/quotes.d.ts.map +1 -1
- package/dist/quotes.js +17 -12
- package/dist/quotes.js.map +1 -1
- package/dist/rehype.d.ts +161 -0
- package/dist/rehype.d.ts.map +1 -0
- package/dist/rehype.js +401 -0
- package/dist/rehype.js.map +1 -0
- package/dist/symbols.d.ts.map +1 -1
- package/dist/symbols.js +2 -1
- package/dist/symbols.js.map +1 -1
- package/dist/utils.d.ts +5 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +19 -0
- package/dist/utils.js.map +1 -1
- package/package.json +28 -4
- package/src/constants.ts +161 -0
- package/src/dashes.ts +211 -0
- package/src/index.ts +243 -0
- package/src/quotes.ts +132 -0
- package/src/rehype.ts +503 -0
- package/src/symbols.ts +324 -0
- package/src/tests/dashes.test.ts +649 -0
- package/src/tests/index.test.ts +528 -0
- package/src/tests/quotes.test.ts +344 -0
- package/src/tests/rehype.test.ts +418 -0
- package/src/tests/symbols.test.ts +708 -0
- package/src/tests/utils.test.ts +98 -0
- package/src/utils.ts +65 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { transform, DEFAULT_SEPARATOR, countSeparators } from "../index.js"
|
|
2
|
+
import { ellipsis } from "../symbols.js"
|
|
3
|
+
import { UNICODE_SYMBOLS, REGEX_SPECIAL_CHARS } from "../constants.js"
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
LEFT_DOUBLE_QUOTE,
|
|
7
|
+
RIGHT_DOUBLE_QUOTE,
|
|
8
|
+
RIGHT_SINGLE_QUOTE,
|
|
9
|
+
EM_DASH,
|
|
10
|
+
EN_DASH,
|
|
11
|
+
ELLIPSIS,
|
|
12
|
+
MULTIPLICATION,
|
|
13
|
+
NOT_EQUAL,
|
|
14
|
+
COPYRIGHT,
|
|
15
|
+
NBSP,
|
|
16
|
+
PRIME,
|
|
17
|
+
DOUBLE_PRIME,
|
|
18
|
+
TRADEMARK,
|
|
19
|
+
REGISTERED,
|
|
20
|
+
DEGREE,
|
|
21
|
+
FRACTION_1_2,
|
|
22
|
+
SUPERSCRIPT_ST,
|
|
23
|
+
} = UNICODE_SYMBOLS
|
|
24
|
+
|
|
25
|
+
describe("transform", () => {
|
|
26
|
+
it("applies both quote and dash transformations", () => {
|
|
27
|
+
const input = '"Hello," she said - "it\'s pages 1-5."'
|
|
28
|
+
const expected = `${LEFT_DOUBLE_QUOTE}Hello,${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}it${RIGHT_SINGLE_QUOTE}s pages 1${EN_DASH}5.${RIGHT_DOUBLE_QUOTE}`
|
|
29
|
+
expect(transform(input)).toBe(expected)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("handles complex mixed content", () => {
|
|
33
|
+
const input = 'I was born in \'99 - "the best year" - and pages 10-20 are my favorite.'
|
|
34
|
+
const expected = `I was born in ${RIGHT_SINGLE_QUOTE}99${EM_DASH}${LEFT_DOUBLE_QUOTE}the best year${RIGHT_DOUBLE_QUOTE}${EM_DASH}and pages 10${EN_DASH}20 are my favorite.`
|
|
35
|
+
expect(transform(input)).toBe(expected)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("preserves separator character", () => {
|
|
39
|
+
const sep = DEFAULT_SEPARATOR
|
|
40
|
+
const input = `"Hello${sep}" - test`
|
|
41
|
+
expect(transform(input, { separator: sep })).toBe(`${LEFT_DOUBLE_QUOTE}Hello${sep}${RIGHT_DOUBLE_QUOTE}${EM_DASH}test`)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("symbol transforms", () => {
|
|
45
|
+
it("applies symbol transforms by default", () => {
|
|
46
|
+
expect(transform('Wait... 5x5 != 25 (c) 2024')).toBe(`Wait${ELLIPSIS} 5${MULTIPLICATION}5 ${NOT_EQUAL} 25 ${COPYRIGHT} 2024`)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("can disable symbol transforms", () => {
|
|
50
|
+
expect(transform('Wait... 5x5 != 25', { symbols: false })).toBe('Wait... 5x5 != 25')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("optional transforms", () => {
|
|
55
|
+
it.each([
|
|
56
|
+
["fractions disabled", "1/2", {}, "1/2"],
|
|
57
|
+
["fractions enabled", "1/2", { fractions: true }, UNICODE_SYMBOLS.FRACTION_1_2],
|
|
58
|
+
["degrees disabled", "20 C", {}, "20 C"],
|
|
59
|
+
["degrees enabled", "20 C", { degrees: true }, `20 ${UNICODE_SYMBOLS.DEGREE}C`],
|
|
60
|
+
["superscript disabled", "1st", {}, "1st"],
|
|
61
|
+
["superscript enabled", "1st", { superscript: true }, `1${UNICODE_SYMBOLS.SUPERSCRIPT_ST}`],
|
|
62
|
+
["ligatures disabled", "??", {}, "??"],
|
|
63
|
+
["ligatures enabled", "??", { ligatures: true }, UNICODE_SYMBOLS.DOUBLE_QUESTION],
|
|
64
|
+
] as const)("%s: %s → %s", (_desc, input, options, expected) => {
|
|
65
|
+
expect(transform(input, options)).toBe(expected)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("punctuationStyle option", () => {
|
|
70
|
+
const periodOutside = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}.`
|
|
71
|
+
const periodInside = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE}`
|
|
72
|
+
|
|
73
|
+
it.each([
|
|
74
|
+
['"Hello".', undefined, periodInside, "american (default)"],
|
|
75
|
+
['"Hello."', "british", periodOutside, "british"],
|
|
76
|
+
['"Hello".', "none", periodOutside, "none"],
|
|
77
|
+
] as const)("handles %s with %s style", (input, style, expected) => {
|
|
78
|
+
expect(transform(input, style ? { punctuationStyle: style } : {})).toBe(expected)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe("collapseSpaces option", () => {
|
|
83
|
+
it.each([
|
|
84
|
+
["hello world", "hello world", "multiple spaces"],
|
|
85
|
+
[`foo${NBSP}${NBSP}bar`, `foo${NBSP}bar`, "multiple nbsp"],
|
|
86
|
+
[`a ${NBSP}b`, `a${NBSP}b`, "mixed spaces prefer nbsp"],
|
|
87
|
+
[`a${NBSP} b`, `a${NBSP}b`, "mixed spaces prefer nbsp"],
|
|
88
|
+
])("collapses %s by default", (input, expected) => {
|
|
89
|
+
expect(transform(input)).toBe(expected)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
["hello world", "hello world", "multiple spaces"],
|
|
94
|
+
[`foo${NBSP}${NBSP}bar`, `foo${NBSP}${NBSP}bar`, "multiple nbsp"],
|
|
95
|
+
])("preserves %s when disabled", (input, expected) => {
|
|
96
|
+
expect(transform(input, { collapseSpaces: false })).toBe(expected)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe("separator preservation", () => {
|
|
101
|
+
it.each([
|
|
102
|
+
[`Wait.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}. for it`, 2],
|
|
103
|
+
[`"Hello${DEFAULT_SEPARATOR}" - ${DEFAULT_SEPARATOR}she${DEFAULT_SEPARATOR} said`, 3],
|
|
104
|
+
[`.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}.`, 2],
|
|
105
|
+
])('preserves %i separators in "%s"', (input, expectedCount) => {
|
|
106
|
+
expect(() => transform(input, { separator: DEFAULT_SEPARATOR })).not.toThrow()
|
|
107
|
+
expect(countSeparators(transform(input, { separator: DEFAULT_SEPARATOR }), DEFAULT_SEPARATOR)).toBe(expectedCount)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("preserves separator in ellipsis", () => {
|
|
111
|
+
const input = `.${DEFAULT_SEPARATOR}.${DEFAULT_SEPARATOR}.`
|
|
112
|
+
const result = ellipsis(input, { separator: DEFAULT_SEPARATOR })
|
|
113
|
+
expect(result.split(DEFAULT_SEPARATOR).length - 1).toBe(2)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("preserves consecutive separators", () => {
|
|
117
|
+
const input = `a${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}${DEFAULT_SEPARATOR}b`
|
|
118
|
+
expect(transform(input)).toBe(input)
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe("Unicode edge cases", () => {
|
|
123
|
+
it.each([
|
|
124
|
+
'"café"',
|
|
125
|
+
'"Hello 👋 world"',
|
|
126
|
+
'"日本語"',
|
|
127
|
+
'"שלום"',
|
|
128
|
+
"a\u200Bb",
|
|
129
|
+
])('preserves content in "%s"', (input) => {
|
|
130
|
+
expect(transform(input)).toBe(transform(input)) // idempotent
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Tests derived from competitor libraries and benchmark cases
|
|
135
|
+
describe("competitor-derived comprehensive tests", () => {
|
|
136
|
+
// These tests document where punctilio excels over competitors
|
|
137
|
+
describe("punctilio advantages (benchmark cases)", () => {
|
|
138
|
+
it.each([
|
|
139
|
+
// Leading apostrophes - smartypants/tipograph fail these
|
|
140
|
+
["'SUP", `${RIGHT_SINGLE_QUOTE}SUP`],
|
|
141
|
+
["Rock 'n' Roll", `Rock ${RIGHT_SINGLE_QUOTE}n${RIGHT_SINGLE_QUOTE} Roll`],
|
|
142
|
+
["I was born in '99", `I was born in ${RIGHT_SINGLE_QUOTE}99`],
|
|
143
|
+
// Em dashes from surrounded hyphens - smartquotes fails
|
|
144
|
+
["This is a - hyphen.", `This is a${EM_DASH}hyphen.`],
|
|
145
|
+
// Number ranges - smartypants/smartquotes/typograf fail
|
|
146
|
+
["Pages 1-5", `Pages 1${EN_DASH}5`],
|
|
147
|
+
["2000-2020", `2000${EN_DASH}2020`],
|
|
148
|
+
// Date ranges - all competitors fail
|
|
149
|
+
["January-March", `January${EN_DASH}March`],
|
|
150
|
+
// Minus signs - most competitors fail
|
|
151
|
+
["-5", `−5`],
|
|
152
|
+
["(-5)", `(−5)`],
|
|
153
|
+
// Prime marks - smartypants/typograf fail
|
|
154
|
+
['5\'10"', `5′10″`],
|
|
155
|
+
['He is 6\'2" tall', `He is 6′2″ tall`],
|
|
156
|
+
])('correctly transforms: "%s"', (input, expected) => {
|
|
157
|
+
expect(transform(input, { symbols: true, degrees: true })).toBe(expected)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Compound word preservation (all libraries should preserve these)
|
|
162
|
+
describe("compound word preservation", () => {
|
|
163
|
+
it.each([
|
|
164
|
+
"well-known",
|
|
165
|
+
"a browser- or OS-specific fashion",
|
|
166
|
+
"re-read",
|
|
167
|
+
"self-aware",
|
|
168
|
+
"high-quality",
|
|
169
|
+
])('preserves compound word: "%s"', (input) => {
|
|
170
|
+
expect(transform(input)).toBe(input)
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// Model name preservation (punctilio's unique strength)
|
|
175
|
+
describe("model name preservation", () => {
|
|
176
|
+
it.each([
|
|
177
|
+
"Llama-2-7B",
|
|
178
|
+
"Llama-3-8B-Instruct",
|
|
179
|
+
"GPT-4",
|
|
180
|
+
"Claude-3-Opus",
|
|
181
|
+
"Qwen1.5-1.8B",
|
|
182
|
+
])('preserves model name: "%s"', (input) => {
|
|
183
|
+
expect(transform(input)).toBe(input)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe("complex real-world text", () => {
|
|
189
|
+
it("handles dialogue with dashes and quotes", () => {
|
|
190
|
+
const input = '"Wait," she said -- "I don\'t think that\'s right."'
|
|
191
|
+
const expected = `${LEFT_DOUBLE_QUOTE}Wait,${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}I don${RIGHT_SINGLE_QUOTE}t think that${RIGHT_SINGLE_QUOTE}s right.${RIGHT_DOUBLE_QUOTE}`
|
|
192
|
+
expect(transform(input)).toEqual(expected)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("handles technical documentation", () => {
|
|
196
|
+
const input = 'The API returns x != y when a <= b and c >= d. Error tolerance: +-5%.'
|
|
197
|
+
const expected = `The API returns x ${NOT_EQUAL} y when a ≤ b and c ≥ d. Error tolerance: ±5%.`
|
|
198
|
+
expect(transform(input)).toBe(expected)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it("handles measurement text", () => {
|
|
202
|
+
const input = 'He is 6\'2" tall.'
|
|
203
|
+
const expected = `He is 6${PRIME}2${DOUBLE_PRIME} tall.`
|
|
204
|
+
expect(transform(input, { symbols: true })).toEqual(expected)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("handles copyright notices", () => {
|
|
208
|
+
const input = '(c) 2024 Company(tm). All rights reserved(r).'
|
|
209
|
+
const expected = `${COPYRIGHT} 2024 Company${TRADEMARK}. All rights reserved${REGISTERED}.`
|
|
210
|
+
expect(transform(input)).toEqual(expected)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe("known limitations", () => {
|
|
215
|
+
// These document current behavior for edge cases
|
|
216
|
+
it("documents prime mark vs closing quote ambiguity", () => {
|
|
217
|
+
// When there's an open quote before a number, the quote after should close
|
|
218
|
+
// not become a prime mark
|
|
219
|
+
const input = '"Number 5"'
|
|
220
|
+
const result = transform(input)
|
|
221
|
+
// The 5" should be a closing quote, not a prime
|
|
222
|
+
expect(result).toBe(`${LEFT_DOUBLE_QUOTE}Number 5${RIGHT_DOUBLE_QUOTE}`)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe("complex combined transformations", () => {
|
|
227
|
+
it.each([
|
|
228
|
+
[
|
|
229
|
+
'"Wait..." she said - "it\'s pages 1-5."',
|
|
230
|
+
`${LEFT_DOUBLE_QUOTE}Wait…${RIGHT_DOUBLE_QUOTE} she said${EM_DASH}${LEFT_DOUBLE_QUOTE}it${RIGHT_SINGLE_QUOTE}s pages 1–5.${RIGHT_DOUBLE_QUOTE}`,
|
|
231
|
+
],
|
|
232
|
+
[
|
|
233
|
+
"(c) 2024 - Room is 10x12, set to 72 F",
|
|
234
|
+
`${COPYRIGHT} 2024${EM_DASH}Room is 10×12, set to 72 °F`,
|
|
235
|
+
],
|
|
236
|
+
])('handles complex transform: "%s"', (input, expected) => {
|
|
237
|
+
expect(transform(input, { degrees: true })).toBe(expected)
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
describe("empty and whitespace inputs", () => {
|
|
242
|
+
it.each([
|
|
243
|
+
["", ""],
|
|
244
|
+
[" ", " "],
|
|
245
|
+
["\n", "\n"],
|
|
246
|
+
["\t", "\t"],
|
|
247
|
+
[" ", " "],
|
|
248
|
+
])('handles empty/whitespace: "%s"', (input, expected) => {
|
|
249
|
+
expect(transform(input)).toBe(expected)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe("very long inputs", () => {
|
|
254
|
+
it("handles long repeated patterns", () => {
|
|
255
|
+
const input = '"Hello" '.repeat(100)
|
|
256
|
+
const expected = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE} `.repeat(100)
|
|
257
|
+
expect(transform(input)).toEqual(expected)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("handles long continuous text", () => {
|
|
261
|
+
const input = "word ".repeat(1000)
|
|
262
|
+
const result = transform(input)
|
|
263
|
+
expect(result).toBe(input.trim() + " ")
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it("converts ALL quote pairs in repeated pattern", () => {
|
|
267
|
+
const count = 50
|
|
268
|
+
const input = '"Hello" '.repeat(count)
|
|
269
|
+
const result = transform(input)
|
|
270
|
+
const leftCount = (result.match(new RegExp(LEFT_DOUBLE_QUOTE, "g")) || []).length
|
|
271
|
+
const rightCount = (result.match(new RegExp(RIGHT_DOUBLE_QUOTE, "g")) || []).length
|
|
272
|
+
expect(leftCount).toBe(count)
|
|
273
|
+
expect(rightCount).toBe(count)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it("converts ALL dashes in repeated pattern", () => {
|
|
277
|
+
const count = 50
|
|
278
|
+
const input = "pages 1-5 ".repeat(count)
|
|
279
|
+
const result = transform(input)
|
|
280
|
+
const enDashCount = (result.match(/–/g) || []).length
|
|
281
|
+
expect(enDashCount).toBe(count)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("converts ALL ellipses in repeated pattern", () => {
|
|
285
|
+
const count = 50
|
|
286
|
+
const input = "Wait... ".repeat(count)
|
|
287
|
+
const result = transform(input)
|
|
288
|
+
const ellipsisCount = (result.match(/…/g) || []).length
|
|
289
|
+
expect(ellipsisCount).toBe(count)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
describe("Unicode edge cases", () => {
|
|
294
|
+
it.each([
|
|
295
|
+
['"test\u200Bword"', `${LEFT_DOUBLE_QUOTE}test\u200Bword${RIGHT_DOUBLE_QUOTE}`],
|
|
296
|
+
['"test\u200Fword"', `${LEFT_DOUBLE_QUOTE}test\u200Fword${RIGHT_DOUBLE_QUOTE}`],
|
|
297
|
+
['"caf\u0065\u0301"', `${LEFT_DOUBLE_QUOTE}caf\u0065\u0301${RIGHT_DOUBLE_QUOTE}`],
|
|
298
|
+
])('handles Unicode edge case', (input, expected) => {
|
|
299
|
+
expect(transform(input)).toBe(expected)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe("option combinations", () => {
|
|
304
|
+
it("applies all optional transforms together", () => {
|
|
305
|
+
const input = "1st place: 1/2 at 72 F - wow!!"
|
|
306
|
+
const expected = `1${SUPERSCRIPT_ST} place: ${FRACTION_1_2} at 72 ${DEGREE}F${EM_DASH}wow!`
|
|
307
|
+
expect(transform(input, {
|
|
308
|
+
fractions: true,
|
|
309
|
+
degrees: true,
|
|
310
|
+
superscript: true,
|
|
311
|
+
ligatures: true,
|
|
312
|
+
})).toEqual(expected)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it("respects disabled transforms", () => {
|
|
316
|
+
const input = "1/2 at 72 F"
|
|
317
|
+
const expected = `${FRACTION_1_2} at 72 F`
|
|
318
|
+
expect(transform(input, {
|
|
319
|
+
fractions: true,
|
|
320
|
+
degrees: false,
|
|
321
|
+
})).toEqual(expected)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe("separator robustness", () => {
|
|
326
|
+
const sep = DEFAULT_SEPARATOR
|
|
327
|
+
|
|
328
|
+
it.each([
|
|
329
|
+
[`${sep}"test"${sep}`, `${sep}${LEFT_DOUBLE_QUOTE}test${RIGHT_DOUBLE_QUOTE}${sep}`],
|
|
330
|
+
[`"test${sep}word"`, `${LEFT_DOUBLE_QUOTE}test${sep}word${RIGHT_DOUBLE_QUOTE}`],
|
|
331
|
+
[`${sep}${sep}"test"${sep}${sep}`, `${sep}${sep}${LEFT_DOUBLE_QUOTE}test${RIGHT_DOUBLE_QUOTE}${sep}${sep}`],
|
|
332
|
+
])('handles separator in quotes', (input, expected) => {
|
|
333
|
+
expect(transform(input, { separator: sep })).toBe(expected)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it.each([
|
|
337
|
+
[`word${sep} - ${sep}word`, `word${sep}${EM_DASH}${sep}word`],
|
|
338
|
+
[`1${sep}-${sep}5`, `1${sep}–${sep}5`],
|
|
339
|
+
])('handles separator in dashes', (input, expected) => {
|
|
340
|
+
expect(transform(input, { separator: sep })).toBe(expected)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it("preserves exact separator count through transform", () => {
|
|
344
|
+
const input = `${sep}text${sep}more${sep}text${sep}`
|
|
345
|
+
const result = transform(input, { separator: sep })
|
|
346
|
+
const inputCount = (input.match(new RegExp(sep, "g")) || []).length
|
|
347
|
+
const resultCount = (result.match(new RegExp(sep, "g")) || []).length
|
|
348
|
+
expect(resultCount).toBe(inputCount)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("handles input containing separator character", () => {
|
|
352
|
+
const input = `Text with ${sep} in it`
|
|
353
|
+
expect(() => transform(input, { separator: sep })).not.toThrow()
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
describe("additional idempotency", () => {
|
|
358
|
+
it.each([
|
|
359
|
+
'"Hello" - she said, "it\'s pages 1-5..."',
|
|
360
|
+
'Product(tm) 5x5 at 72 F (c) 2024',
|
|
361
|
+
"Add 1/2 cup - that's about 3/4 done",
|
|
362
|
+
'1st place winner said "congrats!!"',
|
|
363
|
+
])('is idempotent: "%s"', (input) => {
|
|
364
|
+
const first = transform(input, { fractions: true, degrees: true, superscript: true, ligatures: true })
|
|
365
|
+
const second = transform(first, { fractions: true, degrees: true, superscript: true, ligatures: true })
|
|
366
|
+
expect(second).toBe(first)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it.each([
|
|
370
|
+
`${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`,
|
|
371
|
+
`word${EM_DASH}word`,
|
|
372
|
+
`1–5`,
|
|
373
|
+
`Wait…`,
|
|
374
|
+
`5×5`,
|
|
375
|
+
`20 °C`,
|
|
376
|
+
`${COPYRIGHT} 2024`,
|
|
377
|
+
])('stable for: "%s"', (input) => {
|
|
378
|
+
expect(transform(input)).toBe(input)
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe("style combinations", () => {
|
|
383
|
+
it("applies American conventions throughout", () => {
|
|
384
|
+
const input = '"Hello." - word - "World."'
|
|
385
|
+
const expected = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE}${EM_DASH}word${EM_DASH}${LEFT_DOUBLE_QUOTE}World.${RIGHT_DOUBLE_QUOTE}`
|
|
386
|
+
expect(transform(input, { punctuationStyle: "american", dashStyle: "american" })).toEqual(expected)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it("applies British conventions throughout", () => {
|
|
390
|
+
const input = '"Hello." - word - "World."'
|
|
391
|
+
const expected = `${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}. ${EN_DASH} word ${EN_DASH} ${LEFT_DOUBLE_QUOTE}World${RIGHT_DOUBLE_QUOTE}.`
|
|
392
|
+
expect(transform(input, { punctuationStyle: "british", dashStyle: "british" })).toEqual(expected)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it("applies American punctuation with British dashes", () => {
|
|
396
|
+
const input = '"Hello." - word'
|
|
397
|
+
const expected = `${LEFT_DOUBLE_QUOTE}Hello.${RIGHT_DOUBLE_QUOTE} ${EN_DASH} word`
|
|
398
|
+
expect(transform(input, { punctuationStyle: "american", dashStyle: "british" })).toEqual(expected)
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
describe("regex performance", () => {
|
|
403
|
+
it("handles pathological quote patterns efficiently", () => {
|
|
404
|
+
const input = '"a"'.repeat(50) + "'"
|
|
405
|
+
const start = Date.now()
|
|
406
|
+
transform(input)
|
|
407
|
+
const duration = Date.now() - start
|
|
408
|
+
expect(duration).toBeLessThan(1000)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it("handles pathological dash patterns efficiently", () => {
|
|
412
|
+
const input = "1-2-3-4-5-6-7-8-9-10-11-12-13-14-15"
|
|
413
|
+
const start = Date.now()
|
|
414
|
+
transform(input)
|
|
415
|
+
const duration = Date.now() - start
|
|
416
|
+
expect(duration).toBeLessThan(1000)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it("handles 1000 unbalanced single quotes efficiently", () => {
|
|
420
|
+
const input = "'".repeat(1000) + "a"
|
|
421
|
+
const start = performance.now()
|
|
422
|
+
transform(input)
|
|
423
|
+
const duration = performance.now() - start
|
|
424
|
+
expect(duration).toBeLessThan(500)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it("handles 500 chained number patterns efficiently", () => {
|
|
428
|
+
const input = "1" + "-1".repeat(500)
|
|
429
|
+
const start = performance.now()
|
|
430
|
+
transform(input)
|
|
431
|
+
const duration = performance.now() - start
|
|
432
|
+
expect(duration).toBeLessThan(500)
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
describe("special regex characters in input", () => {
|
|
437
|
+
it.each([
|
|
438
|
+
['"test [bracket]"', `${LEFT_DOUBLE_QUOTE}test [bracket]${RIGHT_DOUBLE_QUOTE}`],
|
|
439
|
+
['"test (paren)"', `${LEFT_DOUBLE_QUOTE}test (paren)${RIGHT_DOUBLE_QUOTE}`],
|
|
440
|
+
['"test {brace}"', `${LEFT_DOUBLE_QUOTE}test {brace}${RIGHT_DOUBLE_QUOTE}`],
|
|
441
|
+
['"test $dollar"', `${LEFT_DOUBLE_QUOTE}test $dollar${RIGHT_DOUBLE_QUOTE}`],
|
|
442
|
+
['"test ^caret"', `${LEFT_DOUBLE_QUOTE}test ^caret${RIGHT_DOUBLE_QUOTE}`],
|
|
443
|
+
['"test .dot"', `${LEFT_DOUBLE_QUOTE}test .dot${RIGHT_DOUBLE_QUOTE}`],
|
|
444
|
+
['"test |pipe"', `${LEFT_DOUBLE_QUOTE}test |pipe${RIGHT_DOUBLE_QUOTE}`],
|
|
445
|
+
])('handles regex special chars: "%s"', (input, expected) => {
|
|
446
|
+
expect(transform(input)).toBe(expected)
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
describe("boundary conditions", () => {
|
|
451
|
+
it.each([
|
|
452
|
+
['"start', `${LEFT_DOUBLE_QUOTE}start`],
|
|
453
|
+
["'start", `${RIGHT_SINGLE_QUOTE}start`],
|
|
454
|
+
["-5 start", `−5 start`],
|
|
455
|
+
["...start", `… start`],
|
|
456
|
+
['end"', `end${RIGHT_DOUBLE_QUOTE}`],
|
|
457
|
+
["end'", `end${RIGHT_SINGLE_QUOTE}`],
|
|
458
|
+
["end...", `end…`],
|
|
459
|
+
["5x", `5×`],
|
|
460
|
+
])('handles boundary: "%s"', (input, expected) => {
|
|
461
|
+
expect(transform(input)).toBe(expected)
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
describe("separator validation", () => {
|
|
466
|
+
const invalidSeparators = [
|
|
467
|
+
["🎉", "emoji (multi-codepoint)"],
|
|
468
|
+
["ab", "multi-character"],
|
|
469
|
+
["", "empty string"],
|
|
470
|
+
] as const
|
|
471
|
+
|
|
472
|
+
it.each(invalidSeparators)("rejects %s separator (%s)", (sep) => {
|
|
473
|
+
expect(() => transform('"Hello"', { separator: sep })).toThrow(
|
|
474
|
+
/Invalid separator.*must be a single character/
|
|
475
|
+
)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
const validSeparators = ["\uE000", "|", "\u2603"] // Default, pipe, snowman
|
|
479
|
+
|
|
480
|
+
it.each(validSeparators)("accepts '%s' as separator", (sep) => {
|
|
481
|
+
expect(() => transform('"Hello"', { separator: sep })).not.toThrow()
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
describe("regex-special separator characters", () => {
|
|
486
|
+
it.each(REGEX_SPECIAL_CHARS)("handles '%s' as separator", (sep) => {
|
|
487
|
+
expect(transform('"Hello"', { separator: sep })).toEqual(
|
|
488
|
+
`${LEFT_DOUBLE_QUOTE}Hello${RIGHT_DOUBLE_QUOTE}`
|
|
489
|
+
)
|
|
490
|
+
})
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
describe("Unicode preservation", () => {
|
|
494
|
+
it.each([
|
|
495
|
+
["emoji", '"Hello 👋 World"', `${LEFT_DOUBLE_QUOTE}Hello 👋 World${RIGHT_DOUBLE_QUOTE}`],
|
|
496
|
+
["RTL text", '"שלום"', `${LEFT_DOUBLE_QUOTE}שלום${RIGHT_DOUBLE_QUOTE}`],
|
|
497
|
+
["zero-width space", '"Hello\u200BWorld"', `${LEFT_DOUBLE_QUOTE}Hello\u200BWorld${RIGHT_DOUBLE_QUOTE}`],
|
|
498
|
+
] as const)("preserves %s in input", (_, input, expected) => {
|
|
499
|
+
expect(transform(input)).toEqual(expected)
|
|
500
|
+
})
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
describe("large input handling", () => {
|
|
504
|
+
it("handles 1MB of text", () => {
|
|
505
|
+
const input = "Hello, world! ".repeat(70000)
|
|
506
|
+
const start = performance.now()
|
|
507
|
+
transform(input)
|
|
508
|
+
expect(performance.now() - start).toBeLessThan(2000)
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
describe("ReDoS resistance", () => {
|
|
513
|
+
const MAX_TIMEOUT_MS = 100
|
|
514
|
+
|
|
515
|
+
it.each([
|
|
516
|
+
["10000 single quotes", "'".repeat(10000)],
|
|
517
|
+
["10000 double quotes", '"'.repeat(10000)],
|
|
518
|
+
["1000 unbalanced quotes", '"Hello '.repeat(1000)],
|
|
519
|
+
["10000 hyphens", "-".repeat(10000)],
|
|
520
|
+
["alternating digits/hyphens", "1-2-3-4-5-6-7-8-9-0".repeat(500)],
|
|
521
|
+
["10000 dots", ".".repeat(10000)],
|
|
522
|
+
] as const)("handles %s", (_, input) => {
|
|
523
|
+
const start = performance.now()
|
|
524
|
+
transform(input)
|
|
525
|
+
expect(performance.now() - start).toBeLessThan(MAX_TIMEOUT_MS)
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
})
|