first commmit
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
ControlId,
|
||||
DEFAULT_LAYOUT,
|
||||
MultiTouchRouter,
|
||||
applySafeArea,
|
||||
classifyDirection,
|
||||
hitTest,
|
||||
isInsideRect,
|
||||
joystickDirection,
|
||||
ZERO_DIRECTION,
|
||||
} from '@ui/InputModel';
|
||||
|
||||
describe('InputModel — layout geometry', () => {
|
||||
it('default landscape layout places joystick on left, attacks on right', () => {
|
||||
expect(DEFAULT_LAYOUT.joystick.cx).toBeLessThan(480);
|
||||
expect(DEFAULT_LAYOUT.shuriken.cx).toBeGreaterThan(480);
|
||||
expect(DEFAULT_LAYOUT.ninjaSword.cx).toBeGreaterThan(DEFAULT_LAYOUT.shuriken.cx);
|
||||
});
|
||||
|
||||
it('isInsideRect is correct for corners and near-misses', () => {
|
||||
const r = { cx: 100, cy: 100, w: 40, h: 40 };
|
||||
expect(isInsideRect(r, 100, 100)).toBe(true);
|
||||
expect(isInsideRect(r, 120, 120)).toBe(true);
|
||||
expect(isInsideRect(r, 121, 100)).toBe(false);
|
||||
expect(isInsideRect(r, 100, 79)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputModel — hitTest priority', () => {
|
||||
it('routes a finger pressing both an attack and the joystick to the attack', () => {
|
||||
// Arrange: stretch the joystick rect so it overlaps the shuriken button.
|
||||
const layout = {
|
||||
...DEFAULT_LAYOUT,
|
||||
joystick: { cx: 120, cy: 100, w: 900, h: 200 },
|
||||
};
|
||||
const id = hitTest(layout, layout.shuriken.cx, layout.shuriken.cy);
|
||||
expect(id).toBe(ControlId.Shuriken);
|
||||
});
|
||||
|
||||
it('returns null when the touch misses every control', () => {
|
||||
expect(hitTest(DEFAULT_LAYOUT, 480, 400)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputModel — joystick dead zone (req 1.5)', () => {
|
||||
it('returns ZERO_DIRECTION inside the 10px dead-zone', () => {
|
||||
const dir = joystickDirection(DEFAULT_LAYOUT, DEFAULT_LAYOUT.joystick.cx + 4, DEFAULT_LAYOUT.joystick.cy - 3);
|
||||
expect(dir).toBe(ZERO_DIRECTION);
|
||||
});
|
||||
|
||||
it('returns a normalised vector with magnitude > deadzone outside it', () => {
|
||||
const dir = joystickDirection(
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_LAYOUT.joystick.cx + 60,
|
||||
DEFAULT_LAYOUT.joystick.cy + 0
|
||||
);
|
||||
expect(dir.magnitude).toBeCloseTo(60);
|
||||
expect(dir.x).toBeCloseTo(1);
|
||||
expect(dir.y).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputModel — parabolic angle classification (req 2.5, 20.3)', () => {
|
||||
it.each([
|
||||
[45, 'parabolic_right'],
|
||||
[50, 'parabolic_right'],
|
||||
[40, 'parabolic_right'],
|
||||
[60, 'parabolic_right'],
|
||||
[135, 'parabolic_left'],
|
||||
[140, 'parabolic_left'],
|
||||
[120, 'parabolic_left'],
|
||||
[0, 'horizontal'],
|
||||
[180, 'horizontal'],
|
||||
[90, 'other'],
|
||||
] as Array<[number, string]>)('degree %p → %p', (deg, klass) => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const dir = { x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 };
|
||||
expect(classifyDirection(dir)).toBe(klass);
|
||||
});
|
||||
|
||||
it('hits ≥95% recognition rate when sampling evenly around 45° ± 15°', () => {
|
||||
let hits = 0;
|
||||
const samples = 200;
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const deg = 30 + (i / samples) * 30; // 30°..60°
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const k = classifyDirection({ x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 });
|
||||
if (k === 'parabolic_right') hits++;
|
||||
}
|
||||
expect(hits / samples).toBeGreaterThanOrEqual(0.95);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InputModel — applySafeArea (req 1.7, 18.6)', () => {
|
||||
it('slides left-group rightwards and right-group leftwards by the insets', () => {
|
||||
const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 40, right: 60, top: 0, bottom: 0 });
|
||||
expect(shifted.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 40);
|
||||
expect(shifted.jump.cx).toBe(DEFAULT_LAYOUT.jump.cx + 40);
|
||||
expect(shifted.shuriken.cx).toBe(DEFAULT_LAYOUT.shuriken.cx - 60);
|
||||
expect(shifted.ninjaSword.cx).toBe(DEFAULT_LAYOUT.ninjaSword.cx - 60);
|
||||
});
|
||||
|
||||
it('keeps every control inside the visible area after shifting', () => {
|
||||
const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 20, right: 20, top: 20, bottom: 20 });
|
||||
const all = [shifted.joystick, shifted.jump, shifted.shuriken, shifted.ninjaSword];
|
||||
for (const r of all) {
|
||||
expect(r.cx + r.w / 2).toBeLessThanOrEqual(960);
|
||||
expect(r.cx - r.w / 2).toBeGreaterThanOrEqual(0);
|
||||
expect(r.cy + r.h / 2).toBeLessThanOrEqual(540);
|
||||
expect(r.cy - r.h / 2).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MultiTouchRouter — req 1.8 (≥3 simultaneous touches)', () => {
|
||||
it('routes three fingers to joystick + jump + shuriken independently', () => {
|
||||
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
const j = router.begin(0, DEFAULT_LAYOUT.joystick.cx, DEFAULT_LAYOUT.joystick.cy, 0);
|
||||
const p = router.begin(1, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 10);
|
||||
const s = router.begin(2, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 20);
|
||||
expect(j).toBe(ControlId.Joystick);
|
||||
expect(p).toBe(ControlId.Jump);
|
||||
expect(s).toBe(ControlId.Shuriken);
|
||||
expect(router.activeTouchCount).toBe(3);
|
||||
expect(router.isPressed(ControlId.Jump)).toBe(true);
|
||||
});
|
||||
|
||||
it('end() returns the previously-bound control and removes the slot', () => {
|
||||
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
router.begin(5, DEFAULT_LAYOUT.ninjaSword.cx, DEFAULT_LAYOUT.ninjaSword.cy, 0);
|
||||
expect(router.end(5)).toBe(ControlId.NinjaSword);
|
||||
expect(router.isPressed(ControlId.NinjaSword)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls through (returns null) when the touch lands outside all controls (req 1.3)', () => {
|
||||
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
expect(router.begin(7, 480, 400, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('earliestPressTs returns the oldest timestamp among the given controls', () => {
|
||||
const router = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
router.begin(0, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 100);
|
||||
router.begin(1, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 80);
|
||||
expect(router.earliestPressTs([ControlId.Jump, ControlId.Shuriken])).toBe(80);
|
||||
expect(router.earliestPressTs([ControlId.NinjaSword])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
DEFAULT_LAYOUT_DELTA,
|
||||
LAYOUT_DELTA_BOUNDS,
|
||||
LayoutCustomizer,
|
||||
applyLayoutDelta,
|
||||
sanitiseLayoutDelta,
|
||||
} from '@ui/LayoutCustomizer';
|
||||
import { DEFAULT_LAYOUT } from '@ui/InputModel';
|
||||
import { StorageMgr } from '@common/StorageMgr';
|
||||
import { STORAGE_KEY } from '@common/Constants';
|
||||
|
||||
function mem() {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
m.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
m.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('sanitiseLayoutDelta', () => {
|
||||
it('returns a defensive copy of the default when given null', () => {
|
||||
const d = sanitiseLayoutDelta(null);
|
||||
expect(d).toEqual(DEFAULT_LAYOUT_DELTA);
|
||||
d.opacity = 0.1;
|
||||
expect(DEFAULT_LAYOUT_DELTA.opacity).toBe(0.7); // ensure we did not mutate
|
||||
});
|
||||
|
||||
it('clamps offset beyond the allowed range', () => {
|
||||
const d = sanitiseLayoutDelta({ joystickOffset: { dx: 9999, dy: -9999 } });
|
||||
expect(d.joystickOffset.dx).toBe(LAYOUT_DELTA_BOUNDS.offsetPxMax);
|
||||
expect(d.joystickOffset.dy).toBe(-LAYOUT_DELTA_BOUNDS.offsetPxMax);
|
||||
});
|
||||
|
||||
it('clamps size scale and opacity to allowed ranges', () => {
|
||||
const d = sanitiseLayoutDelta({ buttonSizeScale: 5, opacity: 2 });
|
||||
expect(d.buttonSizeScale).toBe(LAYOUT_DELTA_BOUNDS.sizeScaleMax);
|
||||
expect(d.opacity).toBe(LAYOUT_DELTA_BOUNDS.opacityMax);
|
||||
});
|
||||
|
||||
it('replaces NaN-like inputs with safe midpoints', () => {
|
||||
const d = sanitiseLayoutDelta({ buttonSizeScale: NaN });
|
||||
expect(d.buttonSizeScale).toBeGreaterThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMin);
|
||||
expect(d.buttonSizeScale).toBeLessThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMax);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyLayoutDelta', () => {
|
||||
it('produces a layout identical to baseline when delta is default', () => {
|
||||
const result = applyLayoutDelta(DEFAULT_LAYOUT, DEFAULT_LAYOUT_DELTA);
|
||||
expect(result.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx);
|
||||
expect(result.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity);
|
||||
});
|
||||
|
||||
it('shifts centres and scales widths', () => {
|
||||
const delta = sanitiseLayoutDelta({
|
||||
joystickOffset: { dx: 30, dy: 10 },
|
||||
buttonSizeScale: 1.2,
|
||||
opacity: 0.9,
|
||||
});
|
||||
const r = applyLayoutDelta(DEFAULT_LAYOUT, delta);
|
||||
expect(r.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 30);
|
||||
expect(r.joystick.cy).toBe(DEFAULT_LAYOUT.joystick.cy + 10);
|
||||
expect(r.shuriken.w).toBeCloseTo(DEFAULT_LAYOUT.shuriken.w * 1.2);
|
||||
expect(r.opacity).toBe(0.9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LayoutCustomizer — persistence', () => {
|
||||
it('returns the default layout when nothing is stored (req 17.6)', () => {
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, new StorageMgr(mem()));
|
||||
const { delta, layout } = cust.loadLayout();
|
||||
expect(delta).toEqual(DEFAULT_LAYOUT_DELTA);
|
||||
expect(layout.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx);
|
||||
});
|
||||
|
||||
it('round-trips a custom delta through saveDelta() / loadLayout()', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
|
||||
cust.saveDelta({
|
||||
joystickOffset: { dx: 20, dy: -15 },
|
||||
jumpOffset: { dx: 0, dy: 0 },
|
||||
shurikenOffset: { dx: -10, dy: 5 },
|
||||
ninjaSwordOffset: { dx: 0, dy: 0 },
|
||||
buttonSizeScale: 1.1,
|
||||
opacity: 0.85,
|
||||
});
|
||||
const { delta } = cust.loadLayout();
|
||||
expect(delta.joystickOffset.dx).toBe(20);
|
||||
expect(delta.shurikenOffset.dx).toBe(-10);
|
||||
expect(delta.opacity).toBe(0.85);
|
||||
});
|
||||
|
||||
it('reset() clears the stored layout', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
|
||||
cust.saveDelta({ ...DEFAULT_LAYOUT_DELTA, opacity: 1.0 });
|
||||
cust.reset();
|
||||
const { delta } = cust.loadLayout();
|
||||
expect(delta.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity);
|
||||
});
|
||||
|
||||
it('falls back to defaults when storage returns corrupted JSON (req 17.6)', () => {
|
||||
const driver = {
|
||||
getItem: () => 'not valid json',
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const storage = new StorageMgr(driver);
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
|
||||
const { delta } = cust.loadLayout();
|
||||
expect(delta).toEqual(DEFAULT_LAYOUT_DELTA);
|
||||
});
|
||||
|
||||
it('uses the kl_control_layout storage key (req 17.2)', () => {
|
||||
const driver = mem();
|
||||
const setSpy = jest.fn(driver.setItem);
|
||||
driver.setItem = setSpy;
|
||||
const storage = new StorageMgr(driver);
|
||||
const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage);
|
||||
cust.saveDelta(DEFAULT_LAYOUT_DELTA);
|
||||
expect(setSpy).toHaveBeenCalled();
|
||||
expect(setSpy.mock.calls[0][0]).toBe(STORAGE_KEY.ControlLayout);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { StorySceneCtrl, BASE_TYPING_CPS } from '@ui/StorySceneCtrl';
|
||||
import { StorageMgr } from '@common/StorageMgr';
|
||||
import { IStorySceneConfig } from '@data/Interfaces';
|
||||
import { STORAGE_KEY } from '@common/Constants';
|
||||
|
||||
const SCENE: IStorySceneConfig = {
|
||||
id: 'chapter_1_intro',
|
||||
bgm: 'bgm_story',
|
||||
maxDurationSec: 30,
|
||||
pages: [
|
||||
{ index: 1, illustration: 'p1', text: 'A' },
|
||||
{ index: 2, illustration: 'p2', text: 'BCDE' },
|
||||
{ index: 3, illustration: 'p3', text: 'FGH' },
|
||||
],
|
||||
};
|
||||
|
||||
function mem() {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
m.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
m.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('StorySceneCtrl — first-time gate (req 19.5)', () => {
|
||||
it('start() reports "already_seen" when storage flag is set', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
storage.set(STORAGE_KEY.StoryIntroSeen, true);
|
||||
const ctrl = new StorySceneCtrl(SCENE, storage);
|
||||
expect(ctrl.start()).toBe('already_seen');
|
||||
});
|
||||
|
||||
it('start() reports "playing" on first run', () => {
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
|
||||
expect(ctrl.start()).toBe('playing');
|
||||
expect(ctrl.status).toBe('typing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySceneCtrl — typewriter (req 19.2-19.3)', () => {
|
||||
it('reveals characters at BASE_TYPING_CPS', () => {
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
|
||||
ctrl.start();
|
||||
// Advance enough time to type "A".
|
||||
ctrl.tick(1 / BASE_TYPING_CPS + 0.001);
|
||||
expect(ctrl.visibleText).toBe('A');
|
||||
// Page "A" complete → waiting_next.
|
||||
expect(ctrl.status).toBe('waiting_next');
|
||||
});
|
||||
|
||||
it('tap during typing fully reveals the current page', () => {
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
|
||||
ctrl.start();
|
||||
ctrl.onTap(); // accelerate page 1 ("A")
|
||||
expect(ctrl.visibleText).toBe('A');
|
||||
expect(ctrl.status).toBe('waiting_next');
|
||||
// Next tap → advance to page 2.
|
||||
ctrl.onTap();
|
||||
expect(ctrl.currentPageNumber).toBe(2);
|
||||
expect(ctrl.status).toBe('typing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySceneCtrl — skip (req 19.4)', () => {
|
||||
it('onSkip immediately finishes and marks seen', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
const onFinished = jest.fn();
|
||||
const ctrl = new StorySceneCtrl(SCENE, storage, { onFinished });
|
||||
ctrl.start();
|
||||
ctrl.onSkip();
|
||||
expect(ctrl.status).toBe('finished');
|
||||
expect(onFinished).toHaveBeenCalledWith(true);
|
||||
expect(storage.get(STORAGE_KEY.StoryIntroSeen, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('re-skipping after finish is a no-op', () => {
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()));
|
||||
ctrl.start();
|
||||
ctrl.onSkip();
|
||||
expect(() => ctrl.onSkip()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySceneCtrl — reset() (req 19.6)', () => {
|
||||
it('clears the "seen" flag', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
const ctrl = new StorySceneCtrl(SCENE, storage);
|
||||
ctrl.start();
|
||||
ctrl.onSkip();
|
||||
expect(ctrl.hasBeenSeen()).toBe(true);
|
||||
ctrl.reset();
|
||||
expect(ctrl.hasBeenSeen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorySceneCtrl — natural finish (all pages)', () => {
|
||||
it('calls onFinished(false) after advancing past the last page', () => {
|
||||
const onFinished = jest.fn();
|
||||
const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()), { onFinished });
|
||||
ctrl.start();
|
||||
// Full page 1.
|
||||
ctrl.onTap();
|
||||
ctrl.onTap(); // → page 2
|
||||
ctrl.onTap(); // reveal page 2
|
||||
ctrl.onTap(); // → page 3
|
||||
ctrl.onTap(); // reveal page 3
|
||||
ctrl.onTap(); // → finish
|
||||
expect(onFinished).toHaveBeenCalledWith(false);
|
||||
expect(ctrl.status).toBe('finished');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { UIFlowMgr, ISceneEnter } from '@ui/UIFlowMgr';
|
||||
import { StorageMgr } from '@common/StorageMgr';
|
||||
import { STORAGE_KEY } from '@common/Constants';
|
||||
|
||||
function mem() {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null),
|
||||
setItem: (k: string, v: string) => {
|
||||
m.set(k, v);
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
m.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('UIFlowMgr — boot path (req 19.1, 19.5)', () => {
|
||||
it('routes first-time boot into story_intro', () => {
|
||||
const events: ISceneEnter[] = [];
|
||||
const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) });
|
||||
flow.onBoot();
|
||||
expect(events[0].scene).toBe('story_intro');
|
||||
});
|
||||
|
||||
it('routes repeat boot into main_menu', () => {
|
||||
const storage = new StorageMgr(mem());
|
||||
storage.set(STORAGE_KEY.StoryIntroSeen, true);
|
||||
const events: ISceneEnter[] = [];
|
||||
const flow = new UIFlowMgr(storage, { onSceneEnter: (e) => events.push(e) });
|
||||
flow.onBoot();
|
||||
expect(events[0].scene).toBe('main_menu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UIFlowMgr — level/settlement/dead transitions', () => {
|
||||
it('onPickLevel → gameplay with payload', () => {
|
||||
const events: ISceneEnter[] = [];
|
||||
const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) });
|
||||
flow.onPickLevel('1-3');
|
||||
expect(events[0]).toEqual({ scene: 'gameplay', payload: { levelId: '1-3' } });
|
||||
});
|
||||
|
||||
it('onPlayerDied pushes a settlement with dead:true', () => {
|
||||
const events: ISceneEnter[] = [];
|
||||
const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) });
|
||||
flow.onPlayerDied('1-2');
|
||||
expect(events[0].scene).toBe('settlement');
|
||||
expect(events[0].payload).toEqual({ levelId: '1-2', dead: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('UIFlowMgr — difficulty guardrail (req 13.1)', () => {
|
||||
it('availableSettingsEntries does NOT include difficulty', () => {
|
||||
const flow = new UIFlowMgr(new StorageMgr(mem()));
|
||||
const entries = flow.availableSettingsEntries();
|
||||
expect(entries).not.toContain('difficulty');
|
||||
expect(entries).toContain('audio_volume');
|
||||
expect(entries).toContain('replay_story_intro');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user