Add TS JSDoc coverage and improve numeric step controls
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// Maintainer-controlled web client version.
|
// Maintainer-controlled web client version.
|
||||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||||
window.CHGRID_WEB_VERSION = "2026.02.22 R155";
|
window.CHGRID_WEB_VERSION = "2026.02.22 R156";
|
||||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* High-level edit session intents derived from keyboard input.
|
||||||
|
*/
|
||||||
export type EditSessionAction = 'submit' | 'cancel' | null;
|
export type EditSessionAction = 'submit' | 'cancel' | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Enter/Escape to submit/cancel semantics for text-editing flows.
|
||||||
|
*/
|
||||||
export function getEditSessionAction(code: string): EditSessionAction {
|
export function getEditSessionAction(code: string): EditSessionAction {
|
||||||
if (code === 'Enter') return 'submit';
|
if (code === 'Enter') return 'submit';
|
||||||
if (code === 'Escape') return 'cancel';
|
if (code === 'Escape') return 'cancel';
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { cycleIndex, findNextIndexByInitial } from './listNavigation';
|
import { cycleIndex, findNextIndexByInitial } from './listNavigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized control result for list-like menus.
|
||||||
|
*/
|
||||||
export type ListControlResult =
|
export type ListControlResult =
|
||||||
| { type: 'move'; index: number; reason: 'arrow' | 'initial' }
|
| { type: 'move'; index: number; reason: 'arrow' | 'initial' }
|
||||||
| { type: 'select' }
|
| { type: 'select' }
|
||||||
| { type: 'cancel' }
|
| { type: 'cancel' }
|
||||||
| { type: 'none' };
|
| { type: 'none' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies common list key handling (arrows, first-letter jump, enter, escape).
|
||||||
|
*/
|
||||||
export function handleListControlKey<T>(
|
export function handleListControlKey<T>(
|
||||||
code: string,
|
code: string,
|
||||||
key: string,
|
key: string,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Cycles an index through a fixed-length list.
|
||||||
|
*/
|
||||||
export function cycleIndex(currentIndex: number, length: number, direction: 'next' | 'prev'): number {
|
export function cycleIndex(currentIndex: number, length: number, direction: 'next' | 'prev'): number {
|
||||||
if (length <= 0) return 0;
|
if (length <= 0) return 0;
|
||||||
if (direction === 'next') {
|
if (direction === 'next') {
|
||||||
@@ -6,6 +9,9 @@ export function cycleIndex(currentIndex: number, length: number, direction: 'nex
|
|||||||
return (currentIndex - 1 + length) % length;
|
return (currentIndex - 1 + length) % length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the next entry whose label starts with the pressed initial.
|
||||||
|
*/
|
||||||
export function findNextIndexByInitial<T>(
|
export function findNextIndexByInitial<T>(
|
||||||
entries: readonly T[],
|
entries: readonly T[],
|
||||||
currentIndex: number,
|
currentIndex: number,
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Declarative command ids for the primary gameplay input mode.
|
||||||
|
*/
|
||||||
export type MainModeCommand =
|
export type MainModeCommand =
|
||||||
| 'editNickname'
|
| 'editNickname'
|
||||||
| 'toggleMute'
|
| 'toggleMute'
|
||||||
@@ -28,6 +31,9 @@ export type MainModeCommand =
|
|||||||
| 'chatLast'
|
| 'chatLast'
|
||||||
| 'escape';
|
| 'escape';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps raw key events to a semantic command for main mode handling.
|
||||||
|
*/
|
||||||
export function resolveMainModeCommand(code: string, shiftKey: boolean): MainModeCommand | null {
|
export function resolveMainModeCommand(code: string, shiftKey: boolean): MainModeCommand | null {
|
||||||
if (code === 'KeyN') return 'editNickname';
|
if (code === 'KeyN') return 'editNickname';
|
||||||
if (code === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute';
|
if (code === 'KeyM') return shiftKey ? 'toggleOutputMode' : 'toggleMute';
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Snaps a numeric value to the nearest step anchored to a minimum/base value.
|
||||||
|
*/
|
||||||
export function snapNumberToStep(value: number, step: number, anchor = 0): number {
|
export function snapNumberToStep(value: number, step: number, anchor = 0): number {
|
||||||
if (!(step > 0) || !Number.isFinite(value) || !Number.isFinite(anchor)) {
|
if (!(step > 0) || !Number.isFinite(value) || !Number.isFinite(anchor)) {
|
||||||
return value;
|
return value;
|
||||||
@@ -7,6 +10,9 @@ export function snapNumberToStep(value: number, step: number, anchor = 0): numbe
|
|||||||
return Number(normalized.toFixed(decimals));
|
return Number(normalized.toFixed(decimals));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats stepped numeric values for speech/status without trailing decimal zeros.
|
||||||
|
*/
|
||||||
export function formatSteppedNumber(value: number, step: number): string {
|
export function formatSteppedNumber(value: number, step: number): string {
|
||||||
const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(Math.abs(Math.log10(step))) + 1);
|
const decimals = step >= 1 ? 0 : Math.min(6, Math.ceil(Math.abs(Math.log10(step))) + 1);
|
||||||
if (decimals <= 0) {
|
if (decimals <= 0) {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { getEditSessionAction } from '../input/editSession';
|
|||||||
import { formatSteppedNumber, snapNumberToStep } from '../input/numeric';
|
import { formatSteppedNumber, snapNumberToStep } from '../input/numeric';
|
||||||
import { type WorldItem } from '../state/gameState';
|
import { type WorldItem } from '../state/gameState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependencies required to drive item property inspect/edit flows.
|
||||||
|
*/
|
||||||
type EditorDeps = {
|
type EditorDeps = {
|
||||||
state: {
|
state: {
|
||||||
mode: string;
|
mode: string;
|
||||||
@@ -40,6 +43,9 @@ type EditorDeps = {
|
|||||||
sfxUiCancel: () => void;
|
sfxUiCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates item property mode handlers so main input dispatch can stay lean.
|
||||||
|
*/
|
||||||
export function createItemPropertyEditor(deps: EditorDeps): {
|
export function createItemPropertyEditor(deps: EditorDeps): {
|
||||||
handleItemPropertiesModeInput: (code: string, key: string) => void;
|
handleItemPropertiesModeInput: (code: string, key: string) => void;
|
||||||
handleItemPropertyEditModeInput: (code: string, key: string, ctrlKey: boolean) => void;
|
handleItemPropertyEditModeInput: (code: string, key: string, ctrlKey: boolean) => void;
|
||||||
@@ -155,7 +161,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
deps.sfxUiCancel();
|
deps.sfxUiCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (code === 'ArrowUp' || code === 'ArrowDown') {
|
if (code === 'ArrowUp' || code === 'ArrowDown' || code === 'PageUp' || code === 'PageDown') {
|
||||||
const metadata = deps.getItemPropertyMetadata(item.type, propertyKey);
|
const metadata = deps.getItemPropertyMetadata(item.type, propertyKey);
|
||||||
if (metadata?.valueType === 'number') {
|
if (metadata?.valueType === 'number') {
|
||||||
const range = metadata.range;
|
const range = metadata.range;
|
||||||
@@ -171,7 +177,8 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
: Number.isFinite(min)
|
: Number.isFinite(min)
|
||||||
? min
|
? min
|
||||||
: 0;
|
: 0;
|
||||||
const delta = code === 'ArrowUp' ? step : -step;
|
const multiplier = code === 'PageUp' || code === 'PageDown' ? 10 : 1;
|
||||||
|
const delta = (code === 'ArrowUp' || code === 'PageUp' ? step : -step) * multiplier;
|
||||||
const anchor = Number.isFinite(min) ? min : 0;
|
const anchor = Number.isFinite(min) ? min : 0;
|
||||||
const attempted = snapNumberToStep(currentValue + delta, step, anchor);
|
const attempted = snapNumberToStep(currentValue + delta, step, anchor);
|
||||||
let nextValue = attempted;
|
let nextValue = attempted;
|
||||||
|
|||||||
@@ -1681,10 +1681,11 @@ function handleChatModeInput(code: string, key: string, ctrlKey: boolean): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean): void {
|
function handleMicGainEditModeInput(code: string, key: string, ctrlKey: boolean): void {
|
||||||
if (code === 'ArrowUp' || code === 'ArrowDown') {
|
if (code === 'ArrowUp' || code === 'ArrowDown' || code === 'PageUp' || code === 'PageDown') {
|
||||||
const raw = Number(state.nicknameInput.trim());
|
const raw = Number(state.nicknameInput.trim());
|
||||||
const base = Number.isFinite(raw) ? raw : audio.getOutboundInputGain();
|
const base = Number.isFinite(raw) ? raw : audio.getOutboundInputGain();
|
||||||
const delta = code === 'ArrowUp' ? MIC_INPUT_GAIN_STEP : -MIC_INPUT_GAIN_STEP;
|
const multiplier = code === 'PageUp' || code === 'PageDown' ? 10 : 1;
|
||||||
|
const delta = (code === 'ArrowUp' || code === 'PageUp' ? MIC_INPUT_GAIN_STEP : -MIC_INPUT_GAIN_STEP) * multiplier;
|
||||||
const attempted = snapNumberToStep(base + delta, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN);
|
const attempted = snapNumberToStep(base + delta, MIC_INPUT_GAIN_STEP, MIC_CALIBRATION_MIN_GAIN);
|
||||||
const next = clampMicInputGain(attempted);
|
const next = clampMicInputGain(attempted);
|
||||||
state.nicknameInput = formatSteppedNumber(next, MIC_INPUT_GAIN_STEP);
|
state.nicknameInput = formatSteppedNumber(next, MIC_INPUT_GAIN_STEP);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { type IncomingMessage } from './protocol';
|
import { type IncomingMessage } from './protocol';
|
||||||
import { type WorldItem } from '../state/gameState';
|
import { type WorldItem } from '../state/gameState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependency contract for creating a message handler without hard-coupling to `main.ts`.
|
||||||
|
*/
|
||||||
type MessageHandlerDeps = {
|
type MessageHandlerDeps = {
|
||||||
getWorldGridSize: () => number;
|
getWorldGridSize: () => number;
|
||||||
setWorldGridSize: (size: number) => void;
|
setWorldGridSize: (size: number) => void;
|
||||||
@@ -63,6 +66,9 @@ type MessageHandlerDeps = {
|
|||||||
playIncomingItemUseSound: (url: string, x: number, y: number) => void;
|
playIncomingItemUseSound: (url: string, x: number, y: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the websocket message dispatcher used by the signaling client.
|
||||||
|
*/
|
||||||
export function createOnMessageHandler(deps: MessageHandlerDeps): (message: IncomingMessage) => Promise<void> {
|
export function createOnMessageHandler(deps: MessageHandlerDeps): (message: IncomingMessage) => Promise<void> {
|
||||||
return async function onMessage(message: IncomingMessage): Promise<void> {
|
return async function onMessage(message: IncomingMessage): Promise<void> {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* UI elements used by binder setup.
|
||||||
|
*/
|
||||||
type UiDom = {
|
type UiDom = {
|
||||||
connectButton: HTMLButtonElement;
|
connectButton: HTMLButtonElement;
|
||||||
preconnectNickname: HTMLInputElement;
|
preconnectNickname: HTMLInputElement;
|
||||||
@@ -11,6 +14,9 @@ type UiDom = {
|
|||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dependency contract for binding DOM event handlers.
|
||||||
|
*/
|
||||||
type UiBindingsDeps = {
|
type UiBindingsDeps = {
|
||||||
dom: UiDom;
|
dom: UiDom;
|
||||||
sanitizeName: (value: string) => string;
|
sanitizeName: (value: string) => string;
|
||||||
@@ -30,6 +36,9 @@ type UiBindingsDeps = {
|
|||||||
persistOnUnload: () => void;
|
persistOnUnload: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches UI listeners (connect/settings/device changes) and focus traps.
|
||||||
|
*/
|
||||||
export function setupUiHandlers(deps: UiBindingsDeps): void {
|
export function setupUiHandlers(deps: UiBindingsDeps): void {
|
||||||
window.addEventListener('pagehide', deps.persistOnUnload);
|
window.addEventListener('pagehide', deps.persistOnUnload);
|
||||||
window.addEventListener('beforeunload', deps.persistOnUnload);
|
window.addEventListener('beforeunload', deps.persistOnUnload);
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
|||||||
"facing": {
|
"facing": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
"tooltip": "Facing direction in degrees used for directional emit.",
|
"tooltip": "Facing direction in degrees used for directional emit.",
|
||||||
"range": {"min": 0, "max": 360, "step": 0.1},
|
"range": {"min": 0, "max": 360, "step": 1},
|
||||||
},
|
},
|
||||||
"emitRange": {
|
"emitRange": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
@@ -133,7 +133,7 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
|
|||||||
raise ValueError("facing must be a number between 0 and 360.") from exc
|
raise ValueError("facing must be a number between 0 and 360.") from exc
|
||||||
if not (0 <= facing <= 360):
|
if not (0 <= facing <= 360):
|
||||||
raise ValueError("facing must be between 0 and 360.")
|
raise ValueError("facing must be between 0 and 360.")
|
||||||
next_params["facing"] = round(facing, 1)
|
next_params["facing"] = int(round(facing))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 20)))
|
emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 20)))
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
|||||||
"facing": {
|
"facing": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
"tooltip": "Facing direction in degrees used when directional is on.",
|
"tooltip": "Facing direction in degrees used when directional is on.",
|
||||||
"range": {"min": 0, "max": 360, "step": 0.1},
|
"range": {"min": 0, "max": 360, "step": 1},
|
||||||
},
|
},
|
||||||
"emitRange": {
|
"emitRange": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
@@ -128,7 +128,7 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
|
|||||||
raise ValueError("facing must be a number between 0 and 360.") from exc
|
raise ValueError("facing must be a number between 0 and 360.") from exc
|
||||||
if not (0 <= facing <= 360):
|
if not (0 <= facing <= 360):
|
||||||
raise ValueError("facing must be between 0 and 360.")
|
raise ValueError("facing must be between 0 and 360.")
|
||||||
next_params["facing"] = round(facing, 1)
|
next_params["facing"] = int(round(facing))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 15)))
|
emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 15)))
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
)
|
)
|
||||||
assert send_payloads[-1].ok is True
|
assert send_payloads[-1].ok is True
|
||||||
assert item.params.get("directional") is True
|
assert item.params.get("directional") is True
|
||||||
assert item.params.get("facing") == 123.4
|
assert item.params.get("facing") == 123
|
||||||
assert item.params.get("emitRange") == 7
|
assert item.params.get("emitRange") == 7
|
||||||
assert item.params.get("emitVolume") == 42
|
assert item.params.get("emitVolume") == 42
|
||||||
assert item.params.get("emitSoundSpeed") == 25
|
assert item.params.get("emitSoundSpeed") == 25
|
||||||
|
|||||||
Reference in New Issue
Block a user