feat(app): add web electron toolkit support

This commit is contained in:
2026-06-10 15:01:09 -05:00
parent e4a4aeb100
commit fa75ce6026
52 changed files with 20438 additions and 76 deletions
+44
View File
@@ -0,0 +1,44 @@
jest.mock('@react-native-async-storage/async-storage', () => {
const storage = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn((key: string) => Promise.resolve(storage.get(key) ?? null)),
removeItem: jest.fn((key: string) => {
storage.delete(key);
return Promise.resolve();
}),
setItem: jest.fn((key: string, value: string) => {
storage.set(key, value);
return Promise.resolve();
}),
},
};
});
import { createPersistOptions } from '../src/state/createPersistOptions';
type TestState = {
hydrated: boolean;
transientValue: string;
};
describe('createPersistOptions', () => {
it('creates named options with JSON storage and preserves extra options', () => {
const options = createPersistOptions<TestState, Pick<TestState, 'hydrated'>>({
name: 'test-state',
partialize: state => ({ hydrated: state.hydrated }),
version: 2,
});
expect(options.name).toBe('test-state');
expect(options.version).toBe(2);
expect(options.storage).toBeDefined();
expect(options.partialize?.({ hydrated: true, transientValue: 'skip' })).toEqual(
{
hydrated: true,
},
);
});
});
+50
View File
@@ -0,0 +1,50 @@
import {
clampVolume,
defaultAutosaveEnabled,
defaultGameResolutionId,
defaultVolumeSettings,
formatResolutionLabel,
gameResolutionOptions,
} from '../src/settings/gameSettings';
describe('game settings definitions', () => {
it('uses landscape phone dimensions for the default enforced resolution', () => {
expect(defaultGameResolutionId).toBe('iphoneDefault');
expect(formatResolutionLabel(defaultGameResolutionId)).toBe(
'Phone Default (844 x 390)',
);
const boundedOptions = gameResolutionOptions.filter(
option => !('unbounded' in option),
);
expect(boundedOptions.map(option => option.width)).toEqual([
812,
844,
915,
932,
]);
expect(boundedOptions.every(option => option.width > option.height)).toBe(true);
});
it('defines a browser resolution that fills available space', () => {
expect(formatResolutionLabel('browser')).toBe(
'Browser (Fill available space)',
);
});
it('clamps volumes between 0 and 100', () => {
expect(clampVolume(-5)).toBe(0);
expect(clampVolume(55)).toBe(55);
expect(clampVolume(105)).toBe(100);
});
it('defines autosave and every default volume setting', () => {
expect(defaultAutosaveEnabled).toBe(true);
expect(defaultVolumeSettings).toEqual({
ambience: 70,
master: 80,
music: 70,
speech: 80,
});
});
});
+60
View File
@@ -0,0 +1,60 @@
jest.mock('@react-native-async-storage/async-storage', () => {
const storage = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn((key: string) => Promise.resolve(storage.get(key) ?? null)),
removeItem: jest.fn((key: string) => {
storage.delete(key);
return Promise.resolve();
}),
setItem: jest.fn((key: string, value: string) => {
storage.set(key, value);
return Promise.resolve();
}),
},
};
});
import { act } from 'react-test-renderer';
import { defaultVolumeSettings } from '../src/settings/gameSettings';
import { useGameSettingsStore } from '../src/state';
describe('useGameSettingsStore', () => {
beforeEach(() => {
useGameSettingsStore.setState({
autosaveEnabled: true,
resolutionId: 'iphoneDefault',
volumes: defaultVolumeSettings,
});
});
it('captures all settings in persisted state', () => {
const partialize = useGameSettingsStore.persist.getOptions().partialize;
expect(partialize?.(useGameSettingsStore.getState())).toEqual({
autosaveEnabled: true,
resolutionId: 'iphoneDefault',
volumes: {
ambience: 70,
master: 80,
music: 70,
speech: 80,
},
});
});
it('updates autosave, resolution, and volume settings', () => {
act(() => {
useGameSettingsStore.getState().toggleAutosave();
useGameSettingsStore.getState().setResolution('phoneLarge');
useGameSettingsStore.getState().setVolume('music', 105);
});
expect(useGameSettingsStore.getState().autosaveEnabled).toBe(false);
expect(useGameSettingsStore.getState().resolutionId).toBe('phoneLarge');
expect(useGameSettingsStore.getState().volumes.music).toBe(100);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { createMemoryStateStorage } from '../src/state/memoryStateStorage';
describe('createMemoryStateStorage', () => {
it('stores, reads, and removes string values', () => {
const storage = createMemoryStateStorage();
expect(storage.getItem('missing')).toBeNull();
storage.setItem('bone-king', 'awake');
expect(storage.getItem('bone-king')).toBe('awake');
storage.removeItem('bone-king');
expect(storage.getItem('bone-king')).toBeNull();
});
});
+60
View File
@@ -0,0 +1,60 @@
import {
policyRoutes,
primaryMenuRoutes,
requiredPageRoutes,
toolkitRoutes,
routeDefinitions,
} from '../src/navigation/routes';
describe('routeDefinitions', () => {
it('defines every required page with labels and titles', () => {
expect(requiredPageRoutes).toEqual([
'home',
'newGame',
'saves',
'settings',
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
'policiesCookies',
'credits',
'toolkit',
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitResources',
]);
for (const routeName of requiredPageRoutes) {
expect(routeDefinitions[routeName].label.length).toBeGreaterThan(0);
expect(routeDefinitions[routeName].title.length).toBeGreaterThan(0);
}
});
it('exposes home menu and policy submenu routes', () => {
expect(primaryMenuRoutes).toEqual([
'newGame',
'saves',
'settings',
'policies',
'credits',
'toolkit',
]);
expect(policyRoutes).toEqual([
'policiesLegal',
'policiesPrivacy',
'policiesTerms',
'policiesCookies',
]);
expect(toolkitRoutes).toEqual([
'toolkitOpeningSequence',
'toolkitCredits',
'toolkitAudio',
'toolkitLore',
'toolkitResources',
]);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { act } from 'react-test-renderer';
import { useNavigationStore } from '../src/state';
jest.mock('@react-native-async-storage/async-storage', () => {
const storage = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn((key: string) => Promise.resolve(storage.get(key) ?? null)),
removeItem: jest.fn((key: string) => {
storage.delete(key);
return Promise.resolve();
}),
setItem: jest.fn((key: string, value: string) => {
storage.set(key, value);
return Promise.resolve();
}),
},
};
});
describe('useNavigationStore', () => {
beforeEach(() => {
useNavigationStore.setState({
currentRouteName: 'home',
pendingRouteName: null,
});
});
it('captures the current and pending routes in persisted state', () => {
act(() => {
useNavigationStore.getState().setPendingRoute('settings');
});
const partialize = useNavigationStore.persist.getOptions().partialize;
expect(partialize?.(useNavigationStore.getState())).toEqual({
currentRouteName: 'home',
pendingRouteName: 'settings',
});
});
it('completes navigation by promoting the pending route to current route', () => {
act(() => {
useNavigationStore.getState().setPendingRoute('settings');
useNavigationStore.getState().completeNavigation('settings');
});
expect(useNavigationStore.getState().currentRouteName).toBe('settings');
expect(useNavigationStore.getState().pendingRouteName).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { StateStorage } from 'zustand/middleware';
import { createWebStateStorage } from '../src/state/persistStorage.web';
function createThrowingStorage(): StateStorage<void> {
return {
getItem: () => null,
removeItem: () => {
throw new Error('Storage is unavailable.');
},
setItem: () => {
throw new Error('Storage is unavailable.');
},
};
}
describe('createWebStateStorage', () => {
it('uses provided storage when it is available', () => {
const backingStore = new Map<string, string>();
const providedStorage: StateStorage<void> = {
getItem: (name: string) => backingStore.get(name) ?? null,
removeItem: (name: string) => {
backingStore.delete(name);
},
setItem: (name: string, value: string) => {
backingStore.set(name, value);
},
};
const storage = createWebStateStorage(() => providedStorage);
storage.setItem('throne', 'claimed');
expect(providedStorage.getItem('throne')).toBe('claimed');
expect(storage.getItem('throne')).toBe('claimed');
});
it('falls back to memory storage when local storage is unavailable', () => {
const storage = createWebStateStorage(createThrowingStorage);
storage.setItem('throne', 'hidden');
expect(storage.getItem('throne')).toBe('hidden');
storage.removeItem('throne');
expect(storage.getItem('throne')).toBeNull();
});
});
+426
View File
@@ -0,0 +1,426 @@
import type { ReactElement } from 'react';
import { ScrollView, Text } from 'react-native';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { AppNavigator } from '../src/navigation/AppNavigator';
import {
policyRoutes,
primaryMenuRoutes,
routeDefinitions,
toolkitRoutes,
} from '../src/navigation/routes';
import { defaultVolumeSettings } from '../src/settings/gameSettings';
import { HomeScreen } from '../src/screens/HomeScreen';
import { PageScreen } from '../src/screens/PageScreen';
import { PoliciesScreen } from '../src/screens/PoliciesScreen';
import { SettingsScreen } from '../src/screens/SettingsScreen';
import { ToolkitScreen } from '../src/screens/ToolkitScreen';
import {
useGameSettingsStore,
useNavigationStore,
useToolkitSocketStore,
} from '../src/state';
const mountedRenderers: ReactTestRenderer[] = [];
jest.mock('@react-native-async-storage/async-storage', () => {
const storage = new Map<string, string>();
return {
__esModule: true,
default: {
getItem: jest.fn((key: string) => Promise.resolve(storage.get(key) ?? null)),
removeItem: jest.fn((key: string) => {
storage.delete(key);
return Promise.resolve();
}),
setItem: jest.fn((key: string, value: string) => {
storage.set(key, value);
return Promise.resolve();
}),
},
};
});
jest.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: ({ children }: { children: ReactElement }) => children,
useSafeAreaInsets: () => ({
bottom: 0,
left: 0,
right: 0,
top: 0,
}),
}));
function render(element: ReactElement) {
let renderer: ReactTestRenderer | undefined;
act(() => {
renderer = create(element);
});
if (renderer == null) {
throw new Error('Renderer was not created.');
}
mountedRenderers.push(renderer);
return renderer;
}
function getRenderedText(renderer: ReactTestRenderer) {
return renderer.root
.findAllByType(Text)
.map(instance => instance.props.children)
.join(' ');
}
function pressByTestID(renderer: ReactTestRenderer, testID: string) {
const element = renderer.root.findByProps({ testID });
const props = element.props as { onPress: () => void };
act(() => {
props.onPress();
});
}
function unmount(renderer: ReactTestRenderer) {
act(() => {
renderer.unmount();
});
const rendererIndex = mountedRenderers.indexOf(renderer);
if (rendererIndex >= 0) {
mountedRenderers.splice(rendererIndex, 1);
}
}
describe('screen scaffold', () => {
beforeEach(() => {
act(() => {
useGameSettingsStore.setState({
autosaveEnabled: true,
resolutionId: 'iphoneDefault',
volumes: defaultVolumeSettings,
});
useNavigationStore.setState({
currentRouteName: 'home',
pendingRouteName: null,
});
useToolkitSocketStore.setState({
lastConnectedAt: null,
socketId: null,
status: 'disconnected',
});
});
});
afterEach(() => {
act(() => {
for (const renderer of mountedRenderers) {
renderer.unmount();
}
mountedRenderers.length = 0;
});
});
it('renders the home screen game name and primary menu entries', () => {
const renderer = render(<HomeScreen navigate={jest.fn()} />);
const text = getRenderedText(renderer);
expect(text).toContain('Throne of the Bone King');
expect(text).toContain('New Game');
expect(text).toContain('Saves');
expect(text).toContain('Settings');
expect(text).toContain('Policies');
expect(text).toContain('Credits');
expect(text).not.toContain('Toolkit');
expect(renderer.root.findByProps({ testID: 'menu-newGame-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-saves-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-settings-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-policies-icon' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'menu-credits-icon' })).toBeTruthy();
expect(renderer.root.findAllByProps({ testID: 'menu-toolkit' })).toHaveLength(0);
});
it('shows the toolkit home entry as a distinct button only in browser sizing', () => {
act(() => {
useGameSettingsStore.setState({
autosaveEnabled: true,
resolutionId: 'browser',
volumes: defaultVolumeSettings,
});
});
const renderer = render(<HomeScreen navigate={jest.fn()} />);
const text = getRenderedText(renderer);
const toolkitIcon = renderer.root.findByProps({ testID: 'menu-toolkit-icon' });
const toolkitIconStyle = toolkitIcon.props.style as Array<
Record<string, string | number> | null
>;
expect(text).toContain('Toolkit');
expect(toolkitIcon).toBeTruthy();
expect(toolkitIconStyle).toEqual(
expect.arrayContaining([
expect.objectContaining({
color: '#f5d98d',
}),
]),
);
expect(renderer.root.findByProps({ testID: 'menu-toolkit' }).props).toMatchObject(
{
disabled: true,
},
);
});
it('enables the toolkit home entry when the toolkit socket is connected', () => {
act(() => {
useGameSettingsStore.setState({
autosaveEnabled: true,
resolutionId: 'browser',
volumes: defaultVolumeSettings,
});
useToolkitSocketStore.getState().setConnected('test-socket');
});
const renderer = render(<HomeScreen navigate={jest.fn()} />);
expect(renderer.root.findByProps({ testID: 'menu-toolkit' }).props).toMatchObject(
{
disabled: false,
},
);
});
it('renders a return-to-home control on non-home pages', () => {
const renderer = render(
<PageScreen goHome={jest.fn()} routeName="newGame" />,
);
expect(renderer.root.findByProps({ testID: 'back-home-button' })).toBeTruthy();
});
it('renders all policy page links from the policies index', () => {
const renderer = render(
<PoliciesScreen goHome={jest.fn()} navigate={jest.fn()} />,
);
const text = getRenderedText(renderer);
expect(text).toContain('Legal');
expect(text).toContain('Privacy');
expect(text).toContain('Terms and Conditions');
expect(text).toContain('Cookies');
});
it('renders settings controls for resolution and audio channels', () => {
const renderer = render(<SettingsScreen goHome={jest.fn()} />);
const text = getRenderedText(renderer);
expect(text).toContain('Game Resolution');
expect(text).toContain('Phone Default');
expect(text).toContain('Master Volume');
expect(text).toContain('Music Volume');
expect(text).toContain('Ambience Volume');
expect(text).toContain('Speech Volume');
expect(text).toContain('Autosave');
});
it('renders the toolkit template and dense toolkit menu entries', () => {
const renderer = render(
<ToolkitScreen
goHome={jest.fn()}
navigate={jest.fn()}
routeName="toolkit"
/>,
);
const text = getRenderedText(renderer);
expect(text).toContain('Toolkit');
expect(text).toContain('Opening Sequence');
expect(text).toContain('Credits');
expect(text).toContain('Audio');
expect(text).toContain('Lore');
expect(text).toContain('Resources');
const homeButton = renderer.root.findByProps({ testID: 'toolkit-home' });
expect(homeButton.props.accessibilityLabel).toBe('Home');
expect(renderer.root.findByProps({ testID: 'toolkit-home-icon' })).toBeTruthy();
expect(renderer.root.findAllByProps({ testID: 'toolkit-home-label' })).toHaveLength(
0,
);
expect(renderer.root.findByProps({ testID: 'toolkit-socket-indicator' })).toBeTruthy();
});
it('renders the toolkit websocket connection indicator', () => {
act(() => {
useToolkitSocketStore.getState().setConnected('test-socket');
});
const renderer = render(
<ToolkitScreen
goHome={jest.fn()}
navigate={jest.fn()}
routeName="toolkit"
/>,
);
const text = getRenderedText(renderer);
const socketDot = renderer.root.findByProps({ testID: 'toolkit-socket-dot' });
expect(text).toContain('Connected');
expect(socketDot.props.style).toEqual(
expect.arrayContaining([
expect.objectContaining({
backgroundColor: '#3fbf6f',
}),
]),
);
});
it('renders toolkit navigation as a dense top toolbar', () => {
const renderer = render(
<ToolkitScreen
goHome={jest.fn()}
navigate={jest.fn()}
routeName="toolkit"
/>,
);
const toolbar = renderer.root.findByProps({ testID: 'toolkit-toolbar' });
const optionGroup = renderer.root.findByProps({
testID: 'toolkit-option-group',
});
const homeSlot = renderer.root.findByProps({ testID: 'toolkit-home-slot' });
expect(toolbar.props.style).toMatchObject({ flexDirection: 'row' });
expect(optionGroup.props.style).toMatchObject({ flexDirection: 'row' });
expect(homeSlot.props.style).toMatchObject({ marginLeft: 'auto' });
expect(
renderer.root.findByProps({ testID: 'toolkit-toolkitOpeningSequence-icon' }),
).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'toolkit-toolkitOpeningSequence-label' }),
).toBeTruthy();
});
it('opens the resolution select and renders all options', () => {
const renderer = render(<SettingsScreen goHome={jest.fn()} />);
expect(renderer.root.findAllByProps({ testID: 'resolution-options' })).toHaveLength(
0,
);
pressByTestID(renderer, 'resolution-select');
const options = renderer.root.findByProps({ testID: 'resolution-options' });
expect(options).toBeTruthy();
expect(options.props.style).toMatchObject({ position: 'absolute' });
expect(renderer.root.findByProps({ testID: 'resolution-browser' })).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'resolution-iphoneMini' })).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'resolution-iphoneDefault' }),
).toBeTruthy();
expect(
renderer.root.findByProps({ testID: 'resolution-androidDefault' }),
).toBeTruthy();
expect(renderer.root.findByProps({ testID: 'resolution-phoneLarge' })).toBeTruthy();
});
it('keeps the active settings route when changing resolution', () => {
const renderer = render(<AppNavigator />);
pressByTestID(renderer, 'menu-settings');
expect(getRenderedText(renderer)).toContain(routeDefinitions.settings.title);
pressByTestID(renderer, 'resolution-select');
pressByTestID(renderer, 'resolution-browser');
expect(getRenderedText(renderer)).toContain(routeDefinitions.settings.title);
expect(getRenderedText(renderer)).not.toContain(routeDefinitions.home.title);
});
it('restores the active route when the navigator remounts', () => {
const renderer = render(<AppNavigator />);
pressByTestID(renderer, 'menu-settings');
expect(getRenderedText(renderer)).toContain(routeDefinitions.settings.title);
unmount(renderer);
const remountedRenderer = render(<AppNavigator />);
expect(getRenderedText(remountedRenderer)).toContain(
routeDefinitions.settings.title,
);
});
it('does not use scroll containers for fixed landscape screens', () => {
const screens = [
<HomeScreen key="home" navigate={jest.fn()} />,
<PageScreen key="page" goHome={jest.fn()} routeName="newGame" />,
<PoliciesScreen key="policies" goHome={jest.fn()} navigate={jest.fn()} />,
<SettingsScreen key="settings" goHome={jest.fn()} />,
<ToolkitScreen
key="toolkit"
goHome={jest.fn()}
navigate={jest.fn()}
routeName="toolkit"
/>,
];
for (const screen of screens) {
const renderer = render(screen);
expect(renderer.root.findAllByType(ScrollView)).toHaveLength(0);
}
});
it('navigates to each scaffold page and returns home', () => {
const renderer = render(<AppNavigator />);
for (const routeName of primaryMenuRoutes) {
if (routeName === 'toolkit') {
continue;
}
pressByTestID(renderer, `menu-${routeName}`);
expect(getRenderedText(renderer)).toContain(routeDefinitions[routeName].title);
pressByTestID(renderer, 'back-home-button');
expect(getRenderedText(renderer)).toContain(routeDefinitions.home.title);
}
pressByTestID(renderer, 'menu-policies');
for (const routeName of policyRoutes) {
pressByTestID(renderer, `policy-${routeName}`);
expect(getRenderedText(renderer)).toContain(routeDefinitions[routeName].title);
pressByTestID(renderer, 'back-home-button');
expect(getRenderedText(renderer)).toContain(routeDefinitions.home.title);
if (routeName !== policyRoutes[policyRoutes.length - 1]) {
pressByTestID(renderer, 'menu-policies');
}
}
act(() => {
useGameSettingsStore.getState().setResolution('browser');
useToolkitSocketStore.getState().setConnected('test-socket');
});
pressByTestID(renderer, 'menu-toolkit');
for (const routeName of toolkitRoutes) {
pressByTestID(renderer, `toolkit-${routeName}`);
expect(getRenderedText(renderer)).toContain(routeDefinitions[routeName].title);
}
pressByTestID(renderer, 'toolkit-home');
expect(getRenderedText(renderer)).toContain(routeDefinitions.home.title);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { act } from 'react-test-renderer';
import { useToolkitSocketStore } from '../src/state/toolkitSocketStore';
describe('useToolkitSocketStore', () => {
beforeEach(() => {
useToolkitSocketStore.setState({
lastConnectedAt: null,
socketId: null,
status: 'disconnected',
});
});
it('tracks connecting, connected, and disconnected socket state', () => {
act(() => {
useToolkitSocketStore.getState().setConnecting();
});
expect(useToolkitSocketStore.getState()).toMatchObject({
socketId: null,
status: 'connecting',
});
act(() => {
useToolkitSocketStore.getState().setConnected('socket-1');
});
expect(useToolkitSocketStore.getState()).toMatchObject({
socketId: 'socket-1',
status: 'connected',
});
expect(useToolkitSocketStore.getState().lastConnectedAt).not.toBeNull();
act(() => {
useToolkitSocketStore.getState().setDisconnected();
});
expect(useToolkitSocketStore.getState()).toMatchObject({
socketId: null,
status: 'disconnected',
});
});
});