@dfosco/storyboard-core 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.
Files changed (42) hide show
  1. package/package.json +18 -0
  2. package/src/comments/api.js +196 -0
  3. package/src/comments/api.test.js +194 -0
  4. package/src/comments/auth.js +79 -0
  5. package/src/comments/auth.test.js +60 -0
  6. package/src/comments/commentMode.js +63 -0
  7. package/src/comments/commentMode.test.js +87 -0
  8. package/src/comments/config.js +43 -0
  9. package/src/comments/config.test.js +76 -0
  10. package/src/comments/graphql.js +65 -0
  11. package/src/comments/graphql.test.js +95 -0
  12. package/src/comments/index.js +40 -0
  13. package/src/comments/metadata.js +52 -0
  14. package/src/comments/metadata.test.js +110 -0
  15. package/src/comments/queries.js +182 -0
  16. package/src/comments/ui/CommentOverlay.js +52 -0
  17. package/src/comments/ui/authModal.js +349 -0
  18. package/src/comments/ui/commentWindow.js +872 -0
  19. package/src/comments/ui/commentsDrawer.js +389 -0
  20. package/src/comments/ui/composer.js +248 -0
  21. package/src/comments/ui/mount.js +364 -0
  22. package/src/devtools.js +365 -0
  23. package/src/devtools.test.js +81 -0
  24. package/src/dotPath.js +53 -0
  25. package/src/dotPath.test.js +114 -0
  26. package/src/hashSubscribe.js +19 -0
  27. package/src/hashSubscribe.test.js +62 -0
  28. package/src/hideMode.js +421 -0
  29. package/src/hideMode.test.js +224 -0
  30. package/src/index.js +38 -0
  31. package/src/interceptHideParams.js +35 -0
  32. package/src/interceptHideParams.test.js +90 -0
  33. package/src/loader.js +212 -0
  34. package/src/loader.test.js +232 -0
  35. package/src/localStorage.js +134 -0
  36. package/src/localStorage.test.js +148 -0
  37. package/src/sceneDebug.js +108 -0
  38. package/src/sceneDebug.test.js +128 -0
  39. package/src/session.js +76 -0
  40. package/src/session.test.js +91 -0
  41. package/src/viewfinder.js +47 -0
  42. package/src/viewfinder.test.js +87 -0
@@ -0,0 +1,148 @@
1
+ import {
2
+ getLocal,
3
+ setLocal,
4
+ removeLocal,
5
+ getAllLocal,
6
+ subscribeToStorage,
7
+ getStorageSnapshot,
8
+ } from './localStorage.js'
9
+
10
+ describe('getLocal', () => {
11
+ it('returns null for missing key', () => {
12
+ expect(getLocal('nonexistent')).toBeNull()
13
+ })
14
+
15
+ it('reads stored value using "storyboard:" prefix internally', () => {
16
+ localStorage.setItem('storyboard:mykey', 'hello')
17
+ expect(getLocal('mykey')).toBe('hello')
18
+ })
19
+
20
+ it('returns null if localStorage throws', () => {
21
+ const original = localStorage.getItem
22
+ localStorage.getItem = () => { throw new Error('fail') }
23
+ expect(getLocal('anything')).toBeNull()
24
+ localStorage.getItem = original
25
+ })
26
+ })
27
+
28
+ describe('setLocal', () => {
29
+ it('stores value with prefix', () => {
30
+ setLocal('color', 'blue')
31
+ expect(localStorage.getItem('storyboard:color')).toBe('blue')
32
+ })
33
+
34
+ it('converts value to string', () => {
35
+ setLocal('num', 42)
36
+ expect(localStorage.getItem('storyboard:num')).toBe('42')
37
+ })
38
+
39
+ it('dispatches storyboard-storage event on window', () => {
40
+ const cb = vi.fn()
41
+ window.addEventListener('storyboard-storage', cb)
42
+ setLocal('x', '1')
43
+ expect(cb).toHaveBeenCalledTimes(1)
44
+ window.removeEventListener('storyboard-storage', cb)
45
+ })
46
+ })
47
+
48
+ describe('removeLocal', () => {
49
+ it('removes prefixed key', () => {
50
+ setLocal('temp', 'val')
51
+ expect(localStorage.getItem('storyboard:temp')).toBe('val')
52
+ removeLocal('temp')
53
+ expect(localStorage.getItem('storyboard:temp')).toBeNull()
54
+ })
55
+
56
+ it('dispatches storyboard-storage event', () => {
57
+ setLocal('temp', 'val')
58
+ const cb = vi.fn()
59
+ window.addEventListener('storyboard-storage', cb)
60
+ removeLocal('temp')
61
+ expect(cb).toHaveBeenCalled()
62
+ window.removeEventListener('storyboard-storage', cb)
63
+ })
64
+ })
65
+
66
+ describe('getAllLocal', () => {
67
+ it('returns empty object when no storyboard keys', () => {
68
+ expect(getAllLocal()).toEqual({})
69
+ })
70
+
71
+ it('returns all prefixed entries with prefix stripped', () => {
72
+ setLocal('a', '1')
73
+ setLocal('b', '2')
74
+ expect(getAllLocal()).toEqual({ a: '1', b: '2' })
75
+ })
76
+
77
+ it('ignores non-storyboard keys', () => {
78
+ localStorage.setItem('other-key', 'nope')
79
+ setLocal('only', 'this')
80
+ const result = getAllLocal()
81
+ expect(result).toEqual({ only: 'this' })
82
+ expect(result['other-key']).toBeUndefined()
83
+ })
84
+ })
85
+
86
+ describe('subscribeToStorage', () => {
87
+ it('calls callback on storyboard-storage event', () => {
88
+ const cb = vi.fn()
89
+ const unsub = subscribeToStorage(cb)
90
+ window.dispatchEvent(new Event('storyboard-storage'))
91
+ expect(cb).toHaveBeenCalledTimes(1)
92
+ unsub()
93
+ })
94
+
95
+ it('calls callback on storage event', () => {
96
+ const cb = vi.fn()
97
+ const unsub = subscribeToStorage(cb)
98
+ window.dispatchEvent(new Event('storage'))
99
+ expect(cb).toHaveBeenCalledTimes(1)
100
+ unsub()
101
+ })
102
+
103
+ it('returns unsubscribe function that works', () => {
104
+ const cb = vi.fn()
105
+ const unsub = subscribeToStorage(cb)
106
+ unsub()
107
+ window.dispatchEvent(new Event('storyboard-storage'))
108
+ window.dispatchEvent(new Event('storage'))
109
+ expect(cb).not.toHaveBeenCalled()
110
+ })
111
+ })
112
+
113
+ describe('getStorageSnapshot', () => {
114
+ it('returns serialized string of all entries', () => {
115
+ setLocal('z', '3')
116
+ setLocal('a', '1')
117
+ // Force cache invalidation so snapshot recomputes
118
+ window.dispatchEvent(new Event('storyboard-storage'))
119
+ const snap = getStorageSnapshot()
120
+ // Entries are sorted alphabetically
121
+ expect(snap).toBe('storyboard:a=1&storyboard:z=3')
122
+ })
123
+
124
+ it('caches result (same reference on repeated calls)', () => {
125
+ setLocal('k', 'v')
126
+ // Invalidate cache first
127
+ window.dispatchEvent(new Event('storyboard-storage'))
128
+ const snap1 = getStorageSnapshot()
129
+ const snap2 = getStorageSnapshot()
130
+ expect(snap1).toBe(snap2)
131
+ })
132
+
133
+ it('invalidates cache on storage event', () => {
134
+ setLocal('k', 'v')
135
+ // Subscribe so events invalidate the cache
136
+ const unsub = subscribeToStorage(() => {})
137
+
138
+ const snap1 = getStorageSnapshot()
139
+ // Directly mutate localStorage and fire event to invalidate
140
+ localStorage.setItem('storyboard:k', 'changed')
141
+ window.dispatchEvent(new Event('storyboard-storage'))
142
+ const snap2 = getStorageSnapshot()
143
+
144
+ expect(snap1).not.toBe(snap2)
145
+ expect(snap2).toContain('storyboard:k=changed')
146
+ unsub()
147
+ })
148
+ })
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Storyboard SceneDebug — a vanilla JS debug panel that displays scene data.
3
+ *
4
+ * Framework-agnostic: creates a DOM element, no React/Vue/etc. needed.
5
+ *
6
+ * Usage:
7
+ * import { mountSceneDebug } from '@dfosco/storyboard-core'
8
+ * mountSceneDebug(document.getElementById('debug'))
9
+ * // or
10
+ * mountSceneDebug() // appends to document.body
11
+ */
12
+ import { loadScene } from './loader.js'
13
+
14
+ const STYLES = `
15
+ .sb-scene-debug {
16
+ padding: 16px;
17
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
18
+ }
19
+ .sb-scene-debug-title {
20
+ font-size: 20px;
21
+ font-weight: 600;
22
+ margin-bottom: 8px;
23
+ color: #c9d1d9;
24
+ }
25
+ .sb-scene-debug-code {
26
+ padding: 16px;
27
+ background-color: #161b22;
28
+ border-radius: 8px;
29
+ overflow: auto;
30
+ font-size: 13px;
31
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
32
+ line-height: 1.5;
33
+ max-height: 70vh;
34
+ color: #c9d1d9;
35
+ white-space: pre-wrap;
36
+ word-break: break-word;
37
+ }
38
+ .sb-scene-debug-error {
39
+ padding: 16px;
40
+ background-color: rgba(248, 81, 73, 0.1);
41
+ border-radius: 8px;
42
+ }
43
+ .sb-scene-debug-error-title {
44
+ color: #f85149;
45
+ font-weight: 600;
46
+ }
47
+ .sb-scene-debug-error-message {
48
+ color: #f85149;
49
+ margin-top: 4px;
50
+ }
51
+ `
52
+
53
+ let stylesInjected = false
54
+
55
+ /**
56
+ * Mount a scene debug panel into the DOM.
57
+ *
58
+ * @param {HTMLElement} [container=document.body] - Where to mount
59
+ * @param {string} [sceneName] - Scene name override (defaults to ?scene= param or "default")
60
+ * @returns {HTMLElement} The created debug element
61
+ */
62
+ export function mountSceneDebug(container, sceneName) {
63
+ const target = container || document.body
64
+ const activeSceneName = sceneName
65
+ || new URLSearchParams(window.location.search).get('scene')
66
+ || 'default'
67
+
68
+ // Inject styles once
69
+ if (!stylesInjected) {
70
+ const styleEl = document.createElement('style')
71
+ styleEl.textContent = STYLES
72
+ document.head.appendChild(styleEl)
73
+ stylesInjected = true
74
+ }
75
+
76
+ const el = document.createElement('div')
77
+ el.className = 'sb-scene-debug'
78
+
79
+ let data = null
80
+ let error = null
81
+ try {
82
+ data = loadScene(activeSceneName)
83
+ } catch (err) {
84
+ error = err.message
85
+ }
86
+
87
+ if (error) {
88
+ el.innerHTML = `
89
+ <div class="sb-scene-debug-error">
90
+ <div class="sb-scene-debug-error-title">Error loading scene</div>
91
+ <p class="sb-scene-debug-error-message">${error}</p>
92
+ </div>`
93
+ } else {
94
+ const title = document.createElement('h2')
95
+ title.className = 'sb-scene-debug-title'
96
+ title.textContent = `Scene: ${activeSceneName}`
97
+
98
+ const pre = document.createElement('pre')
99
+ pre.className = 'sb-scene-debug-code'
100
+ pre.textContent = JSON.stringify(data, null, 2)
101
+
102
+ el.appendChild(title)
103
+ el.appendChild(pre)
104
+ }
105
+
106
+ target.appendChild(el)
107
+ return el
108
+ }
@@ -0,0 +1,128 @@
1
+ import { vi } from 'vitest'
2
+
3
+ const { mockLoadScene } = vi.hoisted(() => ({
4
+ mockLoadScene: vi.fn(() => ({ hello: 'world', count: 42 })),
5
+ }))
6
+
7
+ vi.mock('./loader.js', () => ({
8
+ loadScene: mockLoadScene,
9
+ }))
10
+
11
+ // We need a fresh module for each test since sceneDebug has a module-level
12
+ // `stylesInjected` boolean. We test style injection on the very first call,
13
+ // then subsequent tests just verify other behavior.
14
+ import { mountSceneDebug } from './sceneDebug.js'
15
+
16
+ afterEach(() => {
17
+ document.body.innerHTML = ''
18
+ mockLoadScene.mockReset()
19
+ mockLoadScene.mockReturnValue({ hello: 'world', count: 42 })
20
+ })
21
+
22
+ describe('mountSceneDebug', () => {
23
+ it('injects styles into document.head on first call', () => {
24
+ // This MUST run first to capture the stylesInjected=false → true transition
25
+ mountSceneDebug()
26
+
27
+ const styles = document.head.querySelectorAll('style')
28
+ const hasDebugStyle = Array.from(styles).some((el) =>
29
+ el.textContent.includes('.sb-scene-debug')
30
+ )
31
+ expect(hasDebugStyle).toBe(true)
32
+ })
33
+
34
+ it('creates an element with class sb-scene-debug', () => {
35
+ const el = mountSceneDebug()
36
+
37
+ expect(el.classList.contains('sb-scene-debug')).toBe(true)
38
+ })
39
+
40
+ it('appends to document.body by default', () => {
41
+ mountSceneDebug()
42
+
43
+ expect(document.body.querySelector('.sb-scene-debug')).toBeInTheDocument()
44
+ })
45
+
46
+ it('appends to a custom container', () => {
47
+ const container = document.createElement('div')
48
+ document.body.appendChild(container)
49
+
50
+ mountSceneDebug(container)
51
+
52
+ expect(container.querySelector('.sb-scene-debug')).not.toBeNull()
53
+ })
54
+
55
+ it('returns the created element', () => {
56
+ const el = mountSceneDebug()
57
+
58
+ expect(el).toBeInstanceOf(HTMLElement)
59
+ expect(el.className).toBe('sb-scene-debug')
60
+ })
61
+
62
+ it('renders the scene name in the title', () => {
63
+ mountSceneDebug(undefined, 'my-scene')
64
+
65
+ const title = document.body.querySelector('.sb-scene-debug-title')
66
+ expect(title).not.toBeNull()
67
+ expect(title.textContent).toContain('my-scene')
68
+ })
69
+
70
+ it('defaults scene name to "default" when none is provided', () => {
71
+ mountSceneDebug()
72
+
73
+ const title = document.body.querySelector('.sb-scene-debug-title')
74
+ expect(title.textContent).toContain('default')
75
+ expect(mockLoadScene).toHaveBeenCalledWith('default')
76
+ })
77
+
78
+ it('renders JSON data in a pre element', () => {
79
+ mountSceneDebug()
80
+
81
+ const pre = document.body.querySelector('.sb-scene-debug-code')
82
+ expect(pre).not.toBeNull()
83
+ expect(pre.tagName).toBe('PRE')
84
+
85
+ const parsed = JSON.parse(pre.textContent)
86
+ expect(parsed).toEqual({ hello: 'world', count: 42 })
87
+ })
88
+
89
+ it('shows error when loadScene throws', () => {
90
+ mockLoadScene.mockImplementation(() => {
91
+ throw new Error('Scene not found')
92
+ })
93
+
94
+ const el = mountSceneDebug()
95
+
96
+ const errorTitle = el.querySelector('.sb-scene-debug-error-title')
97
+ expect(errorTitle).not.toBeNull()
98
+ expect(errorTitle.textContent).toContain('Error')
99
+
100
+ const errorMsg = el.querySelector('.sb-scene-debug-error-message')
101
+ expect(errorMsg.textContent).toContain('Scene not found')
102
+
103
+ // Should NOT render the normal title/pre
104
+ expect(el.querySelector('.sb-scene-debug-title')).toBeNull()
105
+ expect(el.querySelector('.sb-scene-debug-code')).toBeNull()
106
+ })
107
+
108
+ it('uses ?scene query param when no sceneName argument is given', () => {
109
+ window.history.pushState(null, '', '?scene=overview')
110
+
111
+ mountSceneDebug()
112
+
113
+ expect(mockLoadScene).toHaveBeenCalledWith('overview')
114
+ const title = document.body.querySelector('.sb-scene-debug-title')
115
+ expect(title.textContent).toContain('overview')
116
+
117
+ // Clean up
118
+ window.history.pushState(null, '', '/')
119
+ })
120
+
121
+ it('allows multiple debug panels to be mounted', () => {
122
+ mountSceneDebug()
123
+ mountSceneDebug()
124
+
125
+ const panels = document.body.querySelectorAll('.sb-scene-debug')
126
+ expect(panels).toHaveLength(2)
127
+ })
128
+ })
package/src/session.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * URL hash-based utilities for storyboard session state.
3
+ *
4
+ * Session params are stored in the URL hash fragment (after #) to avoid
5
+ * triggering React Router re-renders. React Router (used by generouted)
6
+ * patches history.replaceState/pushState, so any search-param change
7
+ * causes a full route tree re-render. The hash is invisible to the router.
8
+ *
9
+ * Format: #key1=value1&key2=value2
10
+ * Example: /page?scene=default#user.name=Alice&settings.theme=dark
11
+ */
12
+
13
+ /**
14
+ * Parse the current hash into a Map of key→value pairs.
15
+ * @returns {URLSearchParams}
16
+ */
17
+ function parseHash() {
18
+ const raw = window.location.hash.replace(/^#/, '')
19
+ return new URLSearchParams(raw)
20
+ }
21
+
22
+ /**
23
+ * Write a URLSearchParams back to the hash.
24
+ * Uses window.location.hash (NOT history.replaceState) because
25
+ * generouted/React Router patches replaceState and would trigger
26
+ * a full route re-render. Native hash assignment only fires
27
+ * 'hashchange' which React Router ignores.
28
+ * @param {URLSearchParams} params
29
+ */
30
+ function writeHash(params) {
31
+ const str = params.toString()
32
+ window.location.hash = str
33
+ }
34
+
35
+ /**
36
+ * Read a single session param value.
37
+ * @param {string} key
38
+ * @returns {string|null}
39
+ */
40
+ export function getParam(key) {
41
+ return parseHash().get(key)
42
+ }
43
+
44
+ /**
45
+ * Write a single session param. Updates the hash in-place.
46
+ * @param {string} key
47
+ * @param {string} value
48
+ */
49
+ export function setParam(key, value) {
50
+ const params = parseHash()
51
+ params.set(key, String(value))
52
+ writeHash(params)
53
+ }
54
+
55
+ /**
56
+ * Return all session params as a plain object.
57
+ * @returns {Record<string, string>}
58
+ */
59
+ export function getAllParams() {
60
+ const params = parseHash()
61
+ const result = {}
62
+ for (const [key, value] of params.entries()) {
63
+ result[key] = value
64
+ }
65
+ return result
66
+ }
67
+
68
+ /**
69
+ * Remove a single session param from the hash.
70
+ * @param {string} key
71
+ */
72
+ export function removeParam(key) {
73
+ const params = parseHash()
74
+ params.delete(key)
75
+ writeHash(params)
76
+ }
@@ -0,0 +1,91 @@
1
+ import { getParam, setParam, getAllParams, removeParam } from './session.js'
2
+
3
+ describe('getParam', () => {
4
+ it('returns null when hash is empty', () => {
5
+ window.location.hash = ''
6
+ expect(getParam('key')).toBeNull()
7
+ })
8
+
9
+ it('returns value for existing param', () => {
10
+ window.location.hash = 'foo=bar'
11
+ expect(getParam('foo')).toBe('bar')
12
+ })
13
+
14
+ it('returns null for missing param', () => {
15
+ window.location.hash = 'foo=bar'
16
+ expect(getParam('missing')).toBeNull()
17
+ })
18
+
19
+ it('handles URL-encoded values', () => {
20
+ window.location.hash = 'name=hello%20world'
21
+ expect(getParam('name')).toBe('hello world')
22
+ })
23
+ })
24
+
25
+ describe('setParam', () => {
26
+ it('sets a new param in hash', () => {
27
+ window.location.hash = ''
28
+ setParam('key', 'value')
29
+ expect(getParam('key')).toBe('value')
30
+ })
31
+
32
+ it('updates existing param', () => {
33
+ window.location.hash = 'key=old'
34
+ setParam('key', 'new')
35
+ expect(getParam('key')).toBe('new')
36
+ })
37
+
38
+ it('preserves other params', () => {
39
+ window.location.hash = 'a=1&b=2'
40
+ setParam('c', '3')
41
+ expect(getParam('a')).toBe('1')
42
+ expect(getParam('b')).toBe('2')
43
+ expect(getParam('c')).toBe('3')
44
+ })
45
+
46
+ it('converts value to string', () => {
47
+ window.location.hash = ''
48
+ setParam('num', 42)
49
+ expect(getParam('num')).toBe('42')
50
+ })
51
+ })
52
+
53
+ describe('getAllParams', () => {
54
+ it('returns empty object for empty hash', () => {
55
+ window.location.hash = ''
56
+ expect(getAllParams()).toEqual({})
57
+ })
58
+
59
+ it('returns all params', () => {
60
+ window.location.hash = 'a=1&b=2'
61
+ expect(getAllParams()).toEqual({ a: '1', b: '2' })
62
+ })
63
+
64
+ it('handles multiple params', () => {
65
+ window.location.hash = 'x=hello&y=world&z=test'
66
+ const params = getAllParams()
67
+ expect(Object.keys(params)).toHaveLength(3)
68
+ expect(params).toEqual({ x: 'hello', y: 'world', z: 'test' })
69
+ })
70
+ })
71
+
72
+ describe('removeParam', () => {
73
+ it('removes existing param', () => {
74
+ window.location.hash = 'a=1&b=2'
75
+ removeParam('a')
76
+ expect(getParam('a')).toBeNull()
77
+ })
78
+
79
+ it('preserves other params', () => {
80
+ window.location.hash = 'a=1&b=2&c=3'
81
+ removeParam('b')
82
+ expect(getParam('a')).toBe('1')
83
+ expect(getParam('c')).toBe('3')
84
+ })
85
+
86
+ it('no-ops for missing param', () => {
87
+ window.location.hash = 'a=1'
88
+ removeParam('nonexistent')
89
+ expect(getParam('a')).toBe('1')
90
+ })
91
+ })
@@ -0,0 +1,47 @@
1
+ import { loadScene } from './loader.js'
2
+
3
+ /**
4
+ * Deterministic hash from a string — used for seeding generative placeholders.
5
+ * @param {string} str
6
+ * @returns {number}
7
+ */
8
+ export function hash(str) {
9
+ let h = 0
10
+ for (let i = 0; i < str.length; i++) {
11
+ h = ((h << 5) - h + str.charCodeAt(i)) | 0
12
+ }
13
+ return Math.abs(h)
14
+ }
15
+
16
+ /**
17
+ * Resolve the target route path for a scene.
18
+ *
19
+ * 1. If scene name matches a known route (case-insensitive), use that route
20
+ * 2. If scene data has a `route` key, use that
21
+ * 3. Fall back to root "/"
22
+ *
23
+ * @param {string} sceneName
24
+ * @param {string[]} knownRoutes - Array of route names (e.g. ["Dashboard", "Repositories"])
25
+ * @returns {string} Full path with ?scene= param
26
+ */
27
+ export function resolveSceneRoute(sceneName, knownRoutes = []) {
28
+ // Case-insensitive match against known routes
29
+ for (const route of knownRoutes) {
30
+ if (route.toLowerCase() === sceneName.toLowerCase()) {
31
+ return `/${route}?scene=${encodeURIComponent(sceneName)}`
32
+ }
33
+ }
34
+
35
+ // Check for explicit `route` key in scene data
36
+ try {
37
+ const data = loadScene(sceneName)
38
+ if (data?.route) {
39
+ const route = data.route.startsWith('/') ? data.route : `/${data.route}`
40
+ return `${route}?scene=${encodeURIComponent(sceneName)}`
41
+ }
42
+ } catch {
43
+ // ignore load errors
44
+ }
45
+
46
+ return `/?scene=${encodeURIComponent(sceneName)}`
47
+ }
@@ -0,0 +1,87 @@
1
+ import { init } from './loader.js'
2
+ import { hash, resolveSceneRoute } from './viewfinder.js'
3
+
4
+ const makeIndex = () => ({
5
+ scenes: {
6
+ default: { title: 'Default Scene' },
7
+ Dashboard: { heading: 'Dashboard' },
8
+ 'custom-route': { route: 'Overview', title: 'Custom' },
9
+ 'absolute-route': { route: '/Forms', title: 'Absolute' },
10
+ 'no-route': { title: 'No route key' },
11
+ },
12
+ objects: {},
13
+ records: {},
14
+ })
15
+
16
+ beforeEach(() => {
17
+ init(makeIndex())
18
+ })
19
+
20
+ describe('hash', () => {
21
+ it('returns a number', () => {
22
+ expect(typeof hash('test')).toBe('number')
23
+ })
24
+
25
+ it('is deterministic', () => {
26
+ expect(hash('hello')).toBe(hash('hello'))
27
+ })
28
+
29
+ it('produces different values for different strings', () => {
30
+ expect(hash('foo')).not.toBe(hash('bar'))
31
+ })
32
+
33
+ it('returns non-negative values', () => {
34
+ expect(hash('abc')).toBeGreaterThanOrEqual(0)
35
+ expect(hash('')).toBeGreaterThanOrEqual(0)
36
+ expect(hash('a very long string with lots of characters')).toBeGreaterThanOrEqual(0)
37
+ })
38
+ })
39
+
40
+ describe('resolveSceneRoute', () => {
41
+ const routes = ['Dashboard', 'Overview', 'Forms', 'Repositories']
42
+
43
+ it('matches scene name to route (exact case)', () => {
44
+ expect(resolveSceneRoute('Dashboard', routes)).toBe('/Dashboard?scene=Dashboard')
45
+ })
46
+
47
+ it('matches scene name to route (case-insensitive)', () => {
48
+ expect(resolveSceneRoute('dashboard', routes)).toBe('/Dashboard?scene=dashboard')
49
+ })
50
+
51
+ it('uses route key from scene data when no route matches', () => {
52
+ expect(resolveSceneRoute('custom-route', routes)).toBe('/Overview?scene=custom-route')
53
+ })
54
+
55
+ it('handles absolute route key (with leading slash)', () => {
56
+ expect(resolveSceneRoute('absolute-route', routes)).toBe('/Forms?scene=absolute-route')
57
+ })
58
+
59
+ it('falls back to root when no match and no route key', () => {
60
+ expect(resolveSceneRoute('no-route', routes)).toBe('/?scene=no-route')
61
+ })
62
+
63
+ it('falls back to root for default scene', () => {
64
+ expect(resolveSceneRoute('default', routes)).toBe('/?scene=default')
65
+ })
66
+
67
+ it('falls back to root when scene does not exist', () => {
68
+ expect(resolveSceneRoute('nonexistent', routes)).toBe('/?scene=nonexistent')
69
+ })
70
+
71
+ it('works with empty routes array', () => {
72
+ expect(resolveSceneRoute('Dashboard', [])).toBe('/?scene=Dashboard')
73
+ })
74
+
75
+ it('works with no routes argument', () => {
76
+ expect(resolveSceneRoute('custom-route')).toBe('/Overview?scene=custom-route')
77
+ })
78
+
79
+ it('encodes special characters in scene name', () => {
80
+ init({
81
+ scenes: { 'has spaces': { title: 'Spaces' } },
82
+ objects: {},
83
+ records: {},
84
+ })
85
+ expect(resolveSceneRoute('has spaces', [])).toBe('/?scene=has%20spaces')
86
+ })
87
+ })