polen
Version:
A framework for delightful GraphQL developer portals
371 lines (319 loc) • 12.8 kB
text/typescript
import * as fc from 'fast-check'
import { describe, expect, it } from 'vitest'
import { PathMap } from './$.js'
describe('PathMap', () => {
describe('create', () => {
it('returns RelativePathMap when no base provided', () => {
const paths = PathMap.create({
src: {
lib: {
utils: 'utils.ts',
},
},
})
expect(paths).not.toHaveProperty('rooted')
expect(paths).not.toHaveProperty('absolute')
expect(paths).not.toHaveProperty('base')
expect(paths.src.lib.utils).toBe('utils.ts')
})
it('returns PathMap with all variants when base provided', () => {
const paths = PathMap.create({
src: {
lib: {
utils: 'utils.ts',
},
},
}, '/project')
expect(paths).toHaveProperty('relative')
expect(paths).toHaveProperty('rooted')
expect(paths).toHaveProperty('absolute')
expect(paths).toHaveProperty('base')
expect(paths.base).toBe('/project')
})
it('processes relative paths correctly', () => {
const paths = PathMap.create({
template: {
server: {
app: 'app.ts',
main: 'main.ts',
},
client: {
entry: 'entry.tsx',
},
'routes.tsx': 'routes.tsx',
},
}, '/base')
// Relative paths - local to parent
expect(paths.relative.template.server.app).toBe('app.ts')
expect(paths.relative.template.server.main).toBe('main.ts')
expect(paths.relative.template.client.entry).toBe('entry.tsx')
expect(paths.relative.template['routes.tsx']).toBe('routes.tsx')
// Directory $ properties - just the segment name
expect(paths.relative.$).toBe('.')
expect(paths.relative.template.$).toBe('template')
expect(paths.relative.template.server.$).toBe('server')
expect(paths.relative.template.client.$).toBe('client')
})
it('processes rooted paths correctly', () => {
const paths = PathMap.create({
template: {
server: {
app: 'app.ts',
main: 'main.ts',
},
client: {
entry: 'entry.tsx',
},
},
}, '/base')
// Rooted paths - from PathMap root
expect(paths.rooted.template.server.app).toBe('template/server/app.ts')
expect(paths.rooted.template.server.main).toBe('template/server/main.ts')
expect(paths.rooted.template.client.entry).toBe('template/client/entry.tsx')
// Directory $ properties - full path from root
expect(paths.rooted.$).toBe('.')
expect(paths.rooted.template.$).toBe('template')
expect(paths.rooted.template.server.$).toBe('template/server')
expect(paths.rooted.template.client.$).toBe('template/client')
})
it('processes absolute paths correctly', () => {
const paths = PathMap.create({
template: {
server: {
app: 'app.ts',
main: 'main.ts',
},
client: {
entry: 'entry.tsx',
},
},
}, '/project')
// Absolute paths
expect(paths.absolute.template.server.app).toBe('/project/template/server/app.ts')
expect(paths.absolute.template.server.main).toBe('/project/template/server/main.ts')
expect(paths.absolute.template.client.entry).toBe('/project/template/client/entry.tsx')
// Directory $ properties
expect(paths.absolute.$).toBe('/project')
expect(paths.absolute.template.$).toBe('/project/template')
expect(paths.absolute.template.server.$).toBe('/project/template/server')
expect(paths.absolute.template.client.$).toBe('/project/template/client')
})
it('handles deeply nested structures', () => {
const paths = PathMap.create({
a: {
b: {
c: {
d: {
e: 'file.ts',
},
},
},
},
}, '/root')
expect(paths.relative.a.b.c.d.e).toBe('file.ts')
expect(paths.rooted.a.b.c.d.e).toBe('a/b/c/d/file.ts')
expect(paths.absolute.a.b.c.d.e).toBe('/root/a/b/c/d/file.ts')
expect(paths.relative.a.b.c.d.$).toBe('d')
expect(paths.rooted.a.b.c.d.$).toBe('a/b/c/d')
expect(paths.absolute.a.b.c.d.$).toBe('/root/a/b/c/d')
})
it('handles quoted property names', () => {
const paths = PathMap.create({
'src-files': {
'main.config.ts': 'main.config.ts',
'.env': '.env',
},
}, '/app')
expect(paths.relative['src-files']['main.config.ts']).toBe('main.config.ts')
expect(paths.relative['src-files']['.env']).toBe('.env')
expect(paths.rooted['src-files']['main.config.ts']).toBe('src-files/main.config.ts')
expect(paths.absolute['src-files']['.env']).toBe('/app/src-files/.env')
})
})
describe('rebase', () => {
it('creates new PathMap with different base', () => {
const original = PathMap.create({
src: {
lib: 'lib.ts',
},
}, '/original')
const rebased = PathMap.rebase(original, '/new')
expect(rebased.base).toBe('/new')
expect(rebased.absolute.src.lib).toBe('/new/src/lib.ts')
// Relative and rooted should remain the same
expect(rebased.relative.src.lib).toBe('lib.ts')
expect(rebased.rooted.src.lib).toBe('src/lib.ts')
})
it('can rebase from RelativePathMap', () => {
const relative = PathMap.create({
src: {
lib: 'lib.ts',
},
})
const based = PathMap.rebase(relative, '/base')
expect(based.base).toBe('/base')
expect(based.absolute.src.lib).toBe('/base/src/lib.ts')
expect(based.relative.src.lib).toBe('lib.ts')
expect(based.rooted.src.lib).toBe('src/lib.ts')
})
it('supports chained rebasing', () => {
const p1 = PathMap.create({ src: { file: 'file.ts' } }, '/base1')
const p2 = PathMap.rebase(p1, '/base2')
const p3 = PathMap.rebase(p2, '/base3')
expect(p1.base).toBe('/base1')
expect(p2.base).toBe('/base2')
expect(p3.base).toBe('/base3')
expect(p1.absolute.src.file).toBe('/base1/src/file.ts')
expect(p2.absolute.src.file).toBe('/base2/src/file.ts')
expect(p3.absolute.src.file).toBe('/base3/src/file.ts')
})
})
describe('property tests', () => {
// Arbitrary for valid path segments
const pathSegment = fc.stringMatching(/^[a-zA-Z0-9_-]+$/)
// Arbitrary for file names
const fileName = fc.stringMatching(/^[a-zA-Z0-9_-]+\.(ts|js|tsx|jsx)$/)
// Arbitrary for path input structures
const pathInput = fc.letrec<{ pathInput: PathMap.PathInput }>(tie => ({
pathInput: fc.dictionary(
pathSegment,
fc.oneof(
fileName,
tie('pathInput'),
),
{ minKeys: 1, maxKeys: 3 },
),
})).pathInput
it('relative paths are always substrings of rooted paths', () => {
fc.assert(
fc.property(pathInput, fc.string({ minLength: 1 }).filter(s => s.startsWith('/')), (input, base) => {
const paths = PathMap.create(input, base)
// For every file path, relative should be at the end of rooted
function check(rel: any, rooted: any) {
for (const [key, value] of Object.entries(rel)) {
if (key === '$') continue
if (typeof value === 'string' && typeof rooted[key] === 'string') {
expect(rooted[key]).toContain(value)
expect(rooted[key].endsWith(value)).toBe(true)
} else if (typeof value === 'object') {
check(value, rooted[key])
}
}
}
check(paths.relative, paths.rooted)
}),
)
})
it('absolute paths always start with base', () => {
fc.assert(
fc.property(pathInput, fc.string({ minLength: 1 }).filter(s => s.startsWith('/')), (input, base) => {
const paths = PathMap.create(input, base)
// Base is normalized (trimmed, multiple slashes collapsed, trailing removed)
const collapsedBase = base.trim().replace(/\/+/g, '/')
const normalizedBase = collapsedBase === '/' ? '/' : collapsedBase.replace(/\/$/, '')
function checkAbsolute(obj: any) {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') {
expect(value.startsWith(normalizedBase)).toBe(true)
} else if (typeof value === 'object') {
checkAbsolute(value)
}
}
}
checkAbsolute(paths.absolute)
}),
)
})
it('rebase preserves structure', () => {
fc.assert(
fc.property(
pathInput,
fc.string({ minLength: 1 }).filter(s => s.startsWith('/')),
fc.string({ minLength: 1 }).filter(s => s.startsWith('/')),
(input, base1, base2) => {
const p1 = PathMap.create(input, base1)
const p2 = PathMap.rebase(p1, base2)
// Structure should be identical
expect(JSON.stringify(p1.relative)).toBe(JSON.stringify(p2.relative))
expect(JSON.stringify(p1.rooted)).toBe(JSON.stringify(p2.rooted))
// Only absolute paths and base should change
const collapsedBase2 = base2.trim().replace(/\/+/g, '/')
const normalizedBase2 = collapsedBase2 === '/' ? '/' : collapsedBase2.replace(/\/$/, '')
expect(p2.base).toBe(normalizedBase2)
function checkBase(obj: any) {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') {
expect(value.startsWith(normalizedBase2)).toBe(true)
} else if (typeof value === 'object') {
checkBase(value)
}
}
}
checkBase(p2.absolute)
},
),
)
})
it('$ properties are consistent across variants', () => {
fc.assert(
fc.property(pathInput, fc.string({ minLength: 1 }).filter(s => s.startsWith('/')), (input, base) => {
const paths = PathMap.create(input, base)
function checkDirs(rel: any, rooted: any, abs: any, currentPath: string[] = []) {
if (rel.$ && rooted.$ && abs.$) {
// Relative $ should be just the last segment or '.'
if (currentPath.length === 0) {
expect(rel.$).toBe('.')
expect(rooted.$).toBe('.')
// Base is normalized in create() (trimmed, collapsed, trailing removed)
const collapsedBase = base.trim().replace(/\/+/g, '/')
const normalizedBase = collapsedBase === '/' ? '/' : collapsedBase.replace(/\/$/, '')
expect(abs.$).toBe(normalizedBase)
} else {
expect(rel.$).toBe(currentPath[currentPath.length - 1])
expect(rooted.$).toBe(currentPath.join('/'))
// Handle base that might be '/' or have trailing slash
// Note: create() normalizes the base (trim + collapse + remove trailing)
const collapsedBase = base.trim().replace(/\/+/g, '/')
const normalizedBase = collapsedBase === '/' ? '/' : collapsedBase.replace(/\/$/, '')
const expectedAbs = normalizedBase === '/'
? `/${currentPath.join('/')}`
: `${normalizedBase}/${currentPath.join('/')}`
expect(abs.$).toBe(expectedAbs)
}
}
for (const key of Object.keys(rel)) {
if (key === '$' || typeof rel[key] === 'string') continue
if (rel[key] && rooted[key] && abs[key]) {
checkDirs(rel[key], rooted[key], abs[key], [...currentPath, key])
}
}
}
checkDirs(paths.relative, paths.rooted, paths.absolute)
}),
)
})
})
describe('type safety', () => {
it('maintains type information through operations', () => {
const paths = PathMap.create({
src: {
lib: {
utils: 'utils.ts',
helpers: 'helpers.ts',
},
components: {
Button: 'Button.tsx',
},
},
}, '/project')
// TypeScript should know about these paths
const _utils: string = paths.relative.src.lib.utils
const _button: string = paths.absolute.src.components.Button
const _libDir: string = paths.rooted.src.lib.$
// @ts-expect-error - should not exist
paths.relative.src.lib.unknown
// @ts-expect-error - should not exist
paths.absolute.notThere
})
})
})