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.
@@ -0,0 +1,444 @@
1
+ import ExpoModulesCore
2
+ import UIKit
3
+ import CoreText
4
+
5
+ // MARK: - Cache Entry
6
+
7
+ private struct CacheEntry {
8
+ let width: Double
9
+ var hits: Int
10
+ }
11
+
12
+ // MARK: - ExpoPretext Module
13
+
14
+ public class ExpoPretext: Module {
15
+
16
+ // Font cache: "family_size_weight_style" -> UIFont
17
+ private var fontCache: [String: UIFont] = [:]
18
+
19
+ // Measure cache: fontKey -> (segmentText -> CacheEntry)
20
+ private var measureCache: [String: [String: CacheEntry]] = [:]
21
+
22
+ // Max segments per font before LRU eviction
23
+ private var maxCacheSize: Int = 5000
24
+
25
+ // MARK: - Module Definition
26
+
27
+ public func definition() -> ModuleDefinition {
28
+ Name("ExpoPretext")
29
+
30
+ // Trim caches on memory warning
31
+ OnAppEntersBackground {
32
+ self.trimCaches(keepTop: 1000)
33
+ }
34
+
35
+ // Register for memory warning notifications
36
+ OnStartObserving {
37
+ NotificationCenter.default.addObserver(
38
+ self,
39
+ selector: #selector(self.handleMemoryWarning),
40
+ name: UIApplication.didReceiveMemoryWarningNotification,
41
+ object: nil
42
+ )
43
+ }
44
+
45
+ OnStopObserving {
46
+ NotificationCenter.default.removeObserver(
47
+ self,
48
+ name: UIApplication.didReceiveMemoryWarningNotification,
49
+ object: nil
50
+ )
51
+ }
52
+
53
+ // segmentAndMeasure(text, font, options?) -> { segments, isWordLike, widths }
54
+ Function("segmentAndMeasure") { (text: String, fontDesc: [String: Any], options: [String: Any]?) -> [String: Any] in
55
+ let font = self.resolveFont(fontDesc)
56
+ let fontKey = self.fontKey(fontDesc)
57
+ let whiteSpace = (options?["whiteSpace"] as? String) ?? "normal"
58
+ let locale = options?["locale"] as? String
59
+ return self.performSegmentAndMeasure(text: text, font: font, fontKey: fontKey, whiteSpace: whiteSpace, locale: locale)
60
+ }
61
+
62
+ // batchSegmentAndMeasure(texts, font, options?) -> [{ segments, isWordLike, widths }]
63
+ Function("batchSegmentAndMeasure") { (texts: [String], fontDesc: [String: Any], options: [String: Any]?) -> [[String: Any]] in
64
+ let font = self.resolveFont(fontDesc)
65
+ let fontKey = self.fontKey(fontDesc)
66
+ let whiteSpace = (options?["whiteSpace"] as? String) ?? "normal"
67
+ let locale = options?["locale"] as? String
68
+ return texts.map { text in
69
+ self.performSegmentAndMeasure(text: text, font: font, fontKey: fontKey, whiteSpace: whiteSpace, locale: locale)
70
+ }
71
+ }
72
+
73
+ // measureGraphemeWidths(segment, font) -> [Double]
74
+ Function("measureGraphemeWidths") { (segment: String, fontDesc: [String: Any]) -> [Double] in
75
+ let font = self.resolveFont(fontDesc)
76
+ var widths: [Double] = []
77
+ var index = segment.startIndex
78
+ while index < segment.endIndex {
79
+ let nextIndex = segment.index(after: index)
80
+ // Walk to next grapheme cluster boundary
81
+ let grapheme = String(segment[index..<nextIndex])
82
+ let attrStr = NSAttributedString(string: grapheme, attributes: [.font: font])
83
+ let ctLine = CTLineCreateWithAttributedString(attrStr)
84
+ widths.append(Double(CTLineGetTypographicBounds(ctLine, nil, nil, nil)))
85
+ index = nextIndex
86
+ }
87
+ return widths
88
+ }
89
+
90
+ // remeasureMerged(segments, font) -> [Double]
91
+ Function("remeasureMerged") { (segments: [String], fontDesc: [String: Any]) -> [Double] in
92
+ let font = self.resolveFont(fontDesc)
93
+ return segments.map { segment in
94
+ let attrStr = NSAttributedString(string: segment, attributes: [.font: font])
95
+ let ctLine = CTLineCreateWithAttributedString(attrStr)
96
+ return Double(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
97
+ }
98
+ }
99
+
100
+ // segmentAndMeasureAsync(text, font, options?) -> Promise<{ segments, isWordLike, widths }>
101
+ AsyncFunction("segmentAndMeasureAsync") { (text: String, fontDesc: [String: Any], options: [String: Any]?, promise: Promise) in
102
+ DispatchQueue.global(qos: .userInitiated).async {
103
+ let font = self.resolveFont(fontDesc)
104
+ let fontKey = self.fontKey(fontDesc)
105
+ let whiteSpace = (options?["whiteSpace"] as? String) ?? "normal"
106
+ let locale = options?["locale"] as? String
107
+ let result = self.performSegmentAndMeasure(text: text, font: font, fontKey: fontKey, whiteSpace: whiteSpace, locale: locale)
108
+ promise.resolve(result)
109
+ }
110
+ }
111
+
112
+ // clearNativeCache() -> void
113
+ Function("clearNativeCache") {
114
+ self.fontCache.removeAll()
115
+ self.measureCache.removeAll()
116
+ }
117
+
118
+ // setNativeCacheSize(size) -> void
119
+ Function("setNativeCacheSize") { (size: Int) in
120
+ self.maxCacheSize = max(size, 100) // floor at 100 to avoid thrashing
121
+ }
122
+
123
+ // measureTextHeight(text, font, maxWidth, lineHeight) -> { height, lineCount }
124
+ // Uses NSLayoutManager (TextKit) — same layout engine as RN Text
125
+ Function("measureTextHeight") {
126
+ (text: String, fontDesc: [String: Any], maxWidth: Double, lineHeight: Double) -> [String: Any] in
127
+ let font = self.resolveFont(fontDesc)
128
+
129
+ let textStorage = NSTextStorage(string: text, attributes: [
130
+ .font: font,
131
+ .paragraphStyle: {
132
+ let ps = NSMutableParagraphStyle()
133
+ ps.minimumLineHeight = CGFloat(lineHeight)
134
+ ps.maximumLineHeight = CGFloat(lineHeight)
135
+ return ps
136
+ }()
137
+ ])
138
+
139
+ let layoutManager = NSLayoutManager()
140
+ let textContainer = NSTextContainer(size: CGSize(
141
+ width: CGFloat(maxWidth),
142
+ height: CGFloat.greatestFiniteMagnitude
143
+ ))
144
+ textContainer.lineFragmentPadding = 0
145
+
146
+ layoutManager.addTextContainer(textContainer)
147
+ textStorage.addLayoutManager(layoutManager)
148
+
149
+ // Force layout
150
+ layoutManager.ensureLayout(for: textContainer)
151
+
152
+ let usedRect = layoutManager.usedRect(for: textContainer)
153
+ let glyphRange = layoutManager.glyphRange(for: textContainer)
154
+
155
+ // Count lines
156
+ var lineCount = 0
157
+ var index = glyphRange.location
158
+ while index < NSMaxRange(glyphRange) {
159
+ var lineRange = NSRange()
160
+ layoutManager.lineFragmentRect(forGlyphAt: index, effectiveRange: &lineRange)
161
+ lineCount += 1
162
+ index = NSMaxRange(lineRange)
163
+ }
164
+
165
+ return [
166
+ "height": Double(ceil(usedRect.height)),
167
+ "lineCount": lineCount
168
+ ]
169
+ }
170
+ }
171
+
172
+ // MARK: - Memory Warning
173
+
174
+ @objc private func handleMemoryWarning() {
175
+ trimCaches(keepTop: 200)
176
+ }
177
+
178
+ // MARK: - Cache Trimming
179
+
180
+ /// Trim each font's measure cache to the top N entries by hit count.
181
+ private func trimCaches(keepTop n: Int) {
182
+ for fontKey in measureCache.keys {
183
+ guard let entries = measureCache[fontKey], entries.count > n else { continue }
184
+ // Sort by hits descending, keep top n
185
+ let sorted = entries.sorted { $0.value.hits > $1.value.hits }
186
+ var trimmed: [String: CacheEntry] = [:]
187
+ trimmed.reserveCapacity(n)
188
+ for (i, pair) in sorted.enumerated() {
189
+ if i >= n { break }
190
+ trimmed[pair.key] = pair.value
191
+ }
192
+ measureCache[fontKey] = trimmed
193
+ }
194
+ }
195
+
196
+ // MARK: - Font Resolution
197
+
198
+ /// Build a cache key from a font descriptor dictionary.
199
+ private func fontKey(_ desc: [String: Any]) -> String {
200
+ let family = (desc["fontFamily"] as? String) ?? "System"
201
+ let size = desc["fontSize"] as? Double ?? 14.0
202
+ let weight = (desc["fontWeight"] as? String) ?? "400"
203
+ let style = (desc["fontStyle"] as? String) ?? "normal"
204
+ return "\(family)_\(size)_\(weight)_\(style)"
205
+ }
206
+
207
+ /// Resolve a font descriptor to a UIFont, using the font cache.
208
+ private func resolveFont(_ desc: [String: Any]) -> UIFont {
209
+ let key = fontKey(desc)
210
+ if let cached = fontCache[key] {
211
+ return cached
212
+ }
213
+
214
+ let family = (desc["fontFamily"] as? String) ?? "System"
215
+ let size = CGFloat(desc["fontSize"] as? Double ?? 14.0)
216
+ let weightStr = (desc["fontWeight"] as? String) ?? "400"
217
+ let style = (desc["fontStyle"] as? String) ?? "normal"
218
+
219
+ let uiWeight = mapFontWeight(weightStr)
220
+ var font: UIFont
221
+
222
+ if family == "System" || family == "system" {
223
+ font = UIFont.systemFont(ofSize: size, weight: uiWeight)
224
+ } else {
225
+ // Try to create the font by family name
226
+ if let descriptorFont = UIFont(name: family, size: size) {
227
+ font = descriptorFont
228
+ } else {
229
+ // Fallback: use system font with the requested weight
230
+ font = UIFont.systemFont(ofSize: size, weight: uiWeight)
231
+ }
232
+ }
233
+
234
+ // Apply bold weight via descriptor if needed
235
+ if uiWeight == .bold || uiWeight == .semibold || uiWeight == .heavy || uiWeight == .black {
236
+ if let descriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) {
237
+ font = UIFont(descriptor: descriptor, size: size)
238
+ }
239
+ }
240
+
241
+ // Apply italic via symbolic trait
242
+ if style == "italic" {
243
+ if let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) {
244
+ font = UIFont(descriptor: descriptor, size: size)
245
+ }
246
+ }
247
+
248
+ fontCache[key] = font
249
+ return font
250
+ }
251
+
252
+ /// Map CSS-style font weight string to UIFont.Weight.
253
+ private func mapFontWeight(_ weight: String) -> UIFont.Weight {
254
+ switch weight {
255
+ case "100": return .ultraLight
256
+ case "200": return .thin
257
+ case "300": return .light
258
+ case "400", "normal": return .regular
259
+ case "500": return .medium
260
+ case "600": return .semibold
261
+ case "700", "bold": return .bold
262
+ case "800": return .heavy
263
+ case "900": return .black
264
+ default: return .regular
265
+ }
266
+ }
267
+
268
+ // MARK: - Segmentation & Measurement
269
+
270
+ /// Core implementation: segment text and measure each segment's width.
271
+ private func performSegmentAndMeasure(
272
+ text: String,
273
+ font: UIFont,
274
+ fontKey: String,
275
+ whiteSpace: String,
276
+ locale: String?
277
+ ) -> [String: Any] {
278
+ if text.isEmpty {
279
+ return [
280
+ "segments": [String](),
281
+ "isWordLike": [Bool](),
282
+ "widths": [Double]()
283
+ ]
284
+ }
285
+
286
+ let rawSegments = segmentText(text, whiteSpace: whiteSpace, locale: locale)
287
+
288
+ var segments: [String] = []
289
+ var isWordLike: [Bool] = []
290
+ var widths: [Double] = []
291
+
292
+ segments.reserveCapacity(rawSegments.count)
293
+ isWordLike.reserveCapacity(rawSegments.count)
294
+ widths.reserveCapacity(rawSegments.count)
295
+
296
+ // Ensure font key has a cache bucket
297
+ if measureCache[fontKey] == nil {
298
+ measureCache[fontKey] = [:]
299
+ }
300
+
301
+ for segment in rawSegments {
302
+ segments.append(segment)
303
+ isWordLike.append(classifyWordLike(segment))
304
+
305
+ // Check measurement cache
306
+ if var entry = measureCache[fontKey]?[segment] {
307
+ entry.hits += 1
308
+ measureCache[fontKey]?[segment] = entry
309
+ widths.append(entry.width)
310
+ } else {
311
+ // Measure the segment using CTLine (Core Text) — this is what
312
+ // RN Text's underlying layout engine uses for glyph measurement
313
+ let attrStr = NSAttributedString(string: segment, attributes: [.font: font])
314
+ let ctLine = CTLineCreateWithAttributedString(attrStr)
315
+ let width = Double(CTLineGetTypographicBounds(ctLine, nil, nil, nil))
316
+ measureCache[fontKey]?[segment] = CacheEntry(width: width, hits: 1)
317
+ widths.append(width)
318
+
319
+ // Check if we need LRU eviction
320
+ if let count = measureCache[fontKey]?.count, count > maxCacheSize {
321
+ evictLRU(fontKey: fontKey)
322
+ }
323
+ }
324
+ }
325
+
326
+ return [
327
+ "segments": segments,
328
+ "isWordLike": isWordLike,
329
+ "widths": widths
330
+ ]
331
+ }
332
+
333
+ /// Segment text using CFStringTokenizer.
334
+ /// In pre-wrap mode, whitespace characters are split into individual segments.
335
+ /// In normal mode, whitespace runs are collapsed into a single space.
336
+ private func segmentText(_ text: String, whiteSpace: String, locale: String?) -> [String] {
337
+ let cfText = text as CFString
338
+ let fullRange = CFRangeMake(0, CFStringGetLength(cfText))
339
+
340
+ // Set up locale for tokenizer
341
+ let cfLocale: CFLocale?
342
+ if let locale = locale {
343
+ let loc = Locale(identifier: locale)
344
+ cfLocale = loc as CFLocale
345
+ } else {
346
+ cfLocale = CFLocaleCopyCurrent()
347
+ }
348
+
349
+ let tokenizer = CFStringTokenizerCreate(
350
+ nil,
351
+ cfText,
352
+ fullRange,
353
+ kCFStringTokenizerUnitWordBoundary,
354
+ cfLocale
355
+ )
356
+
357
+ var segments: [String] = []
358
+ let nsText = text as NSString
359
+ var currentPos: CFIndex = 0
360
+ let length = CFStringGetLength(cfText)
361
+
362
+ // Walk through all tokens
363
+ var tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
364
+
365
+ while currentPos < length {
366
+ if tokenType != CFStringTokenizerTokenType(rawValue: 0) {
367
+ let tokenRange = CFStringTokenizerGetCurrentTokenRange(tokenizer)
368
+
369
+ // Handle any gap before this token (whitespace between tokens)
370
+ if tokenRange.location > currentPos {
371
+ let gapRange = NSRange(location: currentPos, length: tokenRange.location - currentPos)
372
+ let gap = nsText.substring(with: gapRange)
373
+ let whitespaceSegments = processWhitespace(gap, mode: whiteSpace)
374
+ segments.append(contentsOf: whitespaceSegments)
375
+ }
376
+
377
+ // Add the token itself
378
+ let tokenNSRange = NSRange(location: tokenRange.location, length: tokenRange.length)
379
+ let token = nsText.substring(with: tokenNSRange)
380
+ segments.append(token)
381
+
382
+ currentPos = tokenRange.location + tokenRange.length
383
+ tokenType = CFStringTokenizerAdvanceToNextToken(tokenizer)
384
+ } else {
385
+ // No more tokens; process remaining text as whitespace/trailing content
386
+ let remainingRange = NSRange(location: currentPos, length: length - currentPos)
387
+ let remaining = nsText.substring(with: remainingRange)
388
+ let whitespaceSegments = processWhitespace(remaining, mode: whiteSpace)
389
+ segments.append(contentsOf: whitespaceSegments)
390
+ currentPos = length
391
+ }
392
+ }
393
+
394
+ return segments
395
+ }
396
+
397
+ /// Process a whitespace (or non-token gap) string based on white-space mode.
398
+ /// - normal: collapse runs of whitespace to a single space " ".
399
+ /// - pre-wrap: split each whitespace character into its own segment.
400
+ private func processWhitespace(_ gap: String, mode: String) -> [String] {
401
+ if gap.isEmpty { return [] }
402
+
403
+ if mode == "pre-wrap" {
404
+ // Each character becomes its own segment
405
+ return gap.map { String($0) }
406
+ } else {
407
+ // Normal mode: collapse any whitespace run to a single space
408
+ // But if the gap contains no whitespace, return it as-is
409
+ let trimmed = gap.replacingOccurrences(
410
+ of: "\\s+",
411
+ with: " ",
412
+ options: .regularExpression
413
+ )
414
+ if trimmed.isEmpty { return [] }
415
+ return [trimmed]
416
+ }
417
+ }
418
+
419
+ /// Classify whether a segment is "word-like" (contains at least one alphanumeric/letter character).
420
+ private func classifyWordLike(_ segment: String) -> Bool {
421
+ for scalar in segment.unicodeScalars {
422
+ if CharacterSet.alphanumerics.contains(scalar) {
423
+ return true
424
+ }
425
+ }
426
+ return false
427
+ }
428
+
429
+ // MARK: - LRU Eviction
430
+
431
+ /// Evict the least-hit entries from a font's measure cache, keeping the top maxCacheSize * 0.8 entries.
432
+ private func evictLRU(fontKey: String) {
433
+ guard let entries = measureCache[fontKey] else { return }
434
+ let keepCount = Int(Double(maxCacheSize) * 0.8)
435
+ let sorted = entries.sorted { $0.value.hits > $1.value.hits }
436
+ var trimmed: [String: CacheEntry] = [:]
437
+ trimmed.reserveCapacity(keepCount)
438
+ for (i, pair) in sorted.enumerated() {
439
+ if i >= keepCount { break }
440
+ trimmed[pair.key] = pair.value
441
+ }
442
+ measureCache[fontKey] = trimmed
443
+ }
444
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "expo-pretext",
3
+ "version": "0.2.0",
4
+ "description": "DOM-free multiline text height prediction for React Native. Port of Pretext.",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "license": "MIT",
8
+ "author": "Juba Kitiashvili",
9
+ "homepage": "https://github.com/JubaKitiashvili/expo-pretext#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/JubaKitiashvili/expo-pretext"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/JubaKitiashvili/expo-pretext/issues"
16
+ },
17
+ "keywords": [
18
+ "react-native",
19
+ "expo",
20
+ "text-measurement",
21
+ "text-layout",
22
+ "text-height",
23
+ "virtualization",
24
+ "flashlist",
25
+ "pretext",
26
+ "ai-chat",
27
+ "streaming"
28
+ ],
29
+ "files": [
30
+ "src/",
31
+ "ios/",
32
+ "android/",
33
+ "expo-module.config.json",
34
+ "README.md",
35
+ "LICENSE",
36
+ "CHANGELOG.md"
37
+ ],
38
+ "scripts": {
39
+ "test": "bun test src/__tests__/",
40
+ "test:all": "bun test",
41
+ "typecheck": "tsc --noEmit",
42
+ "prepublishOnly": "bun test src/__tests__/"
43
+ },
44
+ "engines": {
45
+ "node": ">=18.0.0"
46
+ },
47
+ "peerDependencies": {
48
+ "expo": ">=52.0.0",
49
+ "react": ">=18.0.0",
50
+ "react-native": ">=0.76.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/react": "^19.2.14",
54
+ "@types/react-native": "^0.73.0",
55
+ "expo": "~53.0.0",
56
+ "expo-module-scripts": "~4.0.0",
57
+ "typescript": "~5.8.0"
58
+ }
59
+ }
@@ -0,0 +1,70 @@
1
+ // src/ExpoPretext.ts
2
+ // JS binding to the native Expo module.
3
+ // All native calls go through this file.
4
+
5
+ import { NativeModule, requireNativeModule } from 'expo-modules-core'
6
+ import type { FontDescriptor, NativeSegmentResult } from './types'
7
+
8
+ type MeasureNativeOptions = {
9
+ whiteSpace?: string
10
+ locale?: string
11
+ }
12
+
13
+ interface ExpoPretextNativeModule extends InstanceType<typeof NativeModule> {
14
+ segmentAndMeasure(
15
+ text: string,
16
+ font: FontDescriptor,
17
+ options?: MeasureNativeOptions
18
+ ): NativeSegmentResult
19
+
20
+ batchSegmentAndMeasure(
21
+ texts: string[],
22
+ font: FontDescriptor,
23
+ options?: MeasureNativeOptions
24
+ ): NativeSegmentResult[]
25
+
26
+ measureGraphemeWidths(
27
+ segment: string,
28
+ font: FontDescriptor
29
+ ): number[]
30
+
31
+ remeasureMerged(
32
+ segments: string[],
33
+ font: FontDescriptor
34
+ ): number[]
35
+
36
+ segmentAndMeasureAsync(
37
+ text: string,
38
+ font: FontDescriptor,
39
+ options?: MeasureNativeOptions
40
+ ): Promise<NativeSegmentResult>
41
+
42
+ measureTextHeight(
43
+ text: string,
44
+ font: FontDescriptor,
45
+ maxWidth: number,
46
+ lineHeight: number
47
+ ): { height: number; lineCount: number }
48
+
49
+ clearNativeCache(): void
50
+
51
+ setNativeCacheSize(size: number): void
52
+ }
53
+
54
+ let nativeModule: ExpoPretextNativeModule | null = null
55
+
56
+ export function getNativeModule(): ExpoPretextNativeModule | null {
57
+ if (nativeModule !== null) return nativeModule
58
+ try {
59
+ nativeModule = requireNativeModule<ExpoPretextNativeModule>('ExpoPretext')
60
+ return nativeModule
61
+ } catch {
62
+ if (__DEV__) {
63
+ console.warn(
64
+ '[expo-pretext] Native module not available. ' +
65
+ 'Using JS estimates. Use a development build for accurate measurements.'
66
+ )
67
+ }
68
+ return null
69
+ }
70
+ }
@@ -0,0 +1,71 @@
1
+ import { describe, test, expect, beforeEach } from 'bun:test'
2
+ import {
3
+ getCachedWidth,
4
+ setCachedWidth,
5
+ cacheNativeResult,
6
+ tryResolveAllFromCache,
7
+ clearJSCache,
8
+ } from '../cache'
9
+
10
+ describe('JS-side width cache', () => {
11
+ beforeEach(() => {
12
+ clearJSCache()
13
+ })
14
+
15
+ test('set and get single width', () => {
16
+ setCachedWidth('Inter_16_400_normal', 'Hello', 42.5)
17
+ expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBe(42.5)
18
+ })
19
+
20
+ test('returns undefined for cache miss', () => {
21
+ expect(getCachedWidth('Inter_16_400_normal', 'missing')).toBeUndefined()
22
+ })
23
+
24
+ test('returns undefined for unknown font key', () => {
25
+ setCachedWidth('Inter_16_400_normal', 'Hello', 42.5)
26
+ expect(getCachedWidth('Arial_16_400_normal', 'Hello')).toBeUndefined()
27
+ })
28
+
29
+ test('cacheNativeResult stores all segments', () => {
30
+ cacheNativeResult('Inter_16_400_normal', ['Hello', ' ', 'world'], [42.5, 4.2, 38.1])
31
+ expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBe(42.5)
32
+ expect(getCachedWidth('Inter_16_400_normal', ' ')).toBe(4.2)
33
+ expect(getCachedWidth('Inter_16_400_normal', 'world')).toBe(38.1)
34
+ })
35
+
36
+ test('tryResolveAllFromCache returns widths when all cached', () => {
37
+ cacheNativeResult('Inter_16_400_normal', ['Hello', ' ', 'world'], [42.5, 4.2, 38.1])
38
+ const result = tryResolveAllFromCache('Inter_16_400_normal', ['Hello', ' ', 'world'])
39
+ expect(result).toEqual([42.5, 4.2, 38.1])
40
+ })
41
+
42
+ test('tryResolveAllFromCache returns null on partial miss', () => {
43
+ cacheNativeResult('Inter_16_400_normal', ['Hello', ' '], [42.5, 4.2])
44
+ const result = tryResolveAllFromCache('Inter_16_400_normal', ['Hello', ' ', 'world'])
45
+ expect(result).toBeNull()
46
+ })
47
+
48
+ test('tryResolveAllFromCache returns null for unknown font', () => {
49
+ const result = tryResolveAllFromCache('Unknown_16_400_normal', ['Hello'])
50
+ expect(result).toBeNull()
51
+ })
52
+
53
+ test('clearJSCache clears everything', () => {
54
+ cacheNativeResult('Inter_16_400_normal', ['Hello'], [42.5])
55
+ clearJSCache()
56
+ expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBeUndefined()
57
+ })
58
+
59
+ test('multiple font keys are independent', () => {
60
+ setCachedWidth('Inter_16_400_normal', 'Hello', 42.5)
61
+ setCachedWidth('Inter_16_700_normal', 'Hello', 44.0)
62
+ expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBe(42.5)
63
+ expect(getCachedWidth('Inter_16_700_normal', 'Hello')).toBe(44.0)
64
+ })
65
+
66
+ test('overwriting a cached value', () => {
67
+ setCachedWidth('Inter_16_400_normal', 'Hello', 42.5)
68
+ setCachedWidth('Inter_16_400_normal', 'Hello', 43.0)
69
+ expect(getCachedWidth('Inter_16_400_normal', 'Hello')).toBe(43.0)
70
+ })
71
+ })