expo-pretext 0.2.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/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/android/src/main/java/expo/modules/pretext/ExpoPretextModule.kt +354 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoPretext.podspec +20 -0
- package/ios/ExpoPretext.swift +444 -0
- package/package.json +59 -0
- package/src/ExpoPretext.ts +70 -0
- package/src/__tests__/cache.test.ts +71 -0
- package/src/__tests__/font-utils.test.ts +69 -0
- package/src/__tests__/layout.test.ts +300 -0
- package/src/__tests__/obstacle-layout.test.ts +127 -0
- package/src/__tests__/setup-mocks.ts +14 -0
- package/src/analysis.ts +1208 -0
- package/src/bidi.ts +175 -0
- package/src/build.ts +503 -0
- package/src/cache.ts +59 -0
- package/src/engine-profile.ts +38 -0
- package/src/font-utils.ts +50 -0
- package/src/generated/bidi-data.ts +998 -0
- package/src/hooks/useFlashListHeights.ts +88 -0
- package/src/hooks/usePreparedText.ts +16 -0
- package/src/hooks/useTextHeight.ts +45 -0
- package/src/index.ts +56 -0
- package/src/layout.ts +353 -0
- package/src/line-break.ts +1113 -0
- package/src/obstacle-layout.ts +193 -0
- package/src/prepare.ts +246 -0
- package/src/rich-inline.ts +647 -0
- package/src/streaming.ts +61 -0
- package/src/types.ts +104 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
textStyleToFontDescriptor,
|
|
4
|
+
getFontKey,
|
|
5
|
+
getLineHeight,
|
|
6
|
+
} from '../font-utils'
|
|
7
|
+
import type { TextStyle } from '../types'
|
|
8
|
+
|
|
9
|
+
describe('font-utils', () => {
|
|
10
|
+
const baseStyle: TextStyle = {
|
|
11
|
+
fontFamily: 'Inter',
|
|
12
|
+
fontSize: 16,
|
|
13
|
+
lineHeight: 24,
|
|
14
|
+
fontWeight: '700',
|
|
15
|
+
fontStyle: 'italic',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('textStyleToFontDescriptor', () => {
|
|
19
|
+
test('converts full style', () => {
|
|
20
|
+
const desc = textStyleToFontDescriptor(baseStyle)
|
|
21
|
+
expect(desc).toEqual({
|
|
22
|
+
fontFamily: 'Inter',
|
|
23
|
+
fontSize: 16,
|
|
24
|
+
fontWeight: '700',
|
|
25
|
+
fontStyle: 'italic',
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('handles minimal style', () => {
|
|
30
|
+
const desc = textStyleToFontDescriptor({ fontFamily: 'Arial', fontSize: 14 })
|
|
31
|
+
expect(desc).toEqual({
|
|
32
|
+
fontFamily: 'Arial',
|
|
33
|
+
fontSize: 14,
|
|
34
|
+
fontWeight: undefined,
|
|
35
|
+
fontStyle: undefined,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('getFontKey', () => {
|
|
41
|
+
test('full style produces correct key', () => {
|
|
42
|
+
expect(getFontKey(baseStyle)).toBe('Inter_16_700_italic')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('minimal style uses defaults', () => {
|
|
46
|
+
expect(getFontKey({ fontFamily: 'Arial', fontSize: 14 })).toBe('Arial_14_400_normal')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('different weights produce different keys', () => {
|
|
50
|
+
const light = getFontKey({ fontFamily: 'Inter', fontSize: 16, fontWeight: '400' })
|
|
51
|
+
const bold = getFontKey({ fontFamily: 'Inter', fontSize: 16, fontWeight: '700' })
|
|
52
|
+
expect(light).not.toBe(bold)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('getLineHeight', () => {
|
|
57
|
+
test('returns explicit lineHeight', () => {
|
|
58
|
+
expect(getLineHeight(baseStyle)).toBe(24)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('falls back to fontSize * 1.2', () => {
|
|
62
|
+
expect(getLineHeight({ fontFamily: 'Inter', fontSize: 20 })).toBe(24)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('falls back correctly for small font', () => {
|
|
66
|
+
expect(getLineHeight({ fontFamily: 'Inter', fontSize: 10 })).toBe(12)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// layout.test.ts
|
|
2
|
+
// Tests for the core layout engine: layout(), layoutWithLines(),
|
|
3
|
+
// walkLineRanges(), measureNaturalWidth(), and the prepare() fallback path.
|
|
4
|
+
//
|
|
5
|
+
// We bypass prepare.ts (which imports ExpoPretext → expo-modules-core →
|
|
6
|
+
// react-native) and instead directly wire the same estimateSegments fallback
|
|
7
|
+
// that prepare() uses when the native module is unavailable.
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from 'bun:test'
|
|
10
|
+
import { buildPreparedText, buildPreparedTextWithSegments } from '../build'
|
|
11
|
+
import {
|
|
12
|
+
layout,
|
|
13
|
+
layoutWithLines,
|
|
14
|
+
walkLineRanges,
|
|
15
|
+
measureNaturalWidth,
|
|
16
|
+
} from '../layout'
|
|
17
|
+
import { analyzeText } from '../analysis'
|
|
18
|
+
import type { TextStyle, NativeSegmentResult } from '../types'
|
|
19
|
+
|
|
20
|
+
const STYLE: TextStyle = { fontFamily: 'System', fontSize: 16, lineHeight: 24 }
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Helpers — mirrors prepare.ts's fallback when native module is unavailable
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
// Same logic as estimateSegments() in prepare.ts
|
|
27
|
+
function estimateSegments(text: string, style: TextStyle): NativeSegmentResult {
|
|
28
|
+
const words = text.split(/(\s+)/)
|
|
29
|
+
const charWidth = style.fontSize * 0.55
|
|
30
|
+
return {
|
|
31
|
+
segments: words,
|
|
32
|
+
isWordLike: words.map(w => !/^\s+$/.test(w)),
|
|
33
|
+
widths: words.map(w => w.length * charWidth),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildWidthMap(result: NativeSegmentResult): Map<string, number> {
|
|
38
|
+
const map = new Map<string, number>()
|
|
39
|
+
for (let i = 0; i < result.segments.length; i++) {
|
|
40
|
+
map.set(result.segments[i]!, result.widths[i]!)
|
|
41
|
+
}
|
|
42
|
+
return map
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function makePrepared(text: string, style: TextStyle = STYLE) {
|
|
46
|
+
if (!text) {
|
|
47
|
+
const analysis = analyzeText([], [], {}, undefined)
|
|
48
|
+
return buildPreparedText(analysis, new Map(), style)
|
|
49
|
+
}
|
|
50
|
+
const result = estimateSegments(text, style)
|
|
51
|
+
const analysis = analyzeText(result.segments, result.isWordLike, {}, undefined)
|
|
52
|
+
return buildPreparedText(analysis, buildWidthMap(result), style)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makePreparedWithSegments(text: string, style: TextStyle = STYLE) {
|
|
56
|
+
if (!text) {
|
|
57
|
+
const analysis = analyzeText([], [], {}, undefined)
|
|
58
|
+
return buildPreparedTextWithSegments(analysis, new Map(), style)
|
|
59
|
+
}
|
|
60
|
+
const result = estimateSegments(text, style)
|
|
61
|
+
const analysis = analyzeText(result.segments, result.isWordLike, {}, undefined)
|
|
62
|
+
return buildPreparedTextWithSegments(analysis, buildWidthMap(result), style)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// layout() basics
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('layout() basics', () => {
|
|
70
|
+
test('empty string returns height = 0 and lineCount = 0', () => {
|
|
71
|
+
const p = makePrepared('')
|
|
72
|
+
const result = layout(p, 300)
|
|
73
|
+
expect(result.height).toBe(0)
|
|
74
|
+
expect(result.lineCount).toBe(0)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('single short word: 1 line, height = lineHeight', () => {
|
|
78
|
+
// "Hello" = 5 chars * 8.8px = 44px → fits in 300px → 1 line
|
|
79
|
+
const p = makePrepared('Hello')
|
|
80
|
+
const result = layout(p, 300)
|
|
81
|
+
expect(result.lineCount).toBe(1)
|
|
82
|
+
expect(result.height).toBe(24)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('text that wraps at narrow width: lineCount > 1', () => {
|
|
86
|
+
// "Hello World Test" = 16 chars * 8.8px = 140.8px → wraps at 100px
|
|
87
|
+
const p = makePrepared('Hello World Test')
|
|
88
|
+
const result = layout(p, 100)
|
|
89
|
+
expect(result.lineCount).toBeGreaterThan(1)
|
|
90
|
+
expect(result.height).toBeGreaterThan(24)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('wider maxWidth produces fewer or equal lines and less or equal height', () => {
|
|
94
|
+
const p = makePrepared('Hello World Test')
|
|
95
|
+
const narrow = layout(p, 100)
|
|
96
|
+
const wide = layout(p, 300)
|
|
97
|
+
expect(wide.lineCount).toBeLessThanOrEqual(narrow.lineCount)
|
|
98
|
+
expect(wide.height).toBeLessThanOrEqual(narrow.height)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('height equals lineCount * lineHeight', () => {
|
|
102
|
+
const p = makePrepared('Hello World Test Long Line')
|
|
103
|
+
const result = layout(p, 100)
|
|
104
|
+
expect(result.height).toBe(result.lineCount * STYLE.lineHeight!)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('zero width handled gracefully — does not throw', () => {
|
|
108
|
+
const p = makePrepared('Hello')
|
|
109
|
+
expect(() => layout(p, 0)).not.toThrow()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('negative width handled gracefully — does not throw', () => {
|
|
113
|
+
const p = makePrepared('Hello')
|
|
114
|
+
expect(() => layout(p, -100)).not.toThrow()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// layoutWithLines()
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
describe('layoutWithLines()', () => {
|
|
123
|
+
test('empty string returns empty lines array, height = 0', () => {
|
|
124
|
+
const p = makePreparedWithSegments('')
|
|
125
|
+
const result = layoutWithLines(p, 300)
|
|
126
|
+
expect(result.lines).toEqual([])
|
|
127
|
+
expect(result.lineCount).toBe(0)
|
|
128
|
+
expect(result.height).toBe(0)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('single word: 1 line with text', () => {
|
|
132
|
+
const p = makePreparedWithSegments('Hello')
|
|
133
|
+
const result = layoutWithLines(p, 300)
|
|
134
|
+
expect(result.lines.length).toBe(1)
|
|
135
|
+
expect(typeof result.lines[0]!.text).toBe('string')
|
|
136
|
+
expect(result.lines[0]!.text.length).toBeGreaterThan(0)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('returns lines with start/end cursor objects', () => {
|
|
140
|
+
const p = makePreparedWithSegments('Hello')
|
|
141
|
+
const result = layoutWithLines(p, 300)
|
|
142
|
+
const line = result.lines[0]!
|
|
143
|
+
expect(line).toHaveProperty('start')
|
|
144
|
+
expect(line).toHaveProperty('end')
|
|
145
|
+
expect(line.start).toHaveProperty('segmentIndex')
|
|
146
|
+
expect(line.start).toHaveProperty('graphemeIndex')
|
|
147
|
+
expect(line.end).toHaveProperty('segmentIndex')
|
|
148
|
+
expect(line.end).toHaveProperty('graphemeIndex')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('lineCount matches lines.length', () => {
|
|
152
|
+
const p = makePreparedWithSegments('Hello World Test')
|
|
153
|
+
const result = layoutWithLines(p, 100)
|
|
154
|
+
expect(result.lineCount).toBe(result.lines.length)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('height matches layout() result', () => {
|
|
158
|
+
const text = 'Hello World Test'
|
|
159
|
+
const p1 = makePrepared(text)
|
|
160
|
+
const p2 = makePreparedWithSegments(text)
|
|
161
|
+
const r1 = layout(p1, 100)
|
|
162
|
+
const r2 = layoutWithLines(p2, 100)
|
|
163
|
+
expect(r2.height).toBe(r1.height)
|
|
164
|
+
expect(r2.lineCount).toBe(r1.lineCount)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('each line has a non-negative width', () => {
|
|
168
|
+
const p = makePreparedWithSegments('Hello World Test')
|
|
169
|
+
const result = layoutWithLines(p, 100)
|
|
170
|
+
for (const line of result.lines) {
|
|
171
|
+
expect(line.width).toBeGreaterThanOrEqual(0)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// walkLineRanges()
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
describe('walkLineRanges()', () => {
|
|
181
|
+
test('empty string: callback never called, returns 0', () => {
|
|
182
|
+
const p = makePreparedWithSegments('')
|
|
183
|
+
let calls = 0
|
|
184
|
+
const total = walkLineRanges(p, 300, () => { calls++ })
|
|
185
|
+
expect(calls).toBe(0)
|
|
186
|
+
expect(total).toBe(0)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('callback called once per line', () => {
|
|
190
|
+
const p = makePreparedWithSegments('Hello World Test')
|
|
191
|
+
const p2 = makePrepared('Hello World Test')
|
|
192
|
+
const lines: unknown[] = []
|
|
193
|
+
walkLineRanges(p, 100, line => lines.push(line))
|
|
194
|
+
const { lineCount } = layout(p2, 100)
|
|
195
|
+
expect(lines.length).toBe(lineCount)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('each line range has start, end, and width properties', () => {
|
|
199
|
+
const p = makePreparedWithSegments('Hello World')
|
|
200
|
+
walkLineRanges(p, 300, line => {
|
|
201
|
+
expect(line).toHaveProperty('start')
|
|
202
|
+
expect(line).toHaveProperty('end')
|
|
203
|
+
expect(line).toHaveProperty('width')
|
|
204
|
+
expect(line.width).toBeGreaterThanOrEqual(0)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('total callback count matches layout() lineCount', () => {
|
|
209
|
+
const text = 'Hello World Test Long String'
|
|
210
|
+
const p = makePreparedWithSegments(text)
|
|
211
|
+
const p2 = makePrepared(text)
|
|
212
|
+
let count = 0
|
|
213
|
+
walkLineRanges(p, 100, () => { count++ })
|
|
214
|
+
expect(count).toBe(layout(p2, 100).lineCount)
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// measureNaturalWidth()
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
describe('measureNaturalWidth()', () => {
|
|
223
|
+
test('returns 0 for empty string', () => {
|
|
224
|
+
const p = makePreparedWithSegments('')
|
|
225
|
+
expect(measureNaturalWidth(p)).toBe(0)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('returns positive number for non-empty text', () => {
|
|
229
|
+
const p = makePreparedWithSegments('Hello')
|
|
230
|
+
expect(measureNaturalWidth(p)).toBeGreaterThan(0)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('"Hello" (5 chars * 8.8px) natural width ≈ 44px — within reasonable bounds', () => {
|
|
234
|
+
const p = makePreparedWithSegments('Hello')
|
|
235
|
+
const w = measureNaturalWidth(p)
|
|
236
|
+
// estimator: 5 * 8.8 = 44
|
|
237
|
+
expect(w).toBeGreaterThan(0)
|
|
238
|
+
expect(w).toBeLessThan(200)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('longer single-word text has greater natural width than shorter', () => {
|
|
242
|
+
const short = makePreparedWithSegments('Hi')
|
|
243
|
+
const long = makePreparedWithSegments('Hello')
|
|
244
|
+
expect(measureNaturalWidth(long)).toBeGreaterThan(measureNaturalWidth(short))
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// prepare() fallback path — edge cases via estimateSegments
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
describe('prepare() fallback edge cases', () => {
|
|
253
|
+
test('empty string — height = 0', () => {
|
|
254
|
+
const p = makePrepared('')
|
|
255
|
+
expect(layout(p, 300).height).toBe(0)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('single character — 1 line', () => {
|
|
259
|
+
const p = makePrepared('A')
|
|
260
|
+
const result = layout(p, 300)
|
|
261
|
+
expect(result.lineCount).toBe(1)
|
|
262
|
+
expect(result.height).toBe(24)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
test('very long string — wraps into multiple lines', () => {
|
|
266
|
+
const long = 'abcdefghij '.repeat(30)
|
|
267
|
+
const p = makePrepared(long)
|
|
268
|
+
const result = layout(p, 300)
|
|
269
|
+
expect(result.lineCount).toBeGreaterThan(1)
|
|
270
|
+
expect(result.height).toBeGreaterThan(24)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('CJK characters — does not throw, returns at least 1 line', () => {
|
|
274
|
+
expect(() => makePrepared('你好世界')).not.toThrow()
|
|
275
|
+
const p = makePrepared('你好世界')
|
|
276
|
+
expect(layout(p, 300).lineCount).toBeGreaterThanOrEqual(1)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('emoji — does not throw, returns at least 1 line', () => {
|
|
280
|
+
expect(() => makePrepared('🎉🎊🎈')).not.toThrow()
|
|
281
|
+
const p = makePrepared('🎉🎊🎈')
|
|
282
|
+
expect(layout(p, 300).lineCount).toBeGreaterThanOrEqual(1)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('mixed scripts (Latin + CJK + emoji) — does not throw, positive height', () => {
|
|
286
|
+
const mixed = 'Hello 你好 World 🎉'
|
|
287
|
+
expect(() => makePrepared(mixed)).not.toThrow()
|
|
288
|
+
const p = makePrepared(mixed)
|
|
289
|
+
const result = layout(p, 300)
|
|
290
|
+
expect(result.lineCount).toBeGreaterThanOrEqual(1)
|
|
291
|
+
expect(result.height).toBeGreaterThan(0)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test('Unicode RTL text — does not throw', () => {
|
|
295
|
+
const arabic = 'مرحبا بالعالم'
|
|
296
|
+
expect(() => makePrepared(arabic)).not.toThrow()
|
|
297
|
+
const p = makePrepared(arabic)
|
|
298
|
+
expect(layout(p, 300).lineCount).toBeGreaterThanOrEqual(1)
|
|
299
|
+
})
|
|
300
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
carveTextLineSlots,
|
|
4
|
+
circleIntervalForBand,
|
|
5
|
+
rectIntervalForBand,
|
|
6
|
+
} from '../obstacle-layout'
|
|
7
|
+
|
|
8
|
+
describe('circleIntervalForBand', () => {
|
|
9
|
+
test('returns null when band is above circle', () => {
|
|
10
|
+
expect(circleIntervalForBand(100, 100, 30, 0, 20)).toBeNull()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('returns null when band is below circle', () => {
|
|
14
|
+
expect(circleIntervalForBand(100, 100, 30, 140, 160)).toBeNull()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('returns interval when band intersects circle center', () => {
|
|
18
|
+
const iv = circleIntervalForBand(100, 100, 50, 90, 110)
|
|
19
|
+
expect(iv).not.toBeNull()
|
|
20
|
+
expect(iv!.left).toBeLessThan(100)
|
|
21
|
+
expect(iv!.right).toBeGreaterThan(100)
|
|
22
|
+
// At center, chord width = 2 * radius
|
|
23
|
+
expect(iv!.right - iv!.left).toBeCloseTo(100, 0)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('returns narrower interval when band is near edge', () => {
|
|
27
|
+
const center = circleIntervalForBand(100, 100, 50, 95, 105)
|
|
28
|
+
const edge = circleIntervalForBand(100, 100, 50, 140, 150)
|
|
29
|
+
expect(center).not.toBeNull()
|
|
30
|
+
expect(edge).not.toBeNull()
|
|
31
|
+
expect(center!.right - center!.left).toBeGreaterThan(edge!.right - edge!.left)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('respects hPad', () => {
|
|
35
|
+
const without = circleIntervalForBand(100, 100, 50, 95, 105, 0, 0)
|
|
36
|
+
const withPad = circleIntervalForBand(100, 100, 50, 95, 105, 10, 0)
|
|
37
|
+
expect(withPad!.left).toBeLessThan(without!.left)
|
|
38
|
+
expect(withPad!.right).toBeGreaterThan(without!.right)
|
|
39
|
+
expect((withPad!.right - withPad!.left) - (without!.right - without!.left)).toBeCloseTo(20, 0)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('rectIntervalForBand', () => {
|
|
44
|
+
test('returns null when band is above rect', () => {
|
|
45
|
+
expect(rectIntervalForBand({ x: 50, y: 100, w: 80, h: 40 }, 0, 20)).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('returns null when band is below rect', () => {
|
|
49
|
+
expect(rectIntervalForBand({ x: 50, y: 100, w: 80, h: 40 }, 150, 170)).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('returns interval when band intersects rect', () => {
|
|
53
|
+
const iv = rectIntervalForBand({ x: 50, y: 100, w: 80, h: 40 }, 110, 130)
|
|
54
|
+
expect(iv).not.toBeNull()
|
|
55
|
+
expect(iv!.left).toBe(50)
|
|
56
|
+
expect(iv!.right).toBe(130)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('carveTextLineSlots', () => {
|
|
61
|
+
test('returns full base when no obstacles', () => {
|
|
62
|
+
const slots = carveTextLineSlots({ left: 0, right: 300 }, [])
|
|
63
|
+
expect(slots).toEqual([{ left: 0, right: 300 }])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('splits base around one obstacle', () => {
|
|
67
|
+
const slots = carveTextLineSlots(
|
|
68
|
+
{ left: 0, right: 300 },
|
|
69
|
+
[{ left: 100, right: 200 }]
|
|
70
|
+
)
|
|
71
|
+
expect(slots.length).toBe(2)
|
|
72
|
+
expect(slots[0]).toEqual({ left: 0, right: 100 })
|
|
73
|
+
expect(slots[1]).toEqual({ left: 200, right: 300 })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('filters slots narrower than minSlotWidth', () => {
|
|
77
|
+
const slots = carveTextLineSlots(
|
|
78
|
+
{ left: 0, right: 300 },
|
|
79
|
+
[{ left: 10, right: 290 }],
|
|
80
|
+
30
|
|
81
|
+
)
|
|
82
|
+
expect(slots.length).toBe(0) // both remaining slots < 30px
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('handles obstacle at left edge', () => {
|
|
86
|
+
const slots = carveTextLineSlots(
|
|
87
|
+
{ left: 0, right: 300 },
|
|
88
|
+
[{ left: -10, right: 80 }]
|
|
89
|
+
)
|
|
90
|
+
expect(slots.length).toBe(1)
|
|
91
|
+
expect(slots[0]!.left).toBe(80)
|
|
92
|
+
expect(slots[0]!.right).toBe(300)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('handles obstacle at right edge', () => {
|
|
96
|
+
const slots = carveTextLineSlots(
|
|
97
|
+
{ left: 0, right: 300 },
|
|
98
|
+
[{ left: 250, right: 350 }]
|
|
99
|
+
)
|
|
100
|
+
expect(slots.length).toBe(1)
|
|
101
|
+
expect(slots[0]!.left).toBe(0)
|
|
102
|
+
expect(slots[0]!.right).toBe(250)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('handles multiple obstacles', () => {
|
|
106
|
+
const slots = carveTextLineSlots(
|
|
107
|
+
{ left: 0, right: 400 },
|
|
108
|
+
[{ left: 50, right: 100 }, { left: 200, right: 280 }]
|
|
109
|
+
)
|
|
110
|
+
expect(slots.length).toBe(3)
|
|
111
|
+
expect(slots[0]).toEqual({ left: 0, right: 50 })
|
|
112
|
+
expect(slots[1]).toEqual({ left: 100, right: 200 })
|
|
113
|
+
expect(slots[2]).toEqual({ left: 280, right: 400 })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('handles overlapping obstacles', () => {
|
|
117
|
+
const slots = carveTextLineSlots(
|
|
118
|
+
{ left: 0, right: 300 },
|
|
119
|
+
[{ left: 80, right: 150 }, { left: 120, right: 200 }]
|
|
120
|
+
)
|
|
121
|
+
// After first: [0,80] [150,300]
|
|
122
|
+
// After second removes from [150,300]: [200,300]
|
|
123
|
+
expect(slots.length).toBe(2)
|
|
124
|
+
expect(slots[0]).toEqual({ left: 0, right: 80 })
|
|
125
|
+
expect(slots[1]).toEqual({ left: 200, right: 300 })
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Test environment setup: mock native dependencies so the layout
|
|
2
|
+
// engine tests can run in bun without a React Native runtime.
|
|
3
|
+
import { mock } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
mock.module('react-native', () => ({
|
|
6
|
+
Platform: { OS: 'ios', select: (obj: Record<string, unknown>) => obj.ios ?? obj.default },
|
|
7
|
+
NativeModules: {},
|
|
8
|
+
NativeEventEmitter: class {},
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
mock.module('expo-modules-core', () => ({
|
|
12
|
+
NativeModule: class {},
|
|
13
|
+
requireNativeModule: () => null,
|
|
14
|
+
}))
|