diff --git a/app/client/.eslintrc.json b/app/client/.eslintrc.json deleted file mode 100644 index 318eb6baeb..0000000000 --- a/app/client/.eslintrc.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "env": { - "browser": true, - "node": false, - "es2021": true - }, - "extends": [ - "standard", - "plugin:wc/recommended", - "plugin:lit/recommended" - ], - "parser": "@babel/eslint-parser", - "parserOptions": { - "ecmaVersion": 13, - "sourceType": "module" - }, - "ignorePatterns": ["**/*.min.js"], - "rules": { - "camelcase": 0 - } -} diff --git a/app/client/components/AppDialog.test.ts b/app/client/components/AppDialog.test.ts new file mode 100644 index 0000000000..559688c81c --- /dev/null +++ b/app/client/components/AppDialog.test.ts @@ -0,0 +1,53 @@ +/* + Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor +*/ +// @vitest-environment happy-dom +import { test, expect, describe, vi } from 'vitest' + +import { AppDialog } from './AppDialog' + +describe('confirm', () => { + test('should close the dialog with "confirm" when isValid is true', () => { + const dialog = new AppDialog() + dialog.isValid = true + const closeFn = vi.fn() + dialog.dialog = { value: { close: closeFn } } as any + + dialog.confirm() + + expect(closeFn).toHaveBeenCalledWith('confirm') + }) + + test('should not close the dialog when isValid is false', () => { + const dialog = new AppDialog() + dialog.isValid = false + const closeFn = vi.fn() + dialog.dialog = { value: { close: closeFn } } as any + + dialog.confirm() + + expect(closeFn).not.toHaveBeenCalled() + }) +}) + +describe('close', () => { + test('should dispatch a CustomEvent with detail "cancel" when not confirmed', () => { + const dialog = new AppDialog() + let received: CustomEvent | undefined + dialog.addEventListener('close', (e) => { received = e as CustomEvent }) + + dialog.close({ target: { returnValue: 'cancel' } } as any) + + expect(received!.detail).toBe('cancel') + }) + + test('should dispatch a CustomEvent with detail "confirm" when confirmed', () => { + const dialog = new AppDialog() + let received: CustomEvent | undefined + dialog.addEventListener('close', (e) => { received = e as CustomEvent }) + + dialog.close({ target: { returnValue: 'confirm' } } as any) + + expect(received!.detail).toBe('confirm') + }) +}) diff --git a/app/client/components/AppDialog.js b/app/client/components/AppDialog.ts similarity index 82% rename from app/client/components/AppDialog.js rename to app/client/components/AppDialog.ts index 8c817c77a8..f6857aeb48 100644 --- a/app/client/components/AppDialog.js +++ b/app/client/components/AppDialog.ts @@ -1,19 +1,15 @@ -'use strict' /* Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor Component that renders a html dialog */ -import { AppElement, html, css } from './AppElement.js' +import { AppElement, html, css } from './AppElement' import { customElement, property } from 'lit/decorators.js' -import { ref, createRef } from 'lit/directives/ref.js' +import { ref, createRef, Ref } from 'lit/directives/ref.js' @customElement('app-dialog') export class AppDialog extends AppElement { - constructor () { - super() - this.dialog = createRef() - } + dialog: Ref = createRef() static styles = css` dialog { @@ -72,10 +68,10 @@ export class AppDialog extends AppElement { } ` @property({ type: Boolean }) - accessor isValid = true + isValid = true @property({ type: Boolean, reflect: true }) - accessor dialogOpen + dialogOpen: boolean | undefined render () { return html` @@ -93,8 +89,8 @@ export class AppDialog extends AppElement { ` } - close (event) { - if (event.target.returnValue !== 'confirm') { + close (event: Event) { + if ((event?.target as HTMLDialogElement).returnValue !== 'confirm') { this.dispatchEvent(new CustomEvent('close', { detail: 'cancel' })) } else { this.dispatchEvent(new CustomEvent('close', { detail: 'confirm' })) @@ -103,20 +99,20 @@ export class AppDialog extends AppElement { confirm () { if (this.isValid) { - this.dialog.value.close('confirm') + this.dialog.value?.close('confirm') } } firstUpdated () { - this.dialog.value.showModal() + this.dialog.value?.showModal() } - updated (changedProperties) { + updated (changedProperties: Map) { if (changedProperties.has('dialogOpen')) { if (this.dialogOpen) { - this.dialog.value.showModal() + this.dialog.value?.showModal() } else { - this.dialog.value.close() + this.dialog.value?.close() } } } diff --git a/app/client/components/AppElement.test.ts b/app/client/components/AppElement.test.ts new file mode 100644 index 0000000000..ed57179b02 --- /dev/null +++ b/app/client/components/AppElement.test.ts @@ -0,0 +1,36 @@ +/* + Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor +*/ +// @vitest-environment happy-dom +import { test, expect, describe } from 'vitest' + +import { AppElement } from './AppElement' +import { customElement } from 'lit/decorators.js' + +// AppElement has no @customElement decorator, so we register a test subclass +@customElement('test-app-element') +class TestAppElement extends AppElement {} + +describe('sendEvent', () => { + test('should dispatch a CustomEvent with the correct type and detail', () => { + const el = new TestAppElement() + let received: CustomEvent | undefined + el.addEventListener('testEvent', (e) => { received = e as CustomEvent }) + + el.sendEvent('testEvent', { foo: 'bar' }) + + expect(received).toBeDefined() + expect(received!.detail).toEqual({ foo: 'bar' }) + }) + + test('should set bubbles and composed to true', () => { + const el = new TestAppElement() + let received: CustomEvent | undefined + el.addEventListener('testEvent', (e) => { received = e as CustomEvent }) + + el.sendEvent('testEvent', null) + + expect(received!.bubbles).toBe(true) + expect(received!.composed).toBe(true) + }) +}) diff --git a/app/client/components/AppElement.js b/app/client/components/AppElement.ts similarity index 89% rename from app/client/components/AppElement.js rename to app/client/components/AppElement.ts index 817492b415..12b946471e 100644 --- a/app/client/components/AppElement.js +++ b/app/client/components/AppElement.ts @@ -1,4 +1,3 @@ -'use strict' /* Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor @@ -10,7 +9,7 @@ export * from 'lit' export class AppElement extends LitElement { // a helper to dispatch events to the parent components - sendEvent (eventType, eventData) { + sendEvent (eventType: string, eventData: unknown) { this.dispatchEvent( new CustomEvent(eventType, { detail: eventData, diff --git a/app/client/components/BatteryIcon.test.ts b/app/client/components/BatteryIcon.test.ts new file mode 100644 index 0000000000..6dc96647ad --- /dev/null +++ b/app/client/components/BatteryIcon.test.ts @@ -0,0 +1,52 @@ +/* + Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor +*/ +// @vitest-environment happy-dom +import { test, expect, describe } from 'vitest' + +// The class is exported as DashboardMetric (naming bug in source) +import { DashboardMetric as BatteryIcon } from './BatteryIcon' + +describe('battery width calculation', () => { + test('should compute batteryWidth as batteryLevel * 416 / 100', () => { + const el = new BatteryIcon() + el.batteryLevel = 50 + // batteryWidth = 50 * 416 / 100 = 208 + // We verify by checking the rendered SVG contains the computed width + // Trigger a render manually via the render method + const result = (el as any).render() + // The render returns a TemplateResult; we check the values array contains 208 + expect(result.values).toContain(208) + }) + + test('should compute batteryWidth as 0 when batteryLevel is 0', () => { + const el = new BatteryIcon() + el.batteryLevel = 0 + const result = (el as any).render() + expect(result.values).toContain(0) + }) + + test('should compute batteryWidth as 416 when batteryLevel is 100', () => { + const el = new BatteryIcon() + el.batteryLevel = 100 + const result = (el as any).render() + expect(result.values).toContain(416) + }) +}) + +describe('icon CSS class', () => { + test('should include low-battery when level is 25 or less', () => { + const el = new BatteryIcon() + el.batteryLevel = 25 + const result = (el as any).render() + expect(result.values).toContain('icon low-battery') + }) + + test('should not include low-battery when level is above 25', () => { + const el = new BatteryIcon() + el.batteryLevel = 26 + const result = (el as any).render() + expect(result.values).toContain('icon') + expect(result.values).not.toContain('icon low-battery') + }) +}) diff --git a/app/client/components/BatteryIcon.js b/app/client/components/BatteryIcon.ts similarity index 92% rename from app/client/components/BatteryIcon.js rename to app/client/components/BatteryIcon.ts index e6701b1428..b129816551 100644 --- a/app/client/components/BatteryIcon.js +++ b/app/client/components/BatteryIcon.ts @@ -1,11 +1,10 @@ -'use strict' /* Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor Component that renders a battery indicator */ -import { AppElement, svg, css } from './AppElement.js' +import { AppElement, svg, css } from './AppElement' import { customElement, property } from 'lit/decorators.js' @customElement('battery-icon') @@ -21,7 +20,7 @@ export class DashboardMetric extends AppElement { ` @property({ type: Number }) - accessor batteryLevel = 0 + batteryLevel = 0 render () { // 416 is the max width value of the battery bar in the SVG graphic diff --git a/app/client/components/DashboardForceCurve.test.ts b/app/client/components/DashboardForceCurve.test.ts new file mode 100644 index 0000000000..0bef40dc40 --- /dev/null +++ b/app/client/components/DashboardForceCurve.test.ts @@ -0,0 +1,110 @@ +/* + Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor +*/ +// @vitest-environment happy-dom +import { test, expect, describe } from 'vitest' + +import { DashboardForceCurve } from './DashboardForceCurve' + +function createForceCurve (): DashboardForceCurve { + const el = new DashboardForceCurve() + return el +} + +describe('_handleClick', () => { + test('should cycle division modes in order: 0 → 2 → 3 → 0', () => { + const el = createForceCurve() + const received: unknown[] = [] + el.addEventListener('changeGuiSetting', (e) => { + received.push((e as CustomEvent).detail) + }) + + el.divisionMode = 0 + el._handleClick() + + el.divisionMode = 2 + el._handleClick() + + el.divisionMode = 3 + el._handleClick() + + expect(received).toEqual([ + { forceCurveDivisionMode: 2 }, + { forceCurveDivisionMode: 3 }, + { forceCurveDivisionMode: 0 } + ]) + }) +}) + +describe('_updateDivisionLines', () => { + test('should compute correct positions for 2-way division', () => { + const el = createForceCurve() + el.value = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + el.divisionMode = 2 + // Mock chart with plugins + el._chart = { + options: { plugins: { divisionLines: { positions: [] } } } + } as any + + el._updateDivisionLines() + + // @ts-ignore + expect(el._chart!.options!.plugins!.divisionLines.positions).toEqual([5]) + }) + + test('should compute correct positions for 3-way division', () => { + const el = createForceCurve() + el.value = [1, 2, 3, 4, 5, 6, 7, 8, 9] + el.divisionMode = 3 + el._chart = { + options: { plugins: { divisionLines: { positions: [] } } } + } as any + + el._updateDivisionLines() + + // @ts-ignore + expect(el._chart!.options!.plugins!.divisionLines.positions).toEqual([3, 6]) + }) + + test('should return an empty array for mode 0', () => { + const el = createForceCurve() + el.value = [1, 2, 3, 4, 5] + el.divisionMode = 0 + el._chart = { + options: { plugins: { divisionLines: { positions: [] } } } + } as any + + el._updateDivisionLines() + + // @ts-ignore + expect(el._chart!.options!.plugins!.divisionLines.positions).toEqual([]) + }) +}) + +describe('shouldUpdate', () => { + test('should return true when updateForceCurve is true', () => { + const el = createForceCurve() + el.updateForceCurve = true + const changedProperties = new Map() + + expect(el.shouldUpdate(changedProperties)).toBe(true) + }) + + test('should return true when divisionMode has changed', () => { + const el = createForceCurve() + el.updateForceCurve = false + el._chart = {} as any // chart exists, so that condition is false + const changedProperties = new Map([['divisionMode', 0]]) + + expect(el.shouldUpdate(changedProperties)).toBe(true) + }) + + test('should return false when nothing relevant has changed and chart exists', () => { + const el = createForceCurve() + el.updateForceCurve = false + el._chart = {} as any + const changedProperties = new Map() + + expect(el.shouldUpdate(changedProperties)).toBe(false) + }) +}) diff --git a/app/client/components/DashboardForceCurve.js b/app/client/components/DashboardForceCurve.ts similarity index 87% rename from app/client/components/DashboardForceCurve.js rename to app/client/components/DashboardForceCurve.ts index c0a79715a7..6833d6b5f4 100644 --- a/app/client/components/DashboardForceCurve.js +++ b/app/client/components/DashboardForceCurve.ts @@ -1,19 +1,19 @@ -'use strict' /* Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor Component that renders a metric of the dashboard */ -import { AppElement, html, css } from './AppElement.js' +import { AppElement, html, css } from './AppElement' import { customElement, property, state } from 'lit/decorators.js' import ChartDataLabels from 'chartjs-plugin-datalabels' import { Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement } from 'chart.js' +import type { Plugin } from 'chart.js' /** @type {import('chart.js').Plugin<'line', {positions: number[]}>} */ -const divisionLinesPlugin = { +const divisionLinesPlugin: Plugin<'line'> = { id: 'divisionLines', - afterDatasetsDraw (chart, args, options) { + afterDatasetsDraw (chart, args, options: { positions?: number[] }) { if (!options.positions?.length) { return } const { ctx, chartArea: { top, bottom } } = chart ctx.save() @@ -62,32 +62,33 @@ export class DashboardForceCurve extends AppElement { @property({ type: Boolean }) - accessor updateForceCurve = false + updateForceCurve = false @property({ type: Array }) - accessor value = [] + value: number[] = [] /** @type {0 | 2 | 3} */ @property({ type: Number }) - accessor divisionMode = 0 + divisionMode: 0 | 2 | 3 = 0 @state() - accessor _chart + _chart: Chart | undefined /** @type {0 | 2 | 3} */ @state() - accessor _divisionMode = 0 + _divisionMode: 0 | 2 | 3 = 0 - shouldUpdate (changedProperties) { + shouldUpdate (changedProperties: Map) { return this.updateForceCurve || changedProperties.has('divisionMode') || this._chart === undefined } _handleClick () { - const modes = /** @type {(0 | 2 | 3)[]} */ ([0, 2, 3]) + /** @type {(0 | 2 | 3)[]} */ + const modes: (0 | 2 | 3)[] = [0, 2, 3] const nextMode = modes[(modes.indexOf(this.divisionMode) + 1) % modes.length] this.sendEvent('changeGuiSetting', { forceCurveDivisionMode: nextMode }) } @@ -111,11 +112,11 @@ export class DashboardForceCurve extends AppElement { // Updated runs _after_ DOM elements exist, which is what chart.js expects. updated () { - this._chart.update() + this._chart?.update() } firstUpdated () { - const ctx = this.renderRoot.querySelector('#chart').getContext('2d') + const ctx = (this.renderRoot.querySelector('#chart') as HTMLCanvasElement).getContext('2d')! this._chart = new Chart( ctx, { @@ -178,7 +179,6 @@ export class DashboardForceCurve extends AppElement { render () { return html` - ${this._chart?.data.datasets[0].data.length ? '' : html`
Force Curve
` diff --git a/app/client/components/DashboardMetric.test.ts b/app/client/components/DashboardMetric.test.ts new file mode 100644 index 0000000000..a98e9a8ba1 --- /dev/null +++ b/app/client/components/DashboardMetric.test.ts @@ -0,0 +1,44 @@ +/* + Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor +*/ +// @vitest-environment happy-dom +import { test, expect, describe } from 'vitest' + +import { DashboardMetric } from './DashboardMetric' + +describe('value display', () => { + test('should fall back to "--" when value is undefined', () => { + const el = new DashboardMetric() + el.value = undefined + const result = (el as any).render() + // The template contains: ${this.value !== undefined ? this.value : '--'} + expect(result.values).toContain('--') + }) + + test('should display the value when it is defined', () => { + const el = new DashboardMetric() + el.value = '42' + const result = (el as any).render() + expect(result.values).toContain('42') + }) +}) + +describe('icon rendering', () => { + test('should use empty class strings when no icon is provided', () => { + const el = new DashboardMetric() + el.icon = '' + const result = (el as any).render() + // When icon is '', the class is '' and font-size style is 200% + expect(result.values).toContain('font-size: 200%;') + }) + + test('should use label/icon classes when an icon is provided', () => { + const el = new DashboardMetric() + el.icon = 'some-icon' + const result = (el as any).render() + expect(result.values).toContain('label') + expect(result.values).toContain('icon') + // When icon is not '', there's no inline font-size override + expect(result.values).not.toContain('font-size: 200%;') + }) +}) diff --git a/app/client/components/DashboardMetric.js b/app/client/components/DashboardMetric.ts similarity index 89% rename from app/client/components/DashboardMetric.js rename to app/client/components/DashboardMetric.ts index 5844fb6899..ed3157e9c9 100644 --- a/app/client/components/DashboardMetric.js +++ b/app/client/components/DashboardMetric.ts @@ -1,11 +1,10 @@ -'use strict' /* Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor Component that renders a metric of the dashboard */ -import { AppElement, html, css } from './AppElement.js' +import { AppElement, html, css } from './AppElement' import { customElement, property } from 'lit/decorators.js' @customElement('dashboard-metric') @@ -35,13 +34,13 @@ export class DashboardMetric extends AppElement { ` @property({ type: Object }) - accessor icon = '' + icon: unknown = '' @property({ type: String }) - accessor unit = '' + unit = '' @property({ type: String }) - accessor value + value: string | number | undefined render () { return html` diff --git a/app/client/components/DashboardToolbar.test.ts b/app/client/components/DashboardToolbar.test.ts new file mode 100644 index 0000000000..a266ba5f85 --- /dev/null +++ b/app/client/components/DashboardToolbar.test.ts @@ -0,0 +1,94 @@ +/* + Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor +*/ +// @vitest-environment happy-dom +import { test, expect, describe } from 'vitest' + +import { DashboardToolbar } from './DashboardToolbar' +import type { AppConfig } from '../store/types' + +function createToolbar (config: Partial = {}): DashboardToolbar { + const toolbar = new DashboardToolbar() + toolbar.config = { + blePeripheralMode: '', + hrmPeripheralMode: '', + antPeripheralMode: '', + uploadEnabled: false, + shutdownEnabled: false, + guiConfigs: { + dashboardMetrics: [], + showIcons: true, + maxNumberOfTiles: 8, + trueBlackTheme: false, + forceCurveDivisionMode: 0 + }, + ...config + } + return toolbar +} + +describe('blePeripheralMode', () => { + test('should return "C2 PM5" when the mode is PM5', () => { + const toolbar = createToolbar({ blePeripheralMode: 'PM5' }) + expect(toolbar.blePeripheralMode()).toBe('C2 PM5') + }) + + test('should return "FTMS Rower" when the mode is FTMS', () => { + const toolbar = createToolbar({ blePeripheralMode: 'FTMS' }) + expect(toolbar.blePeripheralMode()).toBe('FTMS Rower') + }) + + test('should return "FTMS Bike" when the mode is FTMSBIKE', () => { + const toolbar = createToolbar({ blePeripheralMode: 'FTMSBIKE' }) + expect(toolbar.blePeripheralMode()).toBe('FTMS Bike') + }) + + test('should return "Bike Speed + Cadence" when the mode is CSC', () => { + const toolbar = createToolbar({ blePeripheralMode: 'CSC' }) + expect(toolbar.blePeripheralMode()).toBe('Bike Speed + Cadence') + }) + + test('should return "Bike Power" when the mode is CPS', () => { + const toolbar = createToolbar({ blePeripheralMode: 'CPS' }) + expect(toolbar.blePeripheralMode()).toBe('Bike Power') + }) + + test('should return "Off" when the mode is unknown', () => { + const toolbar = createToolbar({ blePeripheralMode: 'UNKNOWN' }) + expect(toolbar.blePeripheralMode()).toBe('Off') + }) + + test('should return "Off" when the mode is an empty string', () => { + const toolbar = createToolbar({ blePeripheralMode: '' }) + expect(toolbar.blePeripheralMode()).toBe('Off') + }) +}) + +describe('renderOptionalButtons', () => { + test('should include the upload button when uploadEnabled is true', () => { + const toolbar = createToolbar({ uploadEnabled: true }) + const buttons = toolbar.renderOptionalButtons() + // Upload button should always appear when uploadEnabled is true regardless of mode + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + test('should include the shutdown button in KIOSK mode when shutdownEnabled is true', () => { + const toolbar = createToolbar({ shutdownEnabled: true }) + toolbar._appMode = 'KIOSK' + const buttons = toolbar.renderOptionalButtons() + // Should have shutdown button + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + test('should not include shutdown button in BROWSER mode', () => { + const toolbar = createToolbar({ shutdownEnabled: true, uploadEnabled: false }) + toolbar._appMode = 'BROWSER' + // In BROWSER mode, fullscreen button may appear if requestFullscreen exists, + // but shutdown should not + const buttons = toolbar.renderOptionalButtons() + // In BROWSER mode with no upload, buttons should only be fullscreen (if available) + // Shutdown is gated by _appMode === 'KIOSK' + const buttonsWithoutFullscreen = buttons.filter((b) => String(b).includes('Shutdown')) + expect(buttonsWithoutFullscreen.length).toBe(0) + }) +}) diff --git a/app/client/components/DashboardToolbar.js b/app/client/components/DashboardToolbar.ts similarity index 92% rename from app/client/components/DashboardToolbar.js rename to app/client/components/DashboardToolbar.ts index ae4085e0af..04087ae30a 100644 --- a/app/client/components/DashboardToolbar.js +++ b/app/client/components/DashboardToolbar.ts @@ -1,15 +1,15 @@ -'use strict' /* Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor Toolbar component combining settings and action buttons */ -import { AppElement, html, css } from './AppElement.js' +import { AppElement, html, css, TemplateResult } from './AppElement' import { customElement, property, state } from 'lit/decorators.js' -import { iconSettings, iconUndo, iconExpand, iconCompress, iconPoweroff, iconBluetooth, iconUpload, iconHeartbeat, iconAntplus } from '../lib/icons.js' -import './SettingsDialog.js' -import './AppDialog.js' +import { iconSettings, iconUndo, iconExpand, iconCompress, iconPoweroff, iconBluetooth, iconUpload, iconHeartbeat, iconAntplus } from '../lib/icons' +import './SettingsDialog' +import './AppDialog' +import type { AppConfig } from '../store/types' @customElement('dashboard-toolbar') export class DashboardToolbar extends AppElement { @@ -83,13 +83,13 @@ export class DashboardToolbar extends AppElement { ` @property({ type: Object }) - accessor config = {} + config!: AppConfig @state() - accessor _appMode = 'BROWSER' + _appMode = 'BROWSER' @state() - accessor _dialog + _dialog?: TemplateResult render () { return html` @@ -139,7 +139,7 @@ export class DashboardToolbar extends AppElement { renderOptionalButtons () { const buttons = [] - if (this._appMode === 'BROWSER' && document.documentElement.requestFullscreen) { + if (this._appMode === 'BROWSER' && typeof document.documentElement.requestFullscreen === 'function') { buttons.push(html`