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