@bycrux/editor 1.1.0 → 1.2.1
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/package.json +2 -2
- package/src/ControlsInfoModal.tsx +1 -0
- package/src/engine/__tests__/scheduler.test.ts +56 -1
- package/src/engine/scheduler.ts +23 -5
- package/src/index.ts +36 -0
- package/src/lib/__tests__/font-faces.test.ts +244 -0
- package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
- package/src/lib/__tests__/google-fonts.test.ts +319 -2
- package/src/lib/font-families.ts +286 -0
- package/src/lib/google-fonts.ts +132 -10
- package/src/schema.ts +21 -0
- package/src/text/FontPicker.tsx +82 -11
- package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
- package/src/video/VersionPanel.tsx +1 -1
- package/src/video/VideoEditor.tsx +98 -4
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +64 -0
- package/src/video/__tests__/cuts.test.ts +84 -0
- package/src/video/__tests__/exportDurationSec.test.ts +60 -0
- package/src/video/__tests__/markerDropTime.test.ts +25 -0
- package/src/video/cuts.ts +30 -2
- package/src/video/preview/PreviewPlayer.tsx +52 -2
- package/src/video/preview/__tests__/useVideoPlayback.canvasClock.test.ts +99 -0
- package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
- package/src/video/preview/useEnginePlayback.ts +58 -8
- package/src/video/preview/useVideoPlayback.ts +64 -17
- package/src/video/timeline/Timeline.tsx +6 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +16 -0
- package/src/video/timeline/__tests__/markers.test.ts +125 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +110 -12
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +106 -2
- package/src/video/timeline/canvas/__tests__/draw.test.ts +73 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +76 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +100 -1
- package/src/video/timeline/canvas/draw.ts +182 -8
- package/src/video/timeline/canvas/hit-test.ts +72 -1
- package/src/video/timeline/canvas/pointer-machine.ts +83 -4
- package/src/video/timeline/markers.ts +109 -0
- package/src/video/timeline/timeline-model.ts +19 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { render, cleanup } from '@testing-library/react'
|
|
3
|
+
import { FontFamilyPicker, setFontsBaseUrl as setPickerBase } from '../../text/FontPicker'
|
|
4
|
+
import { ensureGoogleFontsLoaded, setFontsBaseUrl as setOverlayBase } from '../google-fonts'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// TWO LOADERS, ONE PARTITION
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
//
|
|
10
|
+
// This package ships two independent Google Fonts loaders — `lib/google-fonts`
|
|
11
|
+
// (captions, overlays, the timeline/overlay preview) and `text/FontPicker`
|
|
12
|
+
// (the picker's own preview list). They keep separate bases and separate
|
|
13
|
+
// injection state on purpose. They must NOT keep separate opinions about which
|
|
14
|
+
// families a vendored stylesheet covers: one loader serving a family locally
|
|
15
|
+
// while the other fetches it from Google is a caption in one face in the
|
|
16
|
+
// picker and another on the canvas, with nothing on screen to explain it.
|
|
17
|
+
//
|
|
18
|
+
// The render package pins its two duplicated copies as identical SOURCE TEXT,
|
|
19
|
+
// because its two renderers are separate CLI entry points that cannot import
|
|
20
|
+
// each other. These two can import, and do — both partition through
|
|
21
|
+
// `lib/font-families`. Textual parity would therefore be a test that cannot
|
|
22
|
+
// fail. So this pins the property that actually matters instead: given the
|
|
23
|
+
// same family list, the two loaders put the same families on the same side.
|
|
24
|
+
//
|
|
25
|
+
// Driving them is asymmetric because their inputs are: the overlay loader
|
|
26
|
+
// takes the specs it is asked for, while the picker always requests its own
|
|
27
|
+
// fixed FONT_OPTIONS set. So feed the picker's specs to the overlay loader and
|
|
28
|
+
// compare what each one emits.
|
|
29
|
+
|
|
30
|
+
function injectedHrefs(): string[] {
|
|
31
|
+
return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
|
|
32
|
+
(l) => (l as HTMLLinkElement).href,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The googleapis URL a loader emitted, reduced to its `family=` specs. */
|
|
37
|
+
function googleSpecs(hrefs: string[]): string[] {
|
|
38
|
+
const url = hrefs.find((h) => h.includes('fonts.googleapis.com'))
|
|
39
|
+
if (!url) return []
|
|
40
|
+
return [...url.matchAll(/family=([^&]+)/g)].map((m) => decodeURIComponent(m[1]))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether a loader linked the vendored stylesheet. */
|
|
44
|
+
const linkedVendored = (hrefs: string[]) => hrefs.some((h) => h.endsWith('/fonts.css'))
|
|
45
|
+
|
|
46
|
+
// The picker's own spec list, read out of the module rather than restated, so
|
|
47
|
+
// a change to FONT_OPTIONS cannot leave this comparing a stale set.
|
|
48
|
+
async function pickerSpecs(): Promise<string[]> {
|
|
49
|
+
const { FONT_OPTIONS } = await import('../../text/FontPicker')
|
|
50
|
+
return FONT_OPTIONS.filter((f) => f.isGoogleFont).map(
|
|
51
|
+
(f) => f.spec ?? `${f.label.replace(/ /g, '+')}:wght@400;700`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
57
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
58
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
59
|
+
})
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
cleanup()
|
|
62
|
+
setPickerBase(undefined)
|
|
63
|
+
setOverlayBase(undefined)
|
|
64
|
+
vi.restoreAllMocks()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Each case uses a base unique to itself: both loaders dedupe on the URLs they
|
|
68
|
+
// have already injected, and that state outlives a test, so a repeated base
|
|
69
|
+
// would make a loader look silent when it had merely already spoken.
|
|
70
|
+
describe('the picker loader and the overlay loader partition identically', () => {
|
|
71
|
+
const CASES: [string, (all: string[]) => string[]][] = [
|
|
72
|
+
['every picker family vendored', (all) => all.map(familyOf)],
|
|
73
|
+
['none vendored', () => []],
|
|
74
|
+
['one family missing from the vendored list', (all) => all.slice(1).map(familyOf)],
|
|
75
|
+
['half vendored', (all) => all.slice(0, 10).map(familyOf)],
|
|
76
|
+
['vendored list spelled Google-style, with +', (all) => all.map((s) => s.split(':')[0])],
|
|
77
|
+
['vendored list in a different case', (all) => all.map((s) => familyOf(s).toUpperCase())],
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
function familyOf(spec: string) {
|
|
81
|
+
return spec.split(':')[0].replace(/\+/g, ' ')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [label, listFor] of CASES) {
|
|
85
|
+
it(label, async () => {
|
|
86
|
+
const specs = await pickerSpecs()
|
|
87
|
+
const families = listFor(specs)
|
|
88
|
+
const base = `https://example.com/fonts/parity-${label.replace(/[^a-z]+/gi, '-')}`
|
|
89
|
+
|
|
90
|
+
setPickerBase(base, families)
|
|
91
|
+
render(<FontFamilyPicker value="" onChange={() => {}} />)
|
|
92
|
+
const picker = injectedHrefs()
|
|
93
|
+
|
|
94
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
95
|
+
|
|
96
|
+
setOverlayBase(base, families)
|
|
97
|
+
ensureGoogleFontsLoaded(specs)
|
|
98
|
+
const overlay = injectedHrefs()
|
|
99
|
+
|
|
100
|
+
expect(googleSpecs(overlay)).toEqual(googleSpecs(picker))
|
|
101
|
+
expect(linkedVendored(overlay)).toBe(linkedVendored(picker))
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it('and a host that sets only ONE of the two setters still leaves the other on Google', () => {
|
|
106
|
+
// Not a defect to fix here — it is the documented contract on
|
|
107
|
+
// `src/index.ts` — but it is the mistake a host will actually make, so
|
|
108
|
+
// pin that it stays visible rather than silently half-working.
|
|
109
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
110
|
+
setPickerBase('https://example.com/fonts/only-picker', ['Inter'])
|
|
111
|
+
// The overlay loader was never pointed anywhere, so it has no base at all.
|
|
112
|
+
ensureGoogleFontsLoaded(['Inter:wght@400'])
|
|
113
|
+
const hrefs = injectedHrefs()
|
|
114
|
+
expect(hrefs.some((h) => h.includes('fonts.googleapis.com'))).toBe(true)
|
|
115
|
+
// ...and the picker's own setter said out loud what it was given.
|
|
116
|
+
expect(warn.mock.calls.flat().join(' ')).not.toContain('treating NOTHING as vendored')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// ...AND THEY AGREE AT FACE LEVEL TOO
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
//
|
|
124
|
+
// The partition is family-level AND face-level: a spec is vendored only when
|
|
125
|
+
// its family is declared and every face it requires is available. That second
|
|
126
|
+
// half is the one that can disagree quietly, because both loaders can name the
|
|
127
|
+
// same family while resolving different weights of it — and the picker is the
|
|
128
|
+
// loader that requests `Bebas+Neue:wght@400;700`, the one shipped spec whose
|
|
129
|
+
// answer depends on the `requested` map rather than on what is on disk.
|
|
130
|
+
//
|
|
131
|
+
// This is the TS half's parity mechanism. It is behavioural on purpose: both
|
|
132
|
+
// loaders import `lib/font-families`, so there is no second copy to compare
|
|
133
|
+
// textually and such a test could not fail. What can still diverge is how each
|
|
134
|
+
// loader WIRES that shared code — which argument it passes, and whether it
|
|
135
|
+
// passes the face index at all — and only running both can catch that.
|
|
136
|
+
describe('the two loaders agree on the FACE partition, not just the family one', () => {
|
|
137
|
+
function familyOf(spec: string) {
|
|
138
|
+
return spec.split(':')[0].replace(/\+/g, ' ')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The faces the picker's own specs resolve to, with `broken` letting a case
|
|
142
|
+
* remove something and check both loaders react the same way. */
|
|
143
|
+
function faceDataFor(specs: string[], broken?: (f: Record<string, { normal: number[] }>) => void) {
|
|
144
|
+
const faces: Record<string, { normal: number[] }> = {}
|
|
145
|
+
const requested: Record<string, { normal: number[] }> = {}
|
|
146
|
+
for (const spec of specs) {
|
|
147
|
+
const family = familyOf(spec)
|
|
148
|
+
const axes = spec.split(':')[1]
|
|
149
|
+
const weights = axes ? axes.slice('wght@'.length).split(';').map(Number) : [400]
|
|
150
|
+
requested[family] = { normal: weights }
|
|
151
|
+
// Bebas Neue publishes no 700, so it received only the 400 it asked for.
|
|
152
|
+
faces[family] = { normal: family === 'Bebas Neue' ? [400] : weights }
|
|
153
|
+
}
|
|
154
|
+
broken?.(faces)
|
|
155
|
+
return { faces, requested }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type Faces = Record<string, { normal: number[] }>
|
|
159
|
+
const CASES: [string, (specs: string[]) => { faces: Faces; requested?: Faces }][] = [
|
|
160
|
+
// Every picker spec satisfied — including Bebas Neue, and only because
|
|
161
|
+
// `requested` says Google was asked for the 700 and declined.
|
|
162
|
+
['the real shipped manifest', (s) => faceDataFor(s)],
|
|
163
|
+
// Drop `requested` and Bebas Neue's missing 700 sends that spec to Google.
|
|
164
|
+
// Both loaders must send it, or neither.
|
|
165
|
+
['faces only, no requested — Bebas Neue crosses to Google', (s) => ({ faces: faceDataFor(s).faces })],
|
|
166
|
+
// A weight removed from a family every picker spec asks for at 700.
|
|
167
|
+
['a weight missing from Inter', (s) => faceDataFor(s, (f) => { f.Inter = { normal: [400] } })],
|
|
168
|
+
// Face information that covers nothing at all.
|
|
169
|
+
['an empty faces map', () => ({ faces: {} })],
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
for (const [label, dataFor] of CASES) {
|
|
173
|
+
it(label, async () => {
|
|
174
|
+
const specs = await pickerSpecs()
|
|
175
|
+
const families = specs.map(familyOf)
|
|
176
|
+
const faceData = dataFor(specs)
|
|
177
|
+
const base = `https://example.com/fonts/faceparity-${label.replace(/[^a-z]+/gi, '-')}`
|
|
178
|
+
|
|
179
|
+
setPickerBase(base, families, faceData)
|
|
180
|
+
render(<FontFamilyPicker value="" onChange={() => {}} />)
|
|
181
|
+
const picker = injectedHrefs()
|
|
182
|
+
|
|
183
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
184
|
+
|
|
185
|
+
setOverlayBase(base, families, faceData)
|
|
186
|
+
ensureGoogleFontsLoaded(specs)
|
|
187
|
+
const overlay = injectedHrefs()
|
|
188
|
+
|
|
189
|
+
expect(googleSpecs(overlay)).toEqual(googleSpecs(picker))
|
|
190
|
+
expect(linkedVendored(overlay)).toBe(linkedVendored(picker))
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The property the whole feature is for, asserted on the real shape: with
|
|
195
|
+
// the shipped manifest neither loader touches Google at all.
|
|
196
|
+
it('with the shipped manifest, NEITHER loader reaches Google', async () => {
|
|
197
|
+
const specs = await pickerSpecs()
|
|
198
|
+
const families = specs.map(familyOf)
|
|
199
|
+
const faceData = faceDataFor(specs)
|
|
200
|
+
const base = 'https://example.com/fonts/faceparity-zero-egress'
|
|
201
|
+
|
|
202
|
+
setPickerBase(base, families, faceData)
|
|
203
|
+
render(<FontFamilyPicker value="" onChange={() => {}} />)
|
|
204
|
+
expect(googleSpecs(injectedHrefs())).toEqual([])
|
|
205
|
+
expect(linkedVendored(injectedHrefs())).toBe(true)
|
|
206
|
+
|
|
207
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
208
|
+
|
|
209
|
+
setOverlayBase(base, families, faceData)
|
|
210
|
+
ensureGoogleFontsLoaded(specs)
|
|
211
|
+
expect(googleSpecs(injectedHrefs())).toEqual([])
|
|
212
|
+
expect(linkedVendored(injectedHrefs())).toBe(true)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// Both loaders log the same digest for the same manifest — the value that
|
|
216
|
+
// pins the TS↔JS seam. If one passed the face index and the other did not,
|
|
217
|
+
// the two lines would differ and this is where it would show.
|
|
218
|
+
it('both setters log the same digest for the same manifest', async () => {
|
|
219
|
+
const info = vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
220
|
+
const specs = await pickerSpecs()
|
|
221
|
+
const families = specs.map(familyOf)
|
|
222
|
+
const faceData = faceDataFor(specs)
|
|
223
|
+
|
|
224
|
+
setPickerBase('https://example.com/fonts/digest-picker', families, faceData)
|
|
225
|
+
setOverlayBase('https://example.com/fonts/digest-overlay', families, faceData)
|
|
226
|
+
|
|
227
|
+
const digests = info.mock.calls
|
|
228
|
+
.map((c) => String(c[0]).match(/vendored set ([0-9a-f]{8})/)?.[1])
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
expect(digests).toHaveLength(2)
|
|
231
|
+
expect(digests[0]).toBe(digests[1])
|
|
232
|
+
// The same literal the render suite pins, so a divergence between the two
|
|
233
|
+
// languages surfaces here as well as there.
|
|
234
|
+
expect(digests[0]).toBe('1fcf41c1')
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import { ensureGoogleFontsLoaded } from '../google-fonts'
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { ensureGoogleFontsLoaded, setFontsBaseUrl, vendoredFamiliesDigest } from '../google-fonts'
|
|
3
|
+
import { fontFamilyKey, familiesDigest, partitionFontSpecs, vendoredFaceIndex, vendoredKeySet } from '../font-families'
|
|
3
4
|
|
|
4
5
|
// Each test injects fonts that have not been requested by any prior test so the
|
|
5
6
|
// module-level dedupe Set never short-circuits the <link> append we assert on.
|
|
@@ -11,7 +12,21 @@ function injectedHrefs(): string[] {
|
|
|
11
12
|
|
|
12
13
|
beforeEach(() => {
|
|
13
14
|
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
15
|
+
// The setters log a line per call; individual tests that assert on logging
|
|
16
|
+
// install their own spies.
|
|
17
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
18
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
14
19
|
})
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
setFontsBaseUrl(undefined)
|
|
22
|
+
vi.restoreAllMocks()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// A base per test. The injected-URL Set is module state that outlives a single
|
|
26
|
+
// test (only document.head is reset in beforeEach), so two tests producing the
|
|
27
|
+
// same URL would see the second one suppressed — and it would pass for the
|
|
28
|
+
// wrong reason. Keep every base, and every unvendored family, unique.
|
|
29
|
+
const B = (name: string) => `https://example.com/fonts/${name}`
|
|
15
30
|
|
|
16
31
|
describe('ensureGoogleFontsLoaded', () => {
|
|
17
32
|
it('accepts a proper string[] and appends one <link> with each family', () => {
|
|
@@ -54,3 +69,305 @@ describe('ensureGoogleFontsLoaded', () => {
|
|
|
54
69
|
expect(hrefs[0]).toContain('family=Inter')
|
|
55
70
|
})
|
|
56
71
|
})
|
|
72
|
+
|
|
73
|
+
// The loader is FULLY SYNCHRONOUS and must stay that way. It is called from
|
|
74
|
+
// effects and has to decide the partition before it can act, so the vendored
|
|
75
|
+
// family list is passed into the setter by the host rather than fetched here:
|
|
76
|
+
// a pending fetch would leave the loader with no good move — it cannot block,
|
|
77
|
+
// guessing is silently wrong, and re-injecting once the answer lands leaves
|
|
78
|
+
// two competing stylesheets with the stale one never pruned.
|
|
79
|
+
describe('setFontsBaseUrl', () => {
|
|
80
|
+
it('unset produces exactly the default per-family googleapis URL', () => {
|
|
81
|
+
ensureGoogleFontsLoaded(['Syne:wght@800'])
|
|
82
|
+
expect(injectedHrefs()).toEqual(['https://fonts.googleapis.com/css2?family=Syne:wght@800&display=swap'])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('never fetches — there is no network call anywhere in this loader', () => {
|
|
86
|
+
const fetchSpy = vi.fn()
|
|
87
|
+
vi.stubGlobal('fetch', fetchSpy)
|
|
88
|
+
try {
|
|
89
|
+
setFontsBaseUrl(B('no-fetch'), ['Karla'])
|
|
90
|
+
ensureGoogleFontsLoaded(['Karla:wght@800'])
|
|
91
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
92
|
+
} finally {
|
|
93
|
+
vi.unstubAllGlobals()
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('a vendored family produces <base>/fonts.css ONLY — no googleapis link, no preconnect, no egress', () => {
|
|
98
|
+
const base = B('all-vendored')
|
|
99
|
+
setFontsBaseUrl(base, ['Syne', 'Inter'])
|
|
100
|
+
ensureGoogleFontsLoaded(['Syne:wght@800'])
|
|
101
|
+
expect(injectedHrefs()).toEqual([`${base}/fonts.css`])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('setting a base after a prior injection still injects — a stale guard must not suppress it', () => {
|
|
105
|
+
ensureGoogleFontsLoaded(['Zilla:wght@800'])
|
|
106
|
+
expect(injectedHrefs()).toHaveLength(1)
|
|
107
|
+
|
|
108
|
+
const base = B('another-base')
|
|
109
|
+
setFontsBaseUrl(base, ['Zilla'])
|
|
110
|
+
ensureGoogleFontsLoaded(['Zilla:wght@800'])
|
|
111
|
+
|
|
112
|
+
const hrefs = injectedHrefs()
|
|
113
|
+
expect(hrefs).toHaveLength(2)
|
|
114
|
+
expect(hrefs).toContain(`${base}/fonts.css`)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('logs the vendored set digest once per setter call', () => {
|
|
118
|
+
const info = vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
119
|
+
const base = B('digest-logged')
|
|
120
|
+
setFontsBaseUrl(base, ['Inter', 'Baloo 2'])
|
|
121
|
+
expect(info).toHaveBeenCalledOnce()
|
|
122
|
+
expect(info.mock.calls[0][0]).toContain(vendoredFamiliesDigest())
|
|
123
|
+
expect(info.mock.calls[0][0]).toContain('2 families')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('vendoredFamiliesDigest is empty with no base, and stable across equivalent spellings', () => {
|
|
127
|
+
expect(vendoredFamiliesDigest()).toBe('')
|
|
128
|
+
setFontsBaseUrl(B('digest-a'), ['Baloo 2', 'Inter'])
|
|
129
|
+
const a = vendoredFamiliesDigest()
|
|
130
|
+
// Same set, different order and spelling — the digest sorts and normalises.
|
|
131
|
+
setFontsBaseUrl(B('digest-b'), ['inter', 'Baloo+2'])
|
|
132
|
+
expect(vendoredFamiliesDigest()).toBe(a)
|
|
133
|
+
expect(a).not.toBe('')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// The partition (L1 fall-through)
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
//
|
|
141
|
+
// The vendored stylesheet declares the editor's twenty picker families and
|
|
142
|
+
// nothing else, but `googleFonts` comes out of project data and
|
|
143
|
+
// skills/write-overlay documents arbitrary Google families as first-class —
|
|
144
|
+
// its own worked example is `["Anton", "Playfair+Display:ital@1"]`, and Anton
|
|
145
|
+
// is not a picker family. Linking the vendored sheet and dropping the
|
|
146
|
+
// requested entries renders those in a fallback face with nothing on screen
|
|
147
|
+
// to say so, which is what this partition exists to stop.
|
|
148
|
+
//
|
|
149
|
+
// Every assertion here has a twin on the render side
|
|
150
|
+
// (`montaj_assets/render/test/fonts-fallthrough.test.mjs`). The two sides MUST
|
|
151
|
+
// partition identically: a family loaded locally while editing and from
|
|
152
|
+
// Google at export time — or vice versa — is the caption-shifts-between-
|
|
153
|
+
// preview-and-export bug the Syne case study documents.
|
|
154
|
+
describe('ensureGoogleFontsLoaded: unvendored families fall through to Google', () => {
|
|
155
|
+
it('an unvendored family gets a googleapis link alongside the vendored stylesheet', () => {
|
|
156
|
+
const base = B('mix-basic')
|
|
157
|
+
setFontsBaseUrl(base, ['Inter', 'Baloo 2'])
|
|
158
|
+
ensureGoogleFontsLoaded(['Inter:wght@400', 'Anton', 'Syne:wght@800'])
|
|
159
|
+
expect(injectedHrefs()).toEqual([
|
|
160
|
+
`${base}/fonts.css`,
|
|
161
|
+
'https://fonts.googleapis.com/css2?family=Anton&family=Syne:wght@800&display=swap',
|
|
162
|
+
])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('a MIX splits: vendored families stay local, unvendored ones go to Google, and neither list leaks into the other', () => {
|
|
166
|
+
const base = B('mixed')
|
|
167
|
+
setFontsBaseUrl(base, ['Baloo 2', 'Playfair Display', 'Inter'])
|
|
168
|
+
ensureGoogleFontsLoaded(['Baloo+2:wght@400;500', 'Bitter', 'Playfair+Display:ital@1'])
|
|
169
|
+
|
|
170
|
+
const hrefs = injectedHrefs()
|
|
171
|
+
expect(hrefs).toHaveLength(2)
|
|
172
|
+
expect(hrefs[0]).toBe(`${base}/fonts.css`)
|
|
173
|
+
// Only Bitter crosses to Google. Baloo 2 and Playfair Display are vendored,
|
|
174
|
+
// so their specs must NOT appear in the googleapis URL — a regression that
|
|
175
|
+
// fell everything through would still look right and would silently
|
|
176
|
+
// restore the egress this feature removes.
|
|
177
|
+
expect(hrefs[1]).toBe('https://fonts.googleapis.com/css2?family=Bitter&display=swap')
|
|
178
|
+
expect(hrefs[1]).not.toContain('Baloo')
|
|
179
|
+
expect(hrefs[1]).not.toContain('Playfair')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('all-unvendored does not link the vendored stylesheet at all — it would serve nothing', () => {
|
|
183
|
+
const base = B('all-unvendored')
|
|
184
|
+
setFontsBaseUrl(base, ['Inter', 'Baloo 2'])
|
|
185
|
+
ensureGoogleFontsLoaded(['Amiri', 'Cabin:wght@400'])
|
|
186
|
+
expect(injectedHrefs()).toEqual([
|
|
187
|
+
'https://fonts.googleapis.com/css2?family=Amiri&family=Cabin:wght@400&display=swap',
|
|
188
|
+
])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('logs which families were fetched from Google, and not the ones that were not', () => {
|
|
192
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
193
|
+
const base = B('logged')
|
|
194
|
+
setFontsBaseUrl(base, ['Inter'])
|
|
195
|
+
ensureGoogleFontsLoaded(['Inter:wght@400', 'Cantata+One'])
|
|
196
|
+
expect(warn).toHaveBeenCalledOnce()
|
|
197
|
+
expect(warn.mock.calls[0][0]).toContain('Cantata+One')
|
|
198
|
+
expect(warn.mock.calls[0][0]).toContain('fonts.googleapis.com')
|
|
199
|
+
expect(warn.mock.calls[0][0]).not.toContain('Inter')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('does not re-log per overlay', () => {
|
|
203
|
+
const base = B('repeat')
|
|
204
|
+
setFontsBaseUrl(base, ['Inter'])
|
|
205
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
206
|
+
ensureGoogleFontsLoaded(['Eczar'])
|
|
207
|
+
ensureGoogleFontsLoaded(['Eczar'])
|
|
208
|
+
expect(warn).toHaveBeenCalledOnce()
|
|
209
|
+
expect(injectedHrefs()).toHaveLength(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('the defensive string coercion still holds with a base set', () => {
|
|
213
|
+
const base = B('coercion')
|
|
214
|
+
setFontsBaseUrl(base, ['Inter'])
|
|
215
|
+
// A bare string used to throw "n.map is not a function" and surface as
|
|
216
|
+
// "overlay error: <file>.jsx", taking the whole overlay layer with it.
|
|
217
|
+
expect(() => ensureGoogleFontsLoaded('Dosis,Inter:wght@400' as unknown as string[])).not.toThrow()
|
|
218
|
+
expect(injectedHrefs()).toEqual([
|
|
219
|
+
`${base}/fonts.css`,
|
|
220
|
+
'https://fonts.googleapis.com/css2?family=Dosis&display=swap',
|
|
221
|
+
])
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// THE FAILURE MODE, and it is deliberate in this direction.
|
|
226
|
+
//
|
|
227
|
+
// A base with no family list means NOTHING is vendored: no vendored <link>,
|
|
228
|
+
// every family from Google. The tempting opposite — assume the sheet covers
|
|
229
|
+
// what was asked for — is the SILENT-wrong option: an unvendored family would
|
|
230
|
+
// get no stylesheet at all and preview as a system fallback, while the render
|
|
231
|
+
// (reading its manifest off local disk, where it cannot fail independently)
|
|
232
|
+
// fetched that same family from Google and got it right. Preview and export
|
|
233
|
+
// would disagree with nothing on screen to say so — the Syne bug exactly.
|
|
234
|
+
// This way is loud-wrong: glyphs correct, both sides agreeing, cost is egress,
|
|
235
|
+
// and egress is the one failure the logging already detects.
|
|
236
|
+
describe('setFontsBaseUrl: a base with no family list', () => {
|
|
237
|
+
for (const [label, families] of [
|
|
238
|
+
['omitted', undefined],
|
|
239
|
+
['empty', []],
|
|
240
|
+
['not an array', 'Inter'],
|
|
241
|
+
['all non-strings', [7, null]],
|
|
242
|
+
] as [string, unknown][]) {
|
|
243
|
+
it(`${label}: nothing is vendored, the stylesheet is not linked, everything goes to Google`, () => {
|
|
244
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
245
|
+
const base = B(`nolist-${label.replace(/\s/g, '-')}`)
|
|
246
|
+
setFontsBaseUrl(base, families as string[] | undefined)
|
|
247
|
+
ensureGoogleFontsLoaded([`Q${label.replace(/\s/g, '')}`, 'Inter:wght@400'])
|
|
248
|
+
|
|
249
|
+
const hrefs = injectedHrefs()
|
|
250
|
+
expect(hrefs).toHaveLength(1)
|
|
251
|
+
expect(hrefs[0]).toContain('fonts.googleapis.com')
|
|
252
|
+
expect(hrefs[0]).toContain('family=Inter:wght@400')
|
|
253
|
+
expect(hrefs[0]).not.toContain('fonts.css')
|
|
254
|
+
expect(warn.mock.calls[0][0]).toContain('treating NOTHING as vendored')
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// The shared partition primitives
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
describe('fontFamilyKey: a spec normalises to the family name fonts.css declares', () => {
|
|
263
|
+
for (const [spec, key] of [
|
|
264
|
+
['Baloo+2:wght@400;500', 'baloo 2'],
|
|
265
|
+
['Playfair+Display:ital@1', 'playfair display'],
|
|
266
|
+
['Anton', 'anton'],
|
|
267
|
+
['Source+Serif+4:wght@400;700', 'source serif 4'],
|
|
268
|
+
['Inter:wght@400;600;700;800', 'inter'],
|
|
269
|
+
['Playfair Display', 'playfair display'], // a literal space, not '+'
|
|
270
|
+
[' Anton ', 'anton'],
|
|
271
|
+
['DM+SANS', 'dm sans'], // CSS family matching is case-insensitive
|
|
272
|
+
]) {
|
|
273
|
+
it(`${JSON.stringify(spec)} → ${JSON.stringify(key)}`, () => {
|
|
274
|
+
expect(fontFamilyKey(spec)).toBe(key)
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
describe('partitionFontSpecs', () => {
|
|
280
|
+
it('preserves the caller order within each side', () => {
|
|
281
|
+
const keys = vendoredKeySet(['Inter', 'Baloo 2'])
|
|
282
|
+
expect(partitionFontSpecs(['Anton', 'Inter:wght@400', 'Syne', 'Baloo+2'], keys)).toEqual({
|
|
283
|
+
vendored: ['Inter:wght@400', 'Baloo+2'],
|
|
284
|
+
fellThrough: ['Anton', 'Syne'],
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('an empty vendored set falls everything through', () => {
|
|
289
|
+
expect(partitionFontSpecs(['Anton', 'Inter'], new Set())).toEqual({
|
|
290
|
+
vendored: [],
|
|
291
|
+
fellThrough: ['Anton', 'Inter'],
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('drops non-string list entries rather than coercing them into families', () => {
|
|
296
|
+
// String(7) would otherwise become a "family" named "7".
|
|
297
|
+
expect(vendoredKeySet(['Inter', 7, null, 'Baloo 2'] as unknown[])).toEqual(
|
|
298
|
+
new Set(['inter', 'baloo 2']),
|
|
299
|
+
)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// THE CROSS-LANGUAGE GATE. The renderers carry this same algorithm in plain
|
|
304
|
+
// JS; a textual parity test cannot span TypeScript and JavaScript, so both
|
|
305
|
+
// suites pin the SAME literal digest for the SAME family list instead. If this
|
|
306
|
+
// value and the one in `montaj_assets/render/test/fonts-fallthrough.test.mjs`
|
|
307
|
+
// ever disagree, the editor and the renderers are fingerprinting differently
|
|
308
|
+
// and the digest stops being able to prove they agree — which is the only job
|
|
309
|
+
// it has. Change one, change the other, and only ever deliberately.
|
|
310
|
+
describe('familiesDigest', () => {
|
|
311
|
+
const PRODUCTION_20 = [
|
|
312
|
+
'Baloo 2', 'Bebas Neue', 'DM Sans', 'Fredoka', 'Inter', 'JetBrains Mono', 'Lato',
|
|
313
|
+
'Merriweather', 'Montserrat', 'Nunito', 'Open Sans', 'Oswald', 'Playfair Display',
|
|
314
|
+
'Poppins', 'Raleway', 'Roboto', 'Rubik', 'Sniglet', 'Source Serif 4', 'Work Sans',
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
// The faces those twenty actually resolve to, as `families.json` records
|
|
318
|
+
// them. Bebas Neue is the one family whose `faces` and `requested` differ:
|
|
319
|
+
// it publishes no 700, so we asked for one and received only the 400.
|
|
320
|
+
const W: Record<string, number[]> = {
|
|
321
|
+
'Baloo 2': [400, 500, 600, 700, 800], Fredoka: [300, 400, 500, 600, 700], Sniglet: [400, 800],
|
|
322
|
+
}
|
|
323
|
+
const faces: Record<string, { normal: number[] }> = {}
|
|
324
|
+
const requested: Record<string, { normal: number[] }> = {}
|
|
325
|
+
for (const f of PRODUCTION_20) {
|
|
326
|
+
requested[f] = { normal: W[f] ?? [400, 700] }
|
|
327
|
+
faces[f] = { normal: f === 'Bebas Neue' ? [400] : (W[f] ?? [400, 700]) }
|
|
328
|
+
}
|
|
329
|
+
const INDEX = vendoredFaceIndex(faces, requested)
|
|
330
|
+
|
|
331
|
+
it('the shipped manifest has the digest the render suite also pins', () => {
|
|
332
|
+
expect(familiesDigest(vendoredKeySet(PRODUCTION_20), INDEX)).toBe('1fcf41c1')
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// Unchanged from before faces existed, which is the point: a manifest
|
|
336
|
+
// carrying no face information still digests exactly as an older renderer
|
|
337
|
+
// would, so the two stay comparable.
|
|
338
|
+
it('a manifest with no face information keeps its original digest', () => {
|
|
339
|
+
expect(familiesDigest(vendoredKeySet(PRODUCTION_20))).toBe('63d7e733')
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// The reason the digest had to grow: same twenty family NAMES, one weight
|
|
343
|
+
// fewer. A families-only fingerprint reports a match here, which is exactly
|
|
344
|
+
// the silent drift it exists to make loud.
|
|
345
|
+
it('dropping a single WEIGHT moves the digest, though every family name is unchanged', () => {
|
|
346
|
+
const thinner = vendoredFaceIndex(
|
|
347
|
+
{ ...faces, Inter: { normal: [400] } },
|
|
348
|
+
{ ...requested, Inter: { normal: [400] } },
|
|
349
|
+
)
|
|
350
|
+
expect(familiesDigest(vendoredKeySet(PRODUCTION_20), thinner)).toBe('7b42f0b4')
|
|
351
|
+
expect(familiesDigest(vendoredKeySet(PRODUCTION_20), thinner))
|
|
352
|
+
.not.toBe(familiesDigest(vendoredKeySet(PRODUCTION_20), INDEX))
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('gaining an ITALIC face moves the digest — the style axis is in the input too', () => {
|
|
356
|
+
const italic = vendoredFaceIndex(
|
|
357
|
+
{ ...faces, 'Playfair Display': { normal: [400, 700], italic: [400] } },
|
|
358
|
+
requested,
|
|
359
|
+
)
|
|
360
|
+
expect(familiesDigest(vendoredKeySet(PRODUCTION_20), italic))
|
|
361
|
+
.not.toBe(familiesDigest(vendoredKeySet(PRODUCTION_20), INDEX))
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('is order- and spelling-independent, and changes when the set changes', () => {
|
|
365
|
+
const a = familiesDigest(vendoredKeySet(['Inter', 'Baloo 2']))
|
|
366
|
+
expect(familiesDigest(vendoredKeySet(['Baloo+2', 'inter']))).toBe(a)
|
|
367
|
+
expect(familiesDigest(vendoredKeySet(['Inter']))).not.toBe(a)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('is eight hex characters', () => {
|
|
371
|
+
expect(familiesDigest(vendoredKeySet(PRODUCTION_20))).toMatch(/^[0-9a-f]{8}$/)
|
|
372
|
+
})
|
|
373
|
+
})
|