@dfosco/storyboard-react 1.1.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/package.json +29 -0
- package/src/StoryboardContext.js +3 -0
- package/src/__mocks__/virtual-storyboard-data-index.js +3 -0
- package/src/context/FormContext.js +13 -0
- package/src/context/FormContext.test.js +48 -0
- package/src/context.jsx +78 -0
- package/src/context.test.jsx +102 -0
- package/src/hashPreserver.js +73 -0
- package/src/hashPreserver.test.js +107 -0
- package/src/hooks/useHideMode.js +31 -0
- package/src/hooks/useHideMode.test.js +43 -0
- package/src/hooks/useLocalStorage.js +57 -0
- package/src/hooks/useLocalStorage.test.js +76 -0
- package/src/hooks/useOverride.js +80 -0
- package/src/hooks/useOverride.test.js +66 -0
- package/src/hooks/useRecord.js +130 -0
- package/src/hooks/useRecord.test.js +81 -0
- package/src/hooks/useRecordOverride.js +22 -0
- package/src/hooks/useRecordOverride.test.js +52 -0
- package/src/hooks/useScene.js +28 -0
- package/src/hooks/useScene.test.js +39 -0
- package/src/hooks/useSceneData.js +97 -0
- package/src/hooks/useSceneData.test.js +108 -0
- package/src/hooks/useSession.js +4 -0
- package/src/hooks/useSession.test.js +8 -0
- package/src/hooks/useUndoRedo.js +28 -0
- package/src/hooks/useUndoRedo.test.js +64 -0
- package/src/index.js +27 -0
- package/src/test-utils.js +42 -0
- package/src/vite/data-plugin.js +151 -0
- package/src/vite/data-plugin.test.js +127 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react'
|
|
2
|
+
import { useSceneData, useSceneLoading } from './useSceneData.js'
|
|
3
|
+
import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
|
|
4
|
+
|
|
5
|
+
const sceneData = TEST_SCENES.default
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
seedTestData()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
describe('useSceneData', () => {
|
|
12
|
+
it('returns entire scene object when no path given', () => {
|
|
13
|
+
const { result } = renderHook(() => useSceneData(), {
|
|
14
|
+
wrapper: createWrapper(sceneData),
|
|
15
|
+
})
|
|
16
|
+
expect(result.current).toEqual(sceneData)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns nested value by dot-notation path', () => {
|
|
20
|
+
const { result } = renderHook(() => useSceneData('user.name'), {
|
|
21
|
+
wrapper: createWrapper(sceneData),
|
|
22
|
+
})
|
|
23
|
+
expect(result.current).toBe('Jane')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns deep nested value', () => {
|
|
27
|
+
const { result } = renderHook(() => useSceneData('user.profile.bio'), {
|
|
28
|
+
wrapper: createWrapper(sceneData),
|
|
29
|
+
})
|
|
30
|
+
expect(result.current).toBe('Dev')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns array by path', () => {
|
|
34
|
+
const { result } = renderHook(() => useSceneData('projects'), {
|
|
35
|
+
wrapper: createWrapper(sceneData),
|
|
36
|
+
})
|
|
37
|
+
expect(result.current).toEqual([
|
|
38
|
+
{ id: 1, name: 'alpha' },
|
|
39
|
+
{ id: 2, name: 'beta' },
|
|
40
|
+
])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns array element by index path', () => {
|
|
44
|
+
const { result } = renderHook(() => useSceneData('projects.0'), {
|
|
45
|
+
wrapper: createWrapper(sceneData),
|
|
46
|
+
})
|
|
47
|
+
expect(result.current).toEqual({ id: 1, name: 'alpha' })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns empty object and warns for missing path', () => {
|
|
51
|
+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
52
|
+
const { result } = renderHook(() => useSceneData('nonexistent.path'), {
|
|
53
|
+
wrapper: createWrapper(sceneData),
|
|
54
|
+
})
|
|
55
|
+
expect(result.current).toEqual({})
|
|
56
|
+
expect(spy).toHaveBeenCalledWith(
|
|
57
|
+
expect.stringContaining('nonexistent.path')
|
|
58
|
+
)
|
|
59
|
+
spy.mockRestore()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('throws when used outside StoryboardProvider', () => {
|
|
63
|
+
expect(() => {
|
|
64
|
+
renderHook(() => useSceneData())
|
|
65
|
+
}).toThrow('useSceneData must be used within a <StoryboardProvider>')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns hash override value when param matches path', () => {
|
|
69
|
+
window.location.hash = '#user.name=Alice'
|
|
70
|
+
const { result } = renderHook(() => useSceneData('user.name'), {
|
|
71
|
+
wrapper: createWrapper(sceneData),
|
|
72
|
+
})
|
|
73
|
+
expect(result.current).toBe('Alice')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('applies child overrides to arrays', () => {
|
|
77
|
+
window.location.hash = '#projects.0.name=gamma'
|
|
78
|
+
const { result } = renderHook(() => useSceneData('projects'), {
|
|
79
|
+
wrapper: createWrapper(sceneData),
|
|
80
|
+
})
|
|
81
|
+
expect(result.current[0].name).toBe('gamma')
|
|
82
|
+
expect(result.current[1].name).toBe('beta')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns full scene with all overrides applied when no path', () => {
|
|
86
|
+
window.location.hash = '#user.name=Alice'
|
|
87
|
+
const { result } = renderHook(() => useSceneData(), {
|
|
88
|
+
wrapper: createWrapper(sceneData),
|
|
89
|
+
})
|
|
90
|
+
expect(result.current.user.name).toBe('Alice')
|
|
91
|
+
expect(result.current.settings.theme).toBe('dark')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('useSceneLoading', () => {
|
|
96
|
+
it('returns false when not loading', () => {
|
|
97
|
+
const { result } = renderHook(() => useSceneLoading(), {
|
|
98
|
+
wrapper: createWrapper(sceneData),
|
|
99
|
+
})
|
|
100
|
+
expect(result.current).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('throws when used outside StoryboardProvider', () => {
|
|
104
|
+
expect(() => {
|
|
105
|
+
renderHook(() => useSceneLoading())
|
|
106
|
+
}).toThrow('useSceneLoading must be used within a <StoryboardProvider>')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react'
|
|
2
|
+
import { undo, redo, canUndo, canRedo } from '@dfosco/storyboard-core'
|
|
3
|
+
import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
|
|
4
|
+
import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Undo/redo controls for override history.
|
|
8
|
+
*
|
|
9
|
+
* Every override write (via useOverride) pushes a snapshot to the history
|
|
10
|
+
* stack. This hook exposes navigation through that stack.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ undo: function, redo: function, canUndo: boolean, canRedo: boolean }}
|
|
13
|
+
*/
|
|
14
|
+
export function useUndoRedo() {
|
|
15
|
+
// Re-render on storage or hash changes
|
|
16
|
+
useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
|
|
17
|
+
useSyncExternalStore(subscribeToHash, getHashSnapshot)
|
|
18
|
+
|
|
19
|
+
const handleUndo = useCallback(() => undo(), [])
|
|
20
|
+
const handleRedo = useCallback(() => redo(), [])
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
undo: handleUndo,
|
|
24
|
+
redo: handleRedo,
|
|
25
|
+
canUndo: canUndo(),
|
|
26
|
+
canRedo: canRedo(),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import { seedTestData } from '../../test-utils.js'
|
|
3
|
+
import { pushSnapshot } from '@dfosco/storyboard-core'
|
|
4
|
+
import { useUndoRedo } from './useUndoRedo.js'
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
seedTestData()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('useUndoRedo', () => {
|
|
11
|
+
it('returns { undo, redo, canUndo, canRedo }', () => {
|
|
12
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
13
|
+
expect(typeof result.current.undo).toBe('function')
|
|
14
|
+
expect(typeof result.current.redo).toBe('function')
|
|
15
|
+
expect(typeof result.current.canUndo).toBe('boolean')
|
|
16
|
+
expect(typeof result.current.canRedo).toBe('boolean')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('canUndo and canRedo are false initially', () => {
|
|
20
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
21
|
+
expect(result.current.canUndo).toBe(false)
|
|
22
|
+
expect(result.current.canRedo).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('after pushing snapshots and undoing, canUndo/canRedo reflect state', () => {
|
|
26
|
+
// Pre-populate history with 3 entries so we can undo twice
|
|
27
|
+
pushSnapshot('a=1', '/')
|
|
28
|
+
pushSnapshot('b=2', '/')
|
|
29
|
+
pushSnapshot('c=3', '/')
|
|
30
|
+
|
|
31
|
+
const { result, rerender } = renderHook(() => useUndoRedo())
|
|
32
|
+
|
|
33
|
+
// At index 2 (last entry), can undo but not redo
|
|
34
|
+
expect(result.current.canUndo).toBe(true)
|
|
35
|
+
expect(result.current.canRedo).toBe(false)
|
|
36
|
+
|
|
37
|
+
act(() => {
|
|
38
|
+
result.current.undo()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
rerender()
|
|
42
|
+
// At index 1, can undo and redo
|
|
43
|
+
expect(result.current.canUndo).toBe(true)
|
|
44
|
+
expect(result.current.canRedo).toBe(true)
|
|
45
|
+
|
|
46
|
+
act(() => {
|
|
47
|
+
result.current.undo()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
rerender()
|
|
51
|
+
// At index 0, cannot undo but can redo
|
|
52
|
+
expect(result.current.canUndo).toBe(false)
|
|
53
|
+
expect(result.current.canRedo).toBe(true)
|
|
54
|
+
|
|
55
|
+
act(() => {
|
|
56
|
+
result.current.redo()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
rerender()
|
|
60
|
+
// At index 1 again, can undo and redo
|
|
61
|
+
expect(result.current.canUndo).toBe(true)
|
|
62
|
+
expect(result.current.canRedo).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
})
|
package/src/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dfosco/storyboard-react — React framework binding for Storyboard.
|
|
3
|
+
*
|
|
4
|
+
* Provides hooks, context, and provider for React apps.
|
|
5
|
+
* Design-system-agnostic — no Primer, Reshaped, or other UI library deps.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Context & Provider
|
|
9
|
+
export { default as StoryboardProvider } from './context.jsx'
|
|
10
|
+
export { StoryboardContext } from './StoryboardContext.js'
|
|
11
|
+
|
|
12
|
+
// Hooks
|
|
13
|
+
export { useSceneData, useSceneLoading } from './hooks/useSceneData.js'
|
|
14
|
+
export { useOverride } from './hooks/useOverride.js'
|
|
15
|
+
export { useOverride as useSession } from './hooks/useOverride.js' // deprecated alias
|
|
16
|
+
export { useScene } from './hooks/useScene.js'
|
|
17
|
+
export { useRecord, useRecords } from './hooks/useRecord.js'
|
|
18
|
+
export { useRecordOverride } from './hooks/useRecordOverride.js'
|
|
19
|
+
export { useLocalStorage } from './hooks/useLocalStorage.js'
|
|
20
|
+
export { useHideMode } from './hooks/useHideMode.js'
|
|
21
|
+
export { useUndoRedo } from './hooks/useUndoRedo.js'
|
|
22
|
+
|
|
23
|
+
// React Router integration
|
|
24
|
+
export { installHashPreserver } from './hashPreserver.js'
|
|
25
|
+
|
|
26
|
+
// Form context (for design system packages to use)
|
|
27
|
+
export { FormContext } from './context/FormContext.js'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createElement } from 'react'
|
|
2
|
+
import { StoryboardContext } from './StoryboardContext.js'
|
|
3
|
+
import { init } from '@dfosco/storyboard-core'
|
|
4
|
+
|
|
5
|
+
// Default test data
|
|
6
|
+
export const TEST_SCENES = {
|
|
7
|
+
default: {
|
|
8
|
+
user: { name: 'Jane', profile: { bio: 'Dev' } },
|
|
9
|
+
settings: { theme: 'dark' },
|
|
10
|
+
projects: [{ id: 1, name: 'alpha' }, { id: 2, name: 'beta' }],
|
|
11
|
+
},
|
|
12
|
+
other: {
|
|
13
|
+
user: { name: 'Bob' },
|
|
14
|
+
settings: { theme: 'light' },
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const TEST_OBJECTS = {
|
|
19
|
+
'jane-doe': { name: 'Jane Doe', role: 'admin' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const TEST_RECORDS = {
|
|
23
|
+
posts: [
|
|
24
|
+
{ id: 'post-1', title: 'First Post', author: 'Jane' },
|
|
25
|
+
{ id: 'post-2', title: 'Second Post', author: 'Bob' },
|
|
26
|
+
],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function seedTestData() {
|
|
30
|
+
init({ scenes: TEST_SCENES, objects: TEST_OBJECTS, records: TEST_RECORDS })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Wrapper that provides StoryboardContext with given scene data
|
|
34
|
+
export function createWrapper(sceneData, sceneName = 'default') {
|
|
35
|
+
return function Wrapper({ children }) {
|
|
36
|
+
return createElement(
|
|
37
|
+
StoryboardContext.Provider,
|
|
38
|
+
{ value: { data: sceneData, error: null, loading: false, sceneName } },
|
|
39
|
+
children
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { globSync } from 'glob'
|
|
4
|
+
import { parse as parseJsonc } from 'jsonc-parser'
|
|
5
|
+
|
|
6
|
+
const VIRTUAL_MODULE_ID = 'virtual:storyboard-data-index'
|
|
7
|
+
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
|
|
8
|
+
|
|
9
|
+
const SUFFIXES = ['scene', 'object', 'record']
|
|
10
|
+
const GLOB_PATTERN = '**/*.{scene,object,record}.{json,jsonc}'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract the data name and type suffix from a file path.
|
|
14
|
+
* e.g. "src/data/default.scene.json" → { name: "default", suffix: "scene" }
|
|
15
|
+
* "anywhere/posts.record.jsonc" → { name: "posts", suffix: "record" }
|
|
16
|
+
*/
|
|
17
|
+
function parseDataFile(filePath) {
|
|
18
|
+
const base = path.basename(filePath)
|
|
19
|
+
const match = base.match(/^(.+)\.(scene|object|record)\.(jsonc?)$/)
|
|
20
|
+
if (!match) return null
|
|
21
|
+
return { name: match[1], suffix: match[2], ext: match[3] }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Scan the repo for all data files, validate uniqueness, return the index.
|
|
26
|
+
*/
|
|
27
|
+
function buildIndex(root) {
|
|
28
|
+
const ignore = ['node_modules/**', 'dist/**', '.git/**']
|
|
29
|
+
const files = globSync(GLOB_PATTERN, { cwd: root, ignore, absolute: false })
|
|
30
|
+
|
|
31
|
+
const index = { scene: {}, object: {}, record: {} }
|
|
32
|
+
const seen = {} // "name.suffix" → absolute path (for duplicate detection)
|
|
33
|
+
|
|
34
|
+
for (const relPath of files) {
|
|
35
|
+
const parsed = parseDataFile(relPath)
|
|
36
|
+
if (!parsed) continue
|
|
37
|
+
|
|
38
|
+
const key = `${parsed.name}.${parsed.suffix}`
|
|
39
|
+
const absPath = path.resolve(root, relPath)
|
|
40
|
+
|
|
41
|
+
if (seen[key]) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`[storyboard-data] Duplicate data file: "${key}.json"\n` +
|
|
44
|
+
` Found at: ${seen[key]}\n` +
|
|
45
|
+
` And at: ${absPath}\n` +
|
|
46
|
+
` Every data file name+suffix must be unique across the repo.`
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
seen[key] = absPath
|
|
51
|
+
index[parsed.suffix][parsed.name] = absPath
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return index
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate the virtual module source code.
|
|
59
|
+
* Reads each data file, parses JSONC at build time, and emits pre-parsed
|
|
60
|
+
* JavaScript objects — no runtime parsing needed.
|
|
61
|
+
*/
|
|
62
|
+
function generateModule(index) {
|
|
63
|
+
const declarations = []
|
|
64
|
+
const entries = { scene: [], object: [], record: [] }
|
|
65
|
+
let i = 0
|
|
66
|
+
|
|
67
|
+
for (const suffix of SUFFIXES) {
|
|
68
|
+
for (const [name, absPath] of Object.entries(index[suffix])) {
|
|
69
|
+
const varName = `_d${i++}`
|
|
70
|
+
const raw = fs.readFileSync(absPath, 'utf-8')
|
|
71
|
+
const parsed = parseJsonc(raw)
|
|
72
|
+
declarations.push(`const ${varName} = ${JSON.stringify(parsed)}`)
|
|
73
|
+
entries[suffix].push(` ${JSON.stringify(name)}: ${varName}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return [
|
|
78
|
+
`import { init } from '@dfosco/storyboard-core'`,
|
|
79
|
+
'',
|
|
80
|
+
declarations.join('\n'),
|
|
81
|
+
'',
|
|
82
|
+
`const scenes = {\n${entries.scene.join(',\n')}\n}`,
|
|
83
|
+
`const objects = {\n${entries.object.join(',\n')}\n}`,
|
|
84
|
+
`const records = {\n${entries.record.join(',\n')}\n}`,
|
|
85
|
+
'',
|
|
86
|
+
`init({ scenes, objects, records })`,
|
|
87
|
+
'',
|
|
88
|
+
`export { scenes, objects, records }`,
|
|
89
|
+
`export const index = { scenes, objects, records }`,
|
|
90
|
+
`export default index`,
|
|
91
|
+
].join('\n')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Vite plugin for storyboard data discovery.
|
|
96
|
+
*
|
|
97
|
+
* - Scans the repo for *.scene.json, *.object.json, *.record.json
|
|
98
|
+
* - Validates no two files share the same name+suffix (hard build error)
|
|
99
|
+
* - Generates a virtual module `virtual:storyboard-data-index`
|
|
100
|
+
* - Watches for file additions/removals in dev mode
|
|
101
|
+
*/
|
|
102
|
+
export default function storyboardDataPlugin() {
|
|
103
|
+
let root = ''
|
|
104
|
+
let index = null
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
name: 'storyboard-data',
|
|
108
|
+
enforce: 'pre',
|
|
109
|
+
|
|
110
|
+
configResolved(config) {
|
|
111
|
+
root = config.root
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
resolveId(id) {
|
|
115
|
+
if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
load(id) {
|
|
119
|
+
if (id !== RESOLVED_ID) return null
|
|
120
|
+
if (!index) index = buildIndex(root)
|
|
121
|
+
return generateModule(index)
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
configureServer(server) {
|
|
125
|
+
// Watch for data file changes in dev mode
|
|
126
|
+
const dataGlob = GLOB_PATTERN
|
|
127
|
+
const watcher = server.watcher
|
|
128
|
+
|
|
129
|
+
const invalidate = (filePath) => {
|
|
130
|
+
const parsed = parseDataFile(filePath)
|
|
131
|
+
if (!parsed) return
|
|
132
|
+
// Rebuild index and invalidate virtual module
|
|
133
|
+
index = null
|
|
134
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
|
|
135
|
+
if (mod) {
|
|
136
|
+
server.moduleGraph.invalidateModule(mod)
|
|
137
|
+
server.ws.send({ type: 'full-reload' })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
watcher.on('add', invalidate)
|
|
142
|
+
watcher.on('unlink', invalidate)
|
|
143
|
+
watcher.on('change', invalidate)
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// Rebuild index on each build start
|
|
147
|
+
buildStart() {
|
|
148
|
+
index = null
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import storyboardDataPlugin from './data-plugin.js'
|
|
5
|
+
|
|
6
|
+
const RESOLVED_ID = '\0virtual:storyboard-data-index'
|
|
7
|
+
|
|
8
|
+
let tmpDir
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = mkdtempSync(path.join(tmpdir(), 'sb-test-'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function createPlugin(root) {
|
|
19
|
+
const plugin = storyboardDataPlugin()
|
|
20
|
+
plugin.configResolved({ root: root ?? tmpDir })
|
|
21
|
+
return plugin
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeDataFiles(dir) {
|
|
25
|
+
writeFileSync(
|
|
26
|
+
path.join(dir, 'default.scene.json'),
|
|
27
|
+
JSON.stringify({ title: 'Test' }),
|
|
28
|
+
)
|
|
29
|
+
writeFileSync(
|
|
30
|
+
path.join(dir, 'user.object.json'),
|
|
31
|
+
JSON.stringify({ name: 'Jane' }),
|
|
32
|
+
)
|
|
33
|
+
writeFileSync(
|
|
34
|
+
path.join(dir, 'posts.record.json'),
|
|
35
|
+
JSON.stringify([{ id: '1', title: 'First' }]),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('storyboardDataPlugin', () => {
|
|
40
|
+
it("has name 'storyboard-data'", () => {
|
|
41
|
+
const plugin = storyboardDataPlugin()
|
|
42
|
+
expect(plugin.name).toBe('storyboard-data')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("has enforce 'pre'", () => {
|
|
46
|
+
const plugin = storyboardDataPlugin()
|
|
47
|
+
expect(plugin.enforce).toBe('pre')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("resolveId returns resolved ID for 'virtual:storyboard-data-index'", () => {
|
|
51
|
+
const plugin = createPlugin()
|
|
52
|
+
expect(plugin.resolveId('virtual:storyboard-data-index')).toBe(RESOLVED_ID)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('resolveId returns undefined for other IDs', () => {
|
|
56
|
+
const plugin = createPlugin()
|
|
57
|
+
expect(plugin.resolveId('some-other-module')).toBeUndefined()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('load generates valid module code with init() call', () => {
|
|
61
|
+
writeDataFiles(tmpDir)
|
|
62
|
+
const plugin = createPlugin()
|
|
63
|
+
const code = plugin.load(RESOLVED_ID)
|
|
64
|
+
|
|
65
|
+
expect(code).toContain("import { init } from '@dfosco/storyboard-core'")
|
|
66
|
+
expect(code).toContain('init({ scenes, objects, records })')
|
|
67
|
+
expect(code).toContain('"Test"')
|
|
68
|
+
expect(code).toContain('"Jane"')
|
|
69
|
+
expect(code).toContain('"First"')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('load returns null for other IDs', () => {
|
|
73
|
+
const plugin = createPlugin()
|
|
74
|
+
expect(plugin.load('other-id')).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('duplicate data files throw an error', () => {
|
|
78
|
+
writeFileSync(
|
|
79
|
+
path.join(tmpDir, 'dup.scene.json'),
|
|
80
|
+
JSON.stringify({ a: 1 }),
|
|
81
|
+
)
|
|
82
|
+
const subDir = path.join(tmpDir, 'nested')
|
|
83
|
+
mkdirSync(subDir, { recursive: true })
|
|
84
|
+
writeFileSync(
|
|
85
|
+
path.join(subDir, 'dup.scene.json'),
|
|
86
|
+
JSON.stringify({ a: 2 }),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
const plugin = createPlugin()
|
|
90
|
+
expect(() => plugin.load(RESOLVED_ID)).toThrow(/Duplicate data file/)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('handles JSONC files (with comments)', () => {
|
|
94
|
+
writeFileSync(
|
|
95
|
+
path.join(tmpDir, 'commented.scene.jsonc'),
|
|
96
|
+
'{\n // This is a comment\n "title": "JSONC Scene"\n}\n',
|
|
97
|
+
)
|
|
98
|
+
const plugin = createPlugin()
|
|
99
|
+
const code = plugin.load(RESOLVED_ID)
|
|
100
|
+
|
|
101
|
+
expect(code).toContain('"JSONC Scene"')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('buildStart resets the index cache', () => {
|
|
105
|
+
writeDataFiles(tmpDir)
|
|
106
|
+
const plugin = createPlugin()
|
|
107
|
+
|
|
108
|
+
// First load builds the index
|
|
109
|
+
const code1 = plugin.load(RESOLVED_ID)
|
|
110
|
+
expect(code1).toContain('"Test"')
|
|
111
|
+
|
|
112
|
+
// Add a new file
|
|
113
|
+
writeFileSync(
|
|
114
|
+
path.join(tmpDir, 'extra.scene.json'),
|
|
115
|
+
JSON.stringify({ title: 'Extra' }),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
// Without buildStart, cached index is used — "Extra" won't appear
|
|
119
|
+
const code2 = plugin.load(RESOLVED_ID)
|
|
120
|
+
expect(code2).not.toContain('"Extra"')
|
|
121
|
+
|
|
122
|
+
// After buildStart, index is rebuilt
|
|
123
|
+
plugin.buildStart()
|
|
124
|
+
const code3 = plugin.load(RESOLVED_ID)
|
|
125
|
+
expect(code3).toContain('"Extra"')
|
|
126
|
+
})
|
|
127
|
+
})
|