Skip to content

Commit

Permalink
add template test util
Browse files Browse the repository at this point in the history
  • Loading branch information
dmaskasky committed Oct 3, 2024
1 parent 19e5a4e commit 0d8d4cd
Show file tree
Hide file tree
Showing 12 changed files with 468 additions and 209 deletions.
109 changes: 49 additions & 60 deletions __tests__/ScopeProvider3/01_basic_spec.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import {
useSetAtom,
useAtomValue,
atom,
type Atom,
type WritableAtom,
type SetStateAction,
createStore,
} from 'jotai'
import { atomWithReducer } from 'jotai/vanilla/utils'
import { ScopeProvider } from 'src/ScopeProvider3/ScopeProvider'
import { clickButton, getTextContents } from './utils'
import { clickButton, getAtoms, getTextContents, increment, subscribe } from './utils'

Check failure on line 14 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

`./utils` import should occur after import of `src/ScopeProvider3/scope`

Check failure on line 14 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

`./utils` import should occur after import of `src/ScopeProvider3/scope`
import { AnyAtom, AtomState, Store } from 'src/ScopeProvider3/types'

Check warning on line 15 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

'Store' is defined but never used

Check warning on line 15 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

'Store' is defined but never used
import { createScope } from 'src/ScopeProvider3/scope'

describe('Counter', () => {
/*
Expand Down Expand Up @@ -191,72 +195,57 @@ describe('Counter', () => {
})

/*
base, derived(base)
S0[base]: derived0(base0)
S1[base]: derived0(base1)
a, b(a)
S0[ ]: a0(b0)
S1[b]: a0(b1)
*/
test('04. unscoped derived can read and write to scoped primitive atoms', () => {
const baseAtom = atom(0)
baseAtom.debugLabel = 'base'
const derivedAtom = atom(
(get) => get(baseAtom),
(get, set) => set(baseAtom, get(baseAtom) + 1),
const a = atom(0)
a.debugLabel = 'a'
const b = atom(
(get) => get(a),
(_get, set) => set(a, increment),
)
b.debugLabel = 'b'

const s1Atoms = new Set<AnyAtom>([b])

const s0AtomStateMap = new Map<AnyAtom, AtomState>()
const s0Store = createStore().unstable_derive((_, ...traps) => {
return [
function getAtomState<Value>(atom: Atom<Value>) {
let atomState = s0AtomStateMap.get(atom) as AtomState<Value> | undefined
if (!atomState) {
atomState = { d: new Map(), p: new Set(), n: 0 }
s0AtomStateMap.set(atom, atomState)
}
return atomState
},
...traps,
]
})
const { store: s1Store, atomStateMap: s1AtomStateMap } = createScope(

Check warning on line 227 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's1AtomStateMap' is assigned a value but never used

Check warning on line 227 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's1AtomStateMap' is assigned a value but never used
s1Atoms,
new Set(),
s0Store,
'S1',
)
derivedAtom.debugLabel = 'derived'

function Counter({ level }: { level: string }) {
const [derived, increaseFromDerived] = useAtom(derivedAtom)
const value = useAtomValue(baseAtom)
return (
<div>
base:<span className={`${level} base`}>{derived}</span>
value:<span className={`${level} value`}>{value}</span>
<button className={`${level} setBase`} type="button" onClick={increaseFromDerived}>
increase
</button>
</div>
)
}

function App() {
return (
<div>
<h1>Unscoped</h1>
<Counter level="level0" />
<h1>Scoped Provider</h1>
<ScopeProvider atoms={[baseAtom]}>
<Counter level="level1" />
</ScopeProvider>
</div>
)
}
const { container } = render(<App />)
const increaseUnscopedBase = '.level0.setBase'
const increaseScopedBase = '.level1.setBase'
const atomValueSelectors = ['.level0.base', '.level0.value', '.level1.base', '.level1.value']
const [s0DerivedCb] = subscribe(s0Store, b)

Check warning on line 234 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's0DerivedCb' is assigned a value but never used

Check warning on line 234 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's0DerivedCb' is assigned a value but never used
const [s0BaseCb] = subscribe(s0Store, a)

Check warning on line 235 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's0BaseCb' is assigned a value but never used

Check warning on line 235 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's0BaseCb' is assigned a value but never used
const [s1DerivedCb] = subscribe(s1Store, b)

Check warning on line 236 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's1DerivedCb' is assigned a value but never used

Check warning on line 236 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's1DerivedCb' is assigned a value but never used
const [s1BaseCb] = subscribe(s1Store, a)

Check warning on line 237 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's1BaseCb' is assigned a value but never used

Check warning on line 237 in __tests__/ScopeProvider3/01_basic_spec.test.tsx

View workflow job for this annotation

GitHub Actions / test

's1BaseCb' is assigned a value but never used

expect(getTextContents(container, atomValueSelectors)).toEqual([
'0', // level0 base
'0', // level0 value
'0', // level1 base
'0', // level1 value
])
expect(getAtoms(s0Store, [a, b])).toEqual(['0', '0'])
expect(getAtoms(s1Store, [a, b])).toEqual(['0', '0'])

clickButton(container, increaseUnscopedBase)
expect(getTextContents(container, atomValueSelectors)).toEqual([
'1', // level0 base
'1', // level0 value
'0', // level1 base
'0', // level1 value
])
s0Store.set(b)
expect(getAtoms(s0Store, [a, b])).toEqual(['1', '1'])
expect(getAtoms(s1Store, [a, b])).toEqual(['0', '0'])

clickButton(container, increaseScopedBase)
expect(getTextContents(container, atomValueSelectors)).toEqual([
'1', // level0 base
'1', // level0 value
'1', // level1 base
'1', // level1 value
])
s1Store.set(b)
expect(getAtoms(s0Store, [a, b])).toEqual(['1', '1'])
expect(getAtoms(s1Store, [a, b])).toEqual(['1', '1'])
})

/*
Expand Down
4 changes: 2 additions & 2 deletions __tests__/ScopeProvider3/09_mount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ it('computed atom mounts once for the unscoped and once for the scoped', () => {
)
}
const { unmount } = render(<App />)
expect(onMount).toHaveBeenCalledTimes(2)
expect(onMount).toHaveBeenCalledTimes(1)
unmount()
expect(onUnmount).toHaveBeenCalledTimes(2)
expect(onUnmount).toHaveBeenCalledTimes(1)
})
29 changes: 29 additions & 0 deletions __tests__/ScopeProvider3/scopeTemplate/scopeTemplate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { RESET } from 'jotai/utils'

Check warning on line 1 in __tests__/ScopeProvider3/scopeTemplate/scopeTemplate.test.ts

View workflow job for this annotation

GitHub Actions / test

'RESET' is defined but never used

Check warning on line 1 in __tests__/ScopeProvider3/scopeTemplate/scopeTemplate.test.ts

View workflow job for this annotation

GitHub Actions / test

'RESET' is defined but never used
import { scopeTemplate } from './scopeTemplate'

describe('scopeTemplate', () => {
test('basic', () => {
const {
atoms: { a, b, c },

Check warning on line 7 in __tests__/ScopeProvider3/scopeTemplate/scopeTemplate.test.ts

View workflow job for this annotation

GitHub Actions / test

'a' is assigned a value but never used

Check warning on line 7 in __tests__/ScopeProvider3/scopeTemplate/scopeTemplate.test.ts

View workflow job for this annotation

GitHub Actions / test

'c' is assigned a value but never used

Check warning on line 7 in __tests__/ScopeProvider3/scopeTemplate/scopeTemplate.test.ts

View workflow job for this annotation

GitHub Actions / test

'a' is assigned a value but never used

Check warning on line 7 in __tests__/ScopeProvider3/scopeTemplate/scopeTemplate.test.ts

View workflow job for this annotation

GitHub Actions / test

'c' is assigned a value but never used
scopes: { S0, S1 },
getAtoms,
resetAll,
} = scopeTemplate(`
a, b(a), c(a + b(a))
S0[ ]: a0, b0(a0), c0(a0, b0(a0))
S1[b]: a0, b1(a1), c0(a0, b1(a1))
`)
expect(getAtoms(S0)).toEqual(['a', 'a', 'aa'])
expect(getAtoms(S1)).toEqual(['a', 'a', 'aa'])
S0.set(b, '*')
expect(getAtoms(S0)).toEqual(['*', '*', '**'])
expect(getAtoms(S1)).toEqual(['*', 'a', '*a'])

resetAll()
expect(getAtoms(S0)).toEqual(['a', 'a', 'aa'])
expect(getAtoms(S1)).toEqual(['a', 'a', 'aa'])
S1.set(b, '*')
expect(getAtoms(S0)).toEqual(['a', 'a', 'aa'])
expect(getAtoms(S1)).toEqual(['a', '*', 'a*'])
})
})
147 changes: 147 additions & 0 deletions __tests__/ScopeProvider3/scopeTemplate/scopeTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { type Atom, atom, createStore } from 'jotai'
import { createScope } from 'src/ScopeProvider3/scope'
import type { AnyAtom, AnyWritableAtom, AtomState, Store } from 'src/ScopeProvider3/types'
import { NamedStore } from '../utils'
import type { BuildAtomTypes, ExtractAtomDefs, ExtractScopes } from './types'
import { atomWithReset } from 'jotai/utils'

type WithAtomStateMap<T> = T & { atomStateMap: Map<AnyAtom, AtomState> }

/**
* Parses a string representing atom dependencies in nested scopes,
* and constructs atoms, scopes with their corresponding atom state maps.
*
* @param {string} scopeDescription - The template strings array.
* @example
* const { atoms, scopes } = scopeTemplate(`
* a, b(a)
* S0[ ]: a0, b0(a0)
* S1[b]: a0, b1(a1)
* `);
* @returns {Object} { atoms: { a, b }, scopes: { S0, S1 } }
*/
export function scopeTemplate<
T extends string,
Defs extends string[] = ExtractAtomDefs<T>,
Scopes extends string[] = ExtractScopes<T>,
>(scopeDescription: T) {
const lines = scopeDescription
.split('\n')
.map((line) => line.trim())
.filter(Boolean)

// First line defines the atoms and their dependencies
const atoms = parseAtomsLine<Defs>(lines.shift()!)
type Atoms = typeof atoms
const scopes = {} as { [K in Scopes[number]]: NamedStore }
// Parse scopes
let scopeNumber = 0

if (lines[0]?.match(/^S0/i)) {
lines.shift()
}
const baseStore = createBaseStore('S' + scopeNumber++)
let currentStore: Store = (scopes[baseStore.name as Scopes[number]] = baseStore)

for (const line of lines) {
const match = line.match(/^\w+\[(.*)\]:\s*.*$/)![1]!
if (!match) {
throw new Error(`Invalid scope line: ${line}`)
}
const scopedAtoms = new Set(
match.split(',').map((s) => atomByName(s as keyof Atoms, atoms) as AnyAtom),
)
const store = createScopedStore('S' + scopeNumber++, scopedAtoms, currentStore)
currentStore = scopes[store.name as Scopes[number]] = store
}

function getAtoms(store: Store, atomList: AnyAtom[] = Object.values(atoms)) {
return atomList.map(store.get)
}
function reset(store: WithAtomStateMap<any>) {
store.atomStateMap.clear()
}
function resetAll() {
for (const store of Object.values(scopes)) {
reset(store)
}
}

return { atoms, scopes, getAtoms, reset, resetAll }
}

function createScopedStore(
name: string,
scopedAtoms: Set<AnyAtom>,
currentStore: Store,
): WithAtomStateMap<NamedStore> {
const { store, atomStateMap } = createScope(scopedAtoms, new Set(), currentStore, name)
return Object.assign(store, { atomStateMap })
}

function createBaseStore(name: string = 'S0') {
const atomStateMap = new Map<AnyAtom, AtomState>()
const s0Store = createStore().unstable_derive((_, ...traps) => {
return [
function getAtomState<Value>(a: Atom<Value>) {
let atomState = atomStateMap.get(a) as AtomState<Value> | undefined
if (!atomState) {
atomState = { d: new Map(), p: new Set(), n: 0 }
atomStateMap.set(a, atomState)
}
return atomState
},
...traps,
]
}) as WithAtomStateMap<NamedStore>
return Object.assign(s0Store, { name, atomStateMap })
}

function parseAtomsLine<Defs extends string[]>(line: string) {
// Split by commas
const atomDefs = line.split(',').map((s) => s.trim())
const atoms = {} as BuildAtomTypes<Defs>
for (const atomDef of atomDefs) {
const { name, deps } = parseAtomDef(atomDef, atoms)
Object.assign(atoms, { [name]: createAtom(name, deps) })
}
return atoms
}

function parseAtomDef(atomDef: string, atoms: Record<string, AnyAtom>) {
// atomDef is something like 'a' or 'b(a)'
const match = atomDef.match(/^(\w+)(?:\((.*)\))?$/)
if (!match) {
throw new Error(`Invalid atom definition: ${atomDef}`)
}
const name = match[1]!
const deps: AnyAtom[] = match[2]
? match[2]
.split('+')
.map((s) => s.trim())
.map((s) => parseAtomDef(s, atoms))
.map((def) => atomByName(def.name, atoms))
: []
return { name, deps }
}

function createAtom(name: string, deps: AnyAtom[]) {
// Create atoms based on their dependencies
let atomInstance
if (deps.length === 0) {
atomInstance = atomWithReset<string>(name)
} else if (deps.length === 1) {
atomInstance = atom(
(get) => get(deps[0]!),
(_get, set, ...args) => set(deps[0]! as AnyWritableAtom, ...args),
)
} else {
atomInstance = atom((get) => deps.reduce((acc, depName) => acc + get(depName), ''))
}
atomInstance.debugLabel = name
return atomInstance
}

function atomByName<Atoms extends Record<string, AnyAtom>>(name: keyof Atoms, atoms: Atoms) {
return atoms[name] as AnyAtom
}
76 changes: 76 additions & 0 deletions __tests__/ScopeProvider3/scopeTemplate/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { AnyAtom, AnyWritableAtom } from 'src/ScopeProvider3/types'

export type ExtractAtomDefs<S extends string> = SplitAtoms<FirstNonemptyLine<S>>

type FirstNonemptyLine<S extends string> = S extends `${infer Line}\n${infer Rest}`
? Trim<Line> extends ''
? FirstNonemptyLine<Rest>
: Trim<Line>
: Trim<S>

type SplitAtoms<
S extends string,
Acc extends string[] = [],
> = S extends `${infer AtomDef},${infer Rest}`
? SplitAtoms<Rest, [...Acc, Trim<AtomDef>]>
: S extends `${infer AtomDef}`
? [...Acc, Trim<AtomDef>]
: Acc

type ScopeLines<S extends string> = S extends `${infer Line}\n${infer Rest}`
? Trim<Line> extends ''
? ScopeLines<Rest>
: Rest
: ''

export type ExtractScopes<S extends string> = SplitScopes<ScopeLines<S>>

type SplitScopes<
S extends string,
Acc extends string[] = [],
> = S extends `${infer Line}\n${infer Rest}`
? SplitScopes<Rest, [...Acc, ExtractScopeName<Line>]>
: S extends `${infer Line}`
? [...Acc, ExtractScopeName<Line>]
: Acc

type ExtractScopeName<S extends string> = S extends `${infer Name}[${string}]:${string}`
? Trim<Name>
: never

type Trim<S extends string> = S extends ` ${infer T}` | `${infer T} ` ? Trim<T> : S

type SplitDeps<
S extends string,
Acc extends string[] = [],
> = S extends `${infer Dep} + ${infer Rest}`
? SplitDeps<Rest, [...Acc, Trim<Dep>]>
: S extends `${infer Dep}`
? [...Acc, Trim<Dep>]
: Acc

type ParseAtomDef<S extends string> = S extends `${infer Name}(${infer Deps})`
? { name: Trim<Name>; deps: SplitDeps<Deps> }
: { name: Trim<S>; deps: [] }

type AtomTypeFromDef<Def extends string> = ParseAtomDef<Def> extends {
name: infer Name
deps: infer Deps
}
? {
[K in Name & string]: Deps extends any[]
? Deps['length'] extends 0 | 1
? AnyWritableAtom
: AnyAtom
: never
}
: never

export type BuildAtomTypes<Defs extends string[], Result = {}> = Defs extends [
infer Def,
...infer Rest,
]
? Rest extends string[]
? BuildAtomTypes<Rest, Result & AtomTypeFromDef<Def & string>>
: never
: Result
Loading

0 comments on commit 0d8d4cd

Please sign in to comment.