test: cover mobile compact navigation

This commit is contained in:
lucaronin
2026-05-04 00:38:49 +02:00
parent ef544006d8
commit e0887c6d71
4 changed files with 144 additions and 23 deletions

View File

@@ -18,26 +18,28 @@ import {
SlidersHorizontal,
} from 'phosphor-react-native'
import { MobileNote, notes, sidebarSections } from './demoData'
import {
createCompactNavigationState,
transitionCompactNavigation,
type CompactNavigationEvent,
type CompactPanel,
} from './compactNavigation'
import { NamedIcon, type IconName } from './NamedIcon'
import { styles } from './styles'
import { colors } from './theme'
type CompactPanel = 'sidebar' | 'list' | 'note' | 'properties'
export function MobileApp() {
const { width } = useWindowDimensions()
const isTablet = width >= 820
const showsProperties = width >= 1120
const [selectedNoteId, setSelectedNoteId] = useState(notes[0].id)
const [compactPanel, setCompactPanel] = useState<CompactPanel>('list')
const [compactNavigation, setCompactNavigation] = useState(() => createCompactNavigationState(notes[0].id))
const selectedNote = useMemo(
() => notes.find((note) => note.id === selectedNoteId) ?? notes[0],
[selectedNoteId],
() => notes.find((note) => note.id === compactNavigation.selectedNoteId) ?? notes[0],
[compactNavigation.selectedNoteId],
)
const selectNote = (note: MobileNote) => {
setSelectedNoteId(note.id)
setCompactPanel('note')
setCompactNavigation((state) => transitionCompactNavigation(state, { type: 'selectNote', noteId: note.id }))
}
return (
@@ -46,16 +48,16 @@ export function MobileApp() {
{isTablet ? (
<View style={styles.tabletShell}>
<SidebarPanel />
<NoteListPanel selectedNoteId={selectedNoteId} onSelectNote={selectNote} />
<NoteListPanel selectedNoteId={compactNavigation.selectedNoteId} onSelectNote={selectNote} />
<EditorPanel note={selectedNote} />
{showsProperties ? <PropertiesPanel note={selectedNote} /> : null}
</View>
) : (
<CompactShell
activePanel={compactPanel}
activePanel={compactNavigation.panel}
note={selectedNote}
selectedNoteId={selectedNoteId}
onPanelChange={setCompactPanel}
selectedNoteId={compactNavigation.selectedNoteId}
onNavigate={(event) => setCompactNavigation((state) => transitionCompactNavigation(state, event))}
onSelectNote={selectNote}
/>
)}
@@ -67,38 +69,38 @@ export function MobileApp() {
function CompactShell({
activePanel,
note,
onPanelChange,
onNavigate,
onSelectNote,
selectedNoteId,
}: {
activePanel: CompactPanel
note: MobileNote
onPanelChange: (panel: CompactPanel) => void
onNavigate: (event: CompactNavigationEvent) => void
onSelectNote: (note: MobileNote) => void
selectedNoteId: string
}) {
if (activePanel === 'sidebar') {
return <SidebarPanel onClose={() => onPanelChange('list')} />
return <SidebarPanel onClose={() => onNavigate({ type: 'closeSidebar' })} />
}
if (activePanel === 'note') {
return (
<EditorPanel
note={note}
onBack={() => onPanelChange('list')}
onOpenProperties={() => onPanelChange('properties')}
onBack={() => onNavigate({ type: 'backToList' })}
onOpenProperties={() => onNavigate({ type: 'openProperties' })}
/>
)
}
if (activePanel === 'properties') {
return <PropertiesPanel note={note} onClose={() => onPanelChange('note')} />
return <PropertiesPanel note={note} onClose={() => onNavigate({ type: 'closeProperties' })} />
}
return (
<NoteListPanel
selectedNoteId={selectedNoteId}
onOpenSidebar={() => onPanelChange('sidebar')}
onOpenSidebar={() => onNavigate({ type: 'openSidebar' })}
onSelectNote={onSelectNote}
/>
)

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
createCompactNavigationState,
transitionCompactNavigation,
type CompactNavigationState,
} from './compactNavigation'
describe('compact mobile navigation', () => {
it('starts on the note list with the first note selected', () => {
expect(createCompactNavigationState('workflow')).toEqual({
panel: 'list',
selectedNoteId: 'workflow',
})
})
it('opens a selected note from the list', () => {
const next = transitionCompactNavigation(createCompactNavigationState('workflow'), {
type: 'selectNote',
noteId: 'release',
})
expect(next).toEqual({
panel: 'note',
selectedNoteId: 'release',
})
})
it('returns from sidebar and note surfaces to the list', () => {
expect(panelAfter({ type: 'openSidebar' })).toBe('sidebar')
expect(panelAfter({ type: 'closeSidebar' }, 'sidebar')).toBe('list')
expect(panelAfter({ type: 'backToList' }, 'note')).toBe('list')
})
it('opens and closes note properties without changing the selected note', () => {
const noteState: CompactNavigationState = {
panel: 'note',
selectedNoteId: 'workflow',
}
const propertiesState = transitionCompactNavigation(noteState, { type: 'openProperties' })
const closedState = transitionCompactNavigation(propertiesState, { type: 'closeProperties' })
expect(propertiesState).toEqual({
panel: 'properties',
selectedNoteId: 'workflow',
})
expect(closedState).toEqual({
panel: 'note',
selectedNoteId: 'workflow',
})
})
})
function panelAfter(
event: Parameters<typeof transitionCompactNavigation>[1],
panel: CompactNavigationState['panel'] = 'list',
) {
const state = transitionCompactNavigation({ panel, selectedNoteId: 'workflow' }, event)
return state.panel
}

View File

@@ -0,0 +1,55 @@
export type CompactPanel = 'sidebar' | 'list' | 'note' | 'properties'
export type CompactNavigationState = {
panel: CompactPanel
selectedNoteId: string
}
export type CompactNavigationEvent =
| { type: 'backToList' }
| { type: 'closeProperties' }
| { type: 'closeSidebar' }
| { type: 'openProperties' }
| { type: 'openSidebar' }
| { type: 'selectNote'; noteId: string }
export function createCompactNavigationState(initialNoteId: string): CompactNavigationState {
return {
panel: 'list',
selectedNoteId: initialNoteId,
}
}
export function transitionCompactNavigation(
state: CompactNavigationState,
event: CompactNavigationEvent,
): CompactNavigationState {
if (event.type === 'selectNote') {
return { panel: 'note', selectedNoteId: event.noteId }
}
return {
...state,
panel: nextPanel(state.panel, event),
}
}
function nextPanel(currentPanel: CompactPanel, event: Exclude<CompactNavigationEvent, { type: 'selectNote' }>) {
if (event.type === 'openSidebar') {
return 'sidebar'
}
if (event.type === 'closeSidebar' || event.type === 'backToList') {
return 'list'
}
if (event.type === 'openProperties') {
return 'properties'
}
if (event.type === 'closeProperties') {
return 'note'
}
return currentPanel
}

View File

@@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Branch: `codex/mobile`
- Active phase: Phase 2 - Mobile Shell
- Active slice: Scaffold iPad-first Expo shell with fixture data
- Active slice: Prepare compact navigation for gesture-driven phone shell
- Push policy: commit locally; do not push unless explicitly requested
- Validation target: iPad/iOS simulator first
@@ -34,15 +34,15 @@ This file is the resumable working log for Tolaria mobile. The strategy and road
- Added the first iPad-first/phone-ready shell using fixture notes, shared Markdown title/snippet helpers, Phosphor React Native icons, and responsive panels for sidebar, note list, editor, and properties.
- Split mobile styles into small panel-specific StyleSheet modules so new mobile code starts at CodeScene `10.0`.
- Pinned Expo runtime dependencies to the versions expected by Expo 55 after the initial generated versions failed iOS bundle export (`react-native@0.83.6`, `react@19.2.0`, matching safe-area/svg versions).
- Extracted compact phone navigation into a pure tested state machine so future swipe gestures can dispatch explicit events instead of mutating panels ad hoc.
## Next Action
Continue Phase 2 with the next mobile shell slice:
1. Resolve the local CoreSimulator hang so `pnpm mobile:ios` can launch the app in an iPad simulator.
2. Add a navigation-state test around the compact panel flow before introducing gestures.
3. Add native gesture support for phone/tablet panel transitions.
4. Start the storage/auth abstraction skeleton only after the shell has a reliable simulator loop.
2. Add native gesture support for phone/tablet panel transitions once the simulator path is usable.
3. Start the storage/auth abstraction skeleton after the shell has a reliable simulator loop.
## Verification Log
@@ -81,6 +81,10 @@ Continue Phase 2 with the next mobile shell slice:
- CodeScene pre-commit safeguard passed for the current change set.
- Codacy MCP can read repository analysis and security items for `refactoringhq/tolaria`; local Codacy CLI analysis is currently blocked because the Codacy CLI binary is not installed in this environment.
- iOS simulator validation is blocked locally: Xcode is installed (`Xcode 26.3`), but `xcrun simctl list ...` hangs indefinitely even after opening `/Applications/Xcode.app/Contents/Developer/Applications/Simulator.app`.
- `pnpm --filter @tolaria/mobile test` passed after compact navigation extraction: 2 files / 6 tests.
- `pnpm --filter @tolaria/mobile typecheck` passed after compact navigation extraction.
- CodeScene after compact navigation extraction: `apps/mobile/src/compactNavigation.ts`, `apps/mobile/src/compactNavigation.test.ts`, and the touched `apps/mobile/src/MobileApp.tsx` scored `10`.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after compact navigation extraction.
## Risks / Watch Items