OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / fb44908

One location model, the SPA scheme canonical
The last divergence from the audit. The two surfaces had two location models — graphStack serialized to PATH segments, the floor's {module,path,symbolId} to QUERY params — so opening a file in one told the other nothing. lib/floor/location.js is now the single model. The SPA's path scheme is canonical, because it is the one with existing deep links and history entries: / system /module/:name a module /module/:name/file/:rel/:path a file in it /module/:name/file/:rel/symbol/:sym a symbol in that file (new rung) /path/:a/:b a directory /validation /docs/* /suggestions/* the SPA's other views /floor/... the same places, drawn as the floor The floor moved off query params entirely; what remains a query param is a view PREFERENCE rather than a place — bundled edges, a selection — so the path always means the same thing on either surface. Both sides delegate: the SPA's routeFromUrl and urlFromGraphStack now translate through the shared module, keeping graphStack as this view's working state, and the floor builds its URL from currentLocation(). Neither parses a URL itself. And the payoff: each surface carries a link to the SAME PLACE on the other, via otherSurface(). Verified end to end — /floor/module/hyper-overlay/file/lib/tree.js lands on that file's 19 symbols and offers /module/hyper-overlay/file/lib/tree.js. 8 tests, 47 asserts on the location model alone, including a round-trip for every kind and the file-path split that the two schemes address differently (the floor loads root-relative, the canonical scheme addresses module + relative). Suite: 221 tests, 4790 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fb44908d71ac47b1ecef3b215f5ed82f2c6099c5
by plankatron <fleeky@gmail.com> · 2026-09-21T00:40:39.000Z

Changed files (6) +381 -83

lib/floor/level.html +4 -1
……@@ -67,7 +67,10 @@
6767<div class="hud" id="head">
6868 <div>
6969 <div id="levelname"><b>file level</b></div>
70 <div style="margin-top:4px" id="crumb"></div>
70 <div style="margin-top:4px">
71 <span id="crumb"></span>
72 <a id="other-surface" href="/" style="color:#58a6ff;margin-left:10px">open in the panel view &rsaquo;</a>
73 </div>
7174 </div>
7275 <div style="flex:1">
7376 <input id="path" spellcheck="false" placeholder="open a file by path">
lib/floor/level.js +49 -16
……@@ -13,6 +13,9 @@
1313 */
1414
1515import { layout } from '/floor/layout.js'
16import {
17 parseLocation, locationToUrl, otherSurface, splitFilePath, joinFilePath
18} from '/floor/location.js'
1619import {
1720 buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel,
1821 resolveCode, lineDepths, spanOnLine, marksOwnSpan, bundleLinks,
……@@ -87,7 +90,7 @@ async function loadTree () {
8790 return view.tree
8891}
8992
90async function load (path) {
93async function load (path, symbolName) {
9194 const res = await fetch('/api/derive?path=' + encodeURIComponent(path))
9295 const data = await res.json()
9396 if (data.error) {
……@@ -101,7 +104,7 @@ async function load (path) {
101104 view.lineDepth = lineDepths(data.model, view.lines.length)
102105 view.expanded = new Set()
103106
104 const wantSymbol = new URLSearchParams(location.search).get('symbol')
107 const wantSymbol = symbolName || parseLocation(location.pathname, '').symbol
105108 const sym = wantSymbol ? data.model.symbols.find(s => s.name === wantSymbol) : null
106109 view.symbolId = sym ? sym.id : null
107110
……@@ -598,14 +601,37 @@ function applyExpandParam () {
598601 for (const s of view.model.symbols) if (names.has(s.name)) view.expanded.add(s.id)
599602}
600603
604// The URL is the canonical scheme the SPA defined, under /floor. Anything that
605// is a VIEW PREFERENCE rather than a place — bundled edges, a selection — stays
606// a query param, so the path itself means the same thing on either surface.
601607function syncUrl () {
602 const sym = currentSymbol()
608 const loc = currentLocation()
603609 const q = []
604610 if (view.bundled) q.push('edges=bundled')
605 if (view.module) q.push('module=' + encodeURIComponent(view.module))
606 if (view.path) q.push('path=' + encodeURIComponent(view.path))
607 if (sym) q.push('symbol=' + encodeURIComponent(sym.name))
608 history.replaceState(null, '', '/floor' + (q.length > 0 ? '?' + q.join('&') : ''))
611 if (view.selected) {
612 const card = view.cards.find(c => c.id === view.selected)
613 if (card) q.push('select=' + encodeURIComponent(card.title))
614 }
615 history.replaceState(null, '', locationToUrl(loc, '') + (q.length > 0 ? '?' + q.join('&') : ''))
616
617 const link = document.getElementById('other-surface')
618 if (link) link.setAttribute('href', otherSurface(loc, ''))
619}
620
621// Where we are, in the shared model rather than in this view's private fields.
622function currentLocation () {
623 const sym = currentSymbol()
624 const split = view.path && view.tree
625 ? splitFilePath(view.tree, view.path)
626 : { module: null, file: null }
627 const base = { surface: 'floor', module: null, file: null, symbol: null, path: [], extra: {} }
628
629 if (split.module && sym) {
630 return { ...base, kind: 'symbol', module: split.module, file: split.file, symbol: sym.name }
631 }
632 if (split.module) return { ...base, kind: 'file', module: split.module, file: split.file }
633 if (view.module) return { ...base, kind: 'module', module: view.module }
634 return { ...base, kind: 'system' }
609635}
610636
611637function applyTransform () {
……@@ -686,18 +712,25 @@ function wire () {
686712async function start () {
687713 wire()
688714 await loadTree()
689 const params = new URLSearchParams(location.search)
690 view.module = params.get('module')
691 view.bundled = params.get('edges') === 'bundled'
692 const path = params.get('path')
693715
694 if (path) {
695 await load(path)
696 syncUrl()
697 return
716 const loc = parseLocation(location.pathname, '')
717 view.bundled = new URLSearchParams(location.search).get('edges') === 'bundled'
718
719 if (loc.kind === 'file' || loc.kind === 'symbol') {
720 const rootRel = joinFilePath(view.tree, loc.module, loc.file)
721 view.module = loc.module
722 if (rootRel) {
723 await load(rootRel, loc.symbol)
724 syncUrl()
725 return
726 }
698727 }
699 // No path: the system level is the top of the ladder and the honest default.
728 if (loc.kind === 'module') view.module = loc.module
729
730 // Anything else is the system level: the top of the ladder, and the honest
731 // default rather than guessing a file out of the scan.
700732 rebuild()
733 syncUrl()
701734}
702735
703736start()
lib/floor/location.js +167 -0
……@@ -0,0 +1,167 @@
1/**
2 * Where you are, as one object, shared by every surface.
3 *
4 * There were two location models: the SPA's `graphStack` serialized to PATH
5 * segments, and the floor's { module, path, symbolId } serialized to QUERY
6 * params. Two schemes for the same idea meant opening a file in one view told
7 * the other nothing.
8 *
9 * The SPA's path scheme is canonical, because it is the one with existing deep
10 * links and history entries. The floor adopts it under a /floor prefix, and the
11 * symbol rung extends it:
12 *
13 * / system
14 * /module/:name a module
15 * /module/:name/file/:rel/:path a file in it
16 * /module/:name/file/:rel/symbol/:symbol a symbol in that file
17 * /path/:a/:b a directory in the scan
18 * /validation /docs/* /suggestions/* the SPA's other views
19 *
20 * /floor/... the same places, drawn as the floor
21 *
22 * Pure: strings in, strings out, no tree and no DOM, so both surfaces and the
23 * tests share one implementation.
24 */
25
26const SURFACE = 'floor'
27
28/**
29 * @returns {object} { surface, kind, module, file, symbol, path, extra }
30 * surface: 'spa' | 'floor'
31 * kind: 'system' | 'module' | 'file' | 'symbol' | 'path' |
32 * 'validation' | 'docs' | 'suggestions'
33 * path: array of directory segments, for kind 'path'
34 */
35export function parseLocation (pathname, basePrefix = '') {
36 const loc = {
37 surface: 'spa',
38 kind: 'system',
39 module: null,
40 file: null,
41 symbol: null,
42 path: [],
43 extra: {}
44 }
45
46 let p = pathname || '/'
47 if (basePrefix && p.startsWith(basePrefix)) p = p.slice(basePrefix.length) || '/'
48 if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1)
49
50 let segs = p.split('/').filter(Boolean).map(decodeURIComponent)
51
52 if (segs[0] === SURFACE) {
53 loc.surface = SURFACE
54 segs = segs.slice(1)
55 }
56 if (segs.length === 0 || segs[0] === 'system') return loc
57
58 if (segs[0] === 'validation') {
59 loc.kind = 'validation'
60 if (segs[1] === 'module' && segs[2]) loc.extra.moduleName = segs[2]
61 return loc
62 }
63 if (segs[0] === 'docs') {
64 loc.kind = 'docs'
65 if (segs.length > 1) loc.extra.docPath = segs.slice(1).join('/')
66 return loc
67 }
68 if (segs[0] === 'suggestions') {
69 loc.kind = 'suggestions'
70 if (segs.length > 1) loc.extra.suggestPath = segs.slice(1).join('/')
71 return loc
72 }
73
74 if (segs[0] === 'path') {
75 loc.kind = 'path'
76 loc.path = segs.slice(1)
77 return loc
78 }
79
80 if (segs[0] === 'module' && segs[1]) {
81 loc.kind = 'module'
82 loc.module = segs[1]
83
84 const fileAt = segs.indexOf('file', 2)
85 if (fileAt === -1) return loc
86
87 // A file path has slashes of its own, so it runs until 'symbol' or the end.
88 const symbolAt = segs.indexOf('symbol', fileAt + 1)
89 const fileEnd = symbolAt === -1 ? segs.length : symbolAt
90 const fileParts = segs.slice(fileAt + 1, fileEnd)
91 if (fileParts.length > 0) {
92 loc.kind = 'file'
93 loc.file = fileParts.join('/')
94 }
95 if (symbolAt !== -1 && segs[symbolAt + 1]) {
96 loc.kind = 'symbol'
97 loc.symbol = segs[symbolAt + 1]
98 }
99 return loc
100 }
101
102 return loc
103}
104
105export function locationToUrl (loc, basePrefix = '') {
106 const base = basePrefix + (loc.surface === SURFACE ? '/' + SURFACE : '')
107 const enc = encodeURIComponent
108
109 switch (loc.kind) {
110 case 'module':
111 case 'file':
112 case 'symbol': {
113 if (!loc.module) return base + '/'
114 let url = base + '/module/' + enc(loc.module)
115 if (loc.file) url += '/file/' + loc.file.split('/').map(enc).join('/')
116 if (loc.symbol && loc.kind === 'symbol') url += '/symbol/' + enc(loc.symbol)
117 return url
118 }
119 case 'path':
120 return loc.path.length > 0 ? base + '/path/' + loc.path.map(enc).join('/') : base + '/'
121 case 'validation':
122 return base + '/validation' +
123 (loc.extra && loc.extra.moduleName ? '/module/' + enc(loc.extra.moduleName) : '')
124 case 'docs':
125 return base + '/docs' +
126 (loc.extra && loc.extra.docPath ? '/' + loc.extra.docPath.split('/').map(enc).join('/') : '')
127 case 'suggestions':
128 return base + '/suggestions' +
129 (loc.extra && loc.extra.suggestPath ? '/' + loc.extra.suggestPath.split('/').map(enc).join('/') : '')
130 default:
131 return base + '/'
132 }
133}
134
135/** The same place, on the other surface — which is what makes the views agree. */
136export function otherSurface (loc, basePrefix = '') {
137 return locationToUrl({ ...loc, surface: loc.surface === SURFACE ? 'spa' : SURFACE }, basePrefix)
138}
139
140/**
141 * A root-relative file path split into the module that owns it and the path
142 * within that module, because the canonical scheme addresses a file as
143 * module + relative path while the floor loads it by root-relative path.
144 */
145export function splitFilePath (tree, rootRelPath) {
146 if (!rootRelPath) return { module: null, file: null }
147 let best = null
148 for (const [name, mod] of Object.entries(tree.modules || {})) {
149 const prefix = mod.path && mod.path !== '.' ? mod.path + '/' : ''
150 if (prefix && !rootRelPath.startsWith(prefix)) continue
151 const rel = prefix ? rootRelPath.slice(prefix.length) : rootRelPath
152 if (!mod.files || !mod.files[rel]) continue
153 // The deepest module path wins: a nested package inside another one owns
154 // its own files.
155 if (!best || prefix.length > best.prefixLength) {
156 best = { module: name, file: rel, prefixLength: prefix.length }
157 }
158 }
159 return best ? { module: best.module, file: best.file } : { module: null, file: null }
160}
161
162/** The inverse: module + relative path back to the root-relative path. */
163export function joinFilePath (tree, moduleName, file) {
164 const mod = (tree.modules || {})[moduleName]
165 if (!mod || !file) return file || null
166 return mod.path && mod.path !== '.' ? mod.path + '/' + file : file
167}
lib/render.js +58 -65
……@@ -1947,28 +1947,54 @@ function _basePrefix () {
19471947 return API || ''
19481948}
19491949
1950// The URL is built by the SHARED serializer, so this view and the floor cannot
1951// disagree about what a place is called.
19501952function urlFromGraphStack (stack) {
1951 const base = _basePrefix()
1952 if (!stack || stack.length === 0) return base + '/'
1953 return window.__ctLoc
1954 ? window.__ctLoc.locationToUrl(locationFromStack(stack), _basePrefix())
1955 : _basePrefix() + '/'
1956}
1957
1958// graphStack stays as this view's working state; it converts to the shared
1959// location on the way out.
1960function locationFromStack (stack) {
1961 const loc = {
1962 surface: 'spa', kind: 'system', module: null, file: null, symbol: null, path: [], extra: {}
1963 }
1964 if (!stack || stack.length === 0) return loc
19531965
19541966 const first = stack[0]
19551967 if (first.type === 'module') {
1956 let url = base + '/module/' + encodeURIComponent(first.name)
1968 loc.kind = 'module'
1969 loc.module = first.name
19571970 const second = stack[1]
19581971 if (second && second.type === 'file') {
1959 url += '/file/' + second.name.split('/').map(encodeURIComponent).join('/')
1972 loc.kind = 'file'
1973 loc.file = second.name
19601974 }
1961 return url
1975 return loc
19621976 }
1963
19641977 if (first.type === 'path') {
1965 const parts = stack
1966 .filter(e => e.type === 'path')
1967 .map(e => encodeURIComponent(e.name))
1968 return base + '/path/' + parts.join('/')
1978 loc.kind = 'path'
1979 loc.path = stack.filter(e => e.type === 'path').map(e => e.name)
19691980 }
1981 return loc
1982}
19701983
1971 return base + '/'
1984// And the inverse, so a URL becomes this view's stack.
1985function stackFromLocation (loc) {
1986 if (!loc) return []
1987 if (loc.kind === 'module' || loc.kind === 'file' || loc.kind === 'symbol') {
1988 const stack = [{ type: 'module', name: loc.module, label: loc.module }]
1989 if (loc.file) {
1990 stack.push({ type: 'file', name: loc.file, label: loc.file, moduleName: loc.module })
1991 }
1992 return stack
1993 }
1994 if (loc.kind === 'path') {
1995 return loc.path.map(seg => ({ type: 'path', name: seg, label: seg }))
1996 }
1997 return []
19721998}
19731999
19742000function graphStackFromUrl (pathname) {
……@@ -1980,61 +2006,18 @@ function graphStackFromUrl (pathname) {
19802006// stack: graph stack (only meaningful for *-graph kinds)
19812007// extra: { moduleName (validation), docPath (docs), suggestPath (suggestions) }
19822008function routeFromUrl (pathname) {
1983 const base = _basePrefix()
1984 let p = pathname || '/'
1985 if (base && p.startsWith(base)) p = p.slice(base.length) || '/'
1986
1987 // Normalize: drop trailing slash (except root)
1988 if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1)
1989
1990 if (p === '/' || p === '/system' || p === '') return { kind: 'system', stack: [], extra: {} }
1991
1992 const segs = p.split('/').filter(Boolean)
1993
1994 if (segs[0] === 'validation') {
1995 const moduleName = (segs[1] === 'module' && segs[2]) ? decodeURIComponent(segs[2]) : null
1996 return { kind: 'validation', stack: [], extra: { moduleName } }
1997 }
1998
1999 if (segs[0] === 'docs') {
2000 const docPath = segs.length > 1 ? segs.slice(1).map(decodeURIComponent).join('/') : null
2001 return { kind: 'docs', stack: [], extra: { docPath } }
2002 }
2003
2004 if (segs[0] === 'suggestions' && segs.length > 1) {
2005 const suggestPath = segs.slice(1).map(decodeURIComponent).join('/')
2006 return { kind: 'suggestions', stack: [], extra: { suggestPath } }
2007 }
2008
2009 if (segs[0] === 'module' && segs[1]) {
2010 const moduleName = decodeURIComponent(segs[1])
2011 const stack = [{ type: 'module', name: moduleName, label: moduleName }]
2012 if (segs[2] === 'file' && segs.length > 3) {
2013 const filePath = segs.slice(3).map(decodeURIComponent).join('/')
2014 stack.push({
2015 type: 'file',
2016 name: filePath,
2017 moduleName,
2018 label: filePath.split('/').pop()
2019 })
2020 }
2021 return { kind: 'module-graph', stack, extra: {} }
2022 }
2009 // Parsed by the SHARED location module: the same string means the same place
2010 // here and on the floor. The graph-shaped kinds are translated back into this
2011 // view's stack; validation/docs/suggestions pass their extras through.
2012 if (!window.__ctLoc) return { kind: 'system', stack: [], extra: {} }
2013 const loc = window.__ctLoc.parseLocation(pathname, _basePrefix())
20232014
2024 if (segs[0] === 'path' && segs.length > 1) {
2025 const stack = segs.slice(1).map(s => {
2026 const name = decodeURIComponent(s)
2027 return { type: 'path', name, label: name }
2028 })
2029 return { kind: 'path-graph', stack, extra: {} }
2030 }
2015 const kind = loc.kind === 'module' || loc.kind === 'file' || loc.kind === 'symbol'
2016 ? 'module-graph'
2017 : loc.kind === 'path' ? 'path-graph' : loc.kind
20312018
2032 return { kind: 'system', stack: [], extra: {} }
2019 return { kind, stack: stackFromLocation(loc), extra: loc.extra || {} }
20332020}
2034
2035// Set to true inside popstate handling so nav functions skip pushing again.
2036let _suppressPushState = false
2037
20382021function pushStateForStack () {
20392022 if (_suppressPushState) return
20402023 const url = urlFromGraphStack(graphStack)
……@@ -2182,9 +2165,17 @@ function updateBreadcrumb () {
21822165 const isFinal = i === graphStack.length - 1
21832166 html += '<a href="' + href + '" class="' + (isFinal ? 'current' : '') + '" data-nav-level="' + (i + 1) + '">' + escapeHtml(graphStack[i].label) + '</a>'
21842167 }
2168 // The SAME place, drawn as the floor. This is what makes the two surfaces
2169 // co-related rather than merely similar: one location, two renderings.
2170 if (window.__ctLoc) {
2171 const floorHref = window.__ctLoc.otherSurface(locationFromStack(graphStack), _basePrefix())
2172 html += '<a href="' + floorHref + '" class="other-surface" ' +
2173 'style="margin-left:12px;color:#58a6ff">open in the floor view &rsaquo;</a>'
2174 }
21852175 bc.innerHTML = html
21862176
21872177 for (const a of bc.querySelectorAll('a')) {
2178 if (a.classList.contains('other-surface')) continue // a real navigation
21882179 a.addEventListener('click', (e) => {
21892180 if (e.button !== 0) return
21902181 if (e.metaKey || e.ctrlKey || e.shiftKey) return
……@@ -3930,12 +3921,14 @@ ${NODE_VIEW_JS}
39303921;(async function boot () {
39313922 if (!window.__ct) {
39323923 try {
3933 const [cards, layoutMod] = await Promise.all([
3924 const [cards, layoutMod, locationMod] = await Promise.all([
39343925 import(API + '/floor/cards.js'),
3935 import(API + '/floor/layout.js')
3926 import(API + '/floor/layout.js'),
3927 import(API + '/floor/location.js')
39363928 ])
39373929 window.__ct = cards
39383930 window.__ctLayout = layoutMod.layout
3931 window.__ctLoc = locationMod
39393932 } catch (err) {
39403933 document.getElementById('detail').innerHTML =
39413934 '<div style="padding:20px;color:#ff7b72">Could not load the shared view modules: ' +
lib/server.js +10 -1
……@@ -276,7 +276,12 @@ export function startServer (tree, opts = {}) {
276276 }
277277
278278 // --- the floor: a level of the node ladder, drawn from the derivation.
279 if (url === '/floor' || url === '/floor/') {
279 // /floor and every place under it serve the same shell; the client reads
280 // location.pathname, exactly as the SPA does. One scheme, two surfaces.
281 if (url === '/floor' || url === '/floor/' ||
282 (url.startsWith('/floor/') && !url.startsWith('/floor/level.js') &&
283 !url.startsWith('/floor/cards.js') && !url.startsWith('/floor/layout.js') &&
284 !url.startsWith('/floor/location.js'))) {
280285 sendFile(req, res, 'floor/level.html', 'text/html; charset=utf-8')
281286 return
282287 }
……@@ -286,6 +291,10 @@ export function startServer (tree, opts = {}) {
286291 }
287292 // The view imports the SAME layout module the tests run against, rather than
288293 // a copy of it that can drift.
294 if (url === '/floor/location.js') {
295 sendFile(req, res, 'floor/location.js', 'application/javascript; charset=utf-8')
296 return
297 }
289298 if (url === '/floor/cards.js') {
290299 sendFile(req, res, 'floor/cards.js', 'application/javascript; charset=utf-8')
291300 return
test/location.test.js +93 -0
……@@ -0,0 +1,93 @@
1import { test } from 'brittle'
2import { resolve, dirname } from 'path'
3import { fileURLToPath } from 'url'
4import { scan } from '../lib/scanner.js'
5import {
6 parseLocation, locationToUrl, otherSurface, splitFilePath, joinFilePath
7} from '../lib/floor/location.js'
8
9const HERE = dirname(fileURLToPath(import.meta.url))
10const WORKSPACE = resolve(HERE, 'fixtures/workspace')
11
12// One location model for both surfaces. The SPA's PATH scheme is canonical
13// because it is the one with existing deep links; the floor adopts it under a
14// /floor prefix and the symbol rung extends it.
15
16test('location: the canonical SPA scheme round-trips', (t) => {
17 const cases = [
18 ['/', { surface: 'spa', kind: 'system' }],
19 ['/module/hyper-overlay', { surface: 'spa', kind: 'module', module: 'hyper-overlay' }],
20 ['/module/hyper-overlay/file/lib/tree.js',
21 { surface: 'spa', kind: 'file', module: 'hyper-overlay', file: 'lib/tree.js' }],
22 ['/path/hyper-fleeky/codetree', { surface: 'spa', kind: 'path', path: ['hyper-fleeky', 'codetree'] }]
23 ]
24 for (const [url, expected] of cases) {
25 const loc = parseLocation(url)
26 for (const k of Object.keys(expected)) t.alike(loc[k], expected[k], url + ' -> ' + k)
27 t.is(locationToUrl(loc), url, url + ' round-trips')
28 }
29})
30
31test('location: the symbol rung extends the same scheme', (t) => {
32 const url = '/module/codetree/file/lib/floor/cards.js/symbol/buildFileLevel'
33 const loc = parseLocation(url)
34
35 t.is(loc.kind, 'symbol', 'a symbol location')
36 t.is(loc.module, 'codetree', 'module')
37 t.is(loc.file, 'lib/floor/cards.js', 'the file path keeps its own slashes')
38 t.is(loc.symbol, 'buildFileLevel', 'symbol')
39 t.is(locationToUrl(loc), url, 'round-trips')
40})
41
42test('location: the floor is the same place under a prefix', (t) => {
43 const spa = parseLocation('/module/codetree/file/index.js')
44 const floor = parseLocation('/floor/module/codetree/file/index.js')
45
46 t.is(floor.surface, 'floor', 'surface is read from the prefix')
47 t.is(spa.surface, 'spa', 'and defaults to the SPA')
48 for (const k of ['kind', 'module', 'file', 'symbol']) {
49 t.alike(floor[k], spa[k], 'same ' + k)
50 }
51 t.is(locationToUrl(floor), '/floor/module/codetree/file/index.js', 'round-trips with the prefix')
52})
53
54test('location: otherSurface is the same place on the other view', (t) => {
55 const loc = parseLocation('/module/codetree/file/lib/layout.js')
56 t.is(otherSurface(loc), '/floor/module/codetree/file/lib/layout.js', 'spa -> floor')
57
58 const back = parseLocation(otherSurface(loc))
59 t.is(otherSurface(back), '/module/codetree/file/lib/layout.js', 'floor -> spa')
60 t.alike({ ...back, surface: 'spa' }, loc, 'and nothing else changes')
61})
62
63test('location: a base prefix is honoured on the way in and out', (t) => {
64 const loc = parseLocation('/codetree/module/x', '/codetree')
65 t.is(loc.kind, 'module', 'parsed under the prefix')
66 t.is(locationToUrl(loc, '/codetree'), '/codetree/module/x', 'and written back with it')
67})
68
69test('location: the SPA-only views survive', (t) => {
70 t.is(parseLocation('/validation').kind, 'validation', 'validation')
71 t.is(parseLocation('/validation/module/x').extra.moduleName, 'x', 'with its module')
72 t.is(parseLocation('/docs/spec/SYSTEM.md').extra.docPath, 'spec/SYSTEM.md', 'docs path')
73 t.is(locationToUrl(parseLocation('/docs/spec/SYSTEM.md')), '/docs/spec/SYSTEM.md', 'round-trips')
74 t.is(parseLocation('/suggestions/spec/x.md').kind, 'suggestions', 'suggestions')
75})
76
77test('location: nonsense is the system level, not a throw', (t) => {
78 for (const url of ['', '/nope', '/module', '/module//file//', '///']) {
79 const loc = parseLocation(url)
80 t.ok(loc.kind, url + ' parses to something: ' + loc.kind)
81 }
82})
83
84test('location: a root-relative path splits into module + file, and back', async (t) => {
85 const tree = await scan(WORKSPACE, { maxDepth: 1 })
86
87 const split = splitFilePath(tree, 'widget-store/lib/widget-store.js')
88 t.is(split.module, 'widget-store', 'module found')
89 t.is(split.file, 'lib/widget-store.js', 'path within it')
90 t.is(joinFilePath(tree, split.module, split.file), 'widget-store/lib/widget-store.js', 'and back')
91
92 t.alike(splitFilePath(tree, 'nope/nothing.js'), { module: null, file: null }, 'unknown is null')
93})