diff --git a/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts b/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts index a8753a3a..c0b18be9 100644 --- a/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts +++ b/src/app/widgets/gauge-steel/gauge-steel.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SimpleChange } from '@angular/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { GaugeSteelComponent } from './gauge-steel.component'; +import { GaugeSteelComponent, SteelBackgroundColors, SteelFrameColors } from './gauge-steel.component'; import { UnitsService } from '../../core/services/units.service'; import { States } from '../../core/interfaces/signalk-interfaces'; @@ -242,6 +242,309 @@ describe('GaugeSteelComponent', () => { expect(seeded).toEqual([1800]); }); + // #558: subType, barGauge and decimals are read only when the gauge object is constructed, so + // editing them in widget options produced no visible effect until some unrelated event -- a + // window resize, or the server's unit metadata landing -- happened to rebuild the gauge. + it('rebuilds as the other library type when subType changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Radial = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + steel.Linear = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-subtype'); + fixture.componentRef.setInput('subType', 'radial'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + expect(steel.Radial).toHaveBeenCalledTimes(1); + + fixture.componentRef.setInput('subType', 'linear'); + internals.ngOnChanges({ subType: new SimpleChange('radial', 'linear', false) }); + + expect(steel.Linear).toHaveBeenCalledTimes(1); + }); + + // The geometry keys are per-class: the radial reads `size`, the linear pair reads `width`/`height` + // and falls back to the canvas element's dimensions when they are missing — which the outgoing + // radial had set square. A leftover `size` therefore renders the linear gauge at the tile's + // shorter side, and no resize follows the flip to correct it. + it('re-derives the geometry for the new class when subType changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + const linearOptions: Record[] = []; + steel.Radial = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + steel.Linear = vi.fn(function (this: FakeGauge, _id: string, opts: Record) { + linearOptions.push({ ...opts }); + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-geometry'); + fixture.componentRef.setInput('subType', 'radial'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + onResized: (e: ResizeObserverEntry) => void; + }; + internals.onResized({ contentRect: { width: 400, height: 150 } } as ResizeObserverEntry); + internals.startGauge(true); + + fixture.componentRef.setInput('subType', 'linear'); + internals.ngOnChanges({ subType: new SimpleChange('radial', 'linear', false) }); + + expect(linearOptions).toHaveLength(1); + expect(linearOptions[0]['width']).toBe(400); + expect(linearOptions[0]['height']).toBe(150); + expect(linearOptions[0]['size']).toBeUndefined(); + }); + + // Zone band colours are resolved from the theme inside buildOptions and baked into the Section + // objects at construction, so a day/night switch has to rebuild or the bands keep the old colours. + it('rebuilds when the theme changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Radial = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-theme'); + fixture.componentRef.setInput('subType', 'radial'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + expect(steel.Radial).toHaveBeenCalledTimes(1); + + internals.ngOnChanges({ theme: new SimpleChange(null, {}, false) }); + + expect(steel.Radial).toHaveBeenCalledTimes(2); + }); + + it('rebuilds as a bargraph when the Digital Meter setting is turned on', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Linear = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + steel.LinearBargraph = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-bargauge'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('barGauge', false); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + expect(steel.Linear).toHaveBeenCalledTimes(1); + + fixture.componentRef.setInput('barGauge', true); + internals.ngOnChanges({ barGauge: new SimpleChange(false, true, false) }); + + expect(steel.LinearBargraph).toHaveBeenCalledTimes(1); + }); + + it('rebuilds with the new LCD precision when decimals changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Linear = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-decimals'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('decimals', 2); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + gaugeOptions: { lcdDecimals?: number }; + }; + internals.startGauge(true); + expect(internals.gaugeOptions.lcdDecimals).toBe(2); + + fixture.componentRef.setInput('decimals', 0); + internals.ngOnChanges({ decimals: new SimpleChange(2, 0, false) }); + + expect(internals.gaugeOptions.lcdDecimals).toBe(0); + expect(steel.Linear).toHaveBeenCalledTimes(2); + }); + + // A rebuild constructs the replacement from buildOptions, which re-reads the title, background and + // frame inputs. Calling their setters as well would target whichever gauge object happened to exist + // at that point in the batch -- the one the rebuild discards -- so the batch carries them through + // the rebuild instead. + it('carries a title change batched with a rebuild into the replacement gauge', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + const titleSetter = vi.fn(); + steel.Linear = vi.fn(function (this: FakeGauge & { setTitleString: (t: string) => void }) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + this.setTitleString = titleSetter; + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-title-batch'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('title', 'Old'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + gaugeOptions: { titleString?: string }; + }; + internals.startGauge(true); + + fixture.componentRef.setInput('title', 'New'); + fixture.componentRef.setInput('maxValue', 200); + internals.ngOnChanges({ + title: new SimpleChange('Old', 'New', false), + maxValue: new SimpleChange(100, 200, false), + }); + + expect(internals.gaugeOptions.titleString).toBe('New'); + expect(titleSetter).not.toHaveBeenCalled(); + }); + + // The batch runs ONE rebuild: buildOptions re-reads every input, so a second would repeat the work + // on inputs the first already carried -- and the library cannot cancel the discarded gauge's tween, + // so it keeps repainting the canvas with its stale scale and wins the last frame. + it('rebuilds once for a batch carrying several structural changes', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Section = vi.fn((lower: number, upper: number, color: string) => ({ lower, upper, color })); + steel.Linear = vi.fn(function (this: FakeGauge) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-one-rebuild'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('units', 'V'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + fixture.componentRef.setInput('decimals', 2); + fixture.componentRef.setInput('zones', []); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + expect(steel.Linear).toHaveBeenCalledTimes(1); + + fixture.componentRef.setInput('maxValue', 200); + fixture.componentRef.setInput('decimals', 0); + fixture.componentRef.setInput('zones', [{ upper: 50, state: States.Alarm }]); + internals.ngOnChanges({ + maxValue: new SimpleChange(100, 200, false), + decimals: new SimpleChange(2, 0, false), + zones: new SimpleChange([], [{ upper: 50, state: States.Alarm }], false), + }); + + expect(steel.Linear).toHaveBeenCalledTimes(2); + }); + + // The setters run only on a batch with no structural change; buildOptions carries these inputs + // through a rebuild. Both halves need pinning -- deleting the setters is otherwise invisible. + it('applies a standalone title, background and frame change through the live setters', () => { + const calls: string[] = []; + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + steel.Radial = vi.fn(function (this: FakeGauge & Record) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + this.setTitleString = () => { calls.push('title'); }; + this.setBackgroundColor = () => { calls.push('background'); }; + this.setFrameDesign = () => { calls.push('frame'); }; + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-live-setters'); + fixture.componentRef.setInput('subType', 'radial'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + }; + internals.startGauge(true); + + internals.ngOnChanges({ title: new SimpleChange('Old', 'New', false) }); + internals.ngOnChanges({ backgroundColor: new SimpleChange('carbon', 'white', false) }); + internals.ngOnChanges({ frameColor: new SimpleChange('anthracite', 'brass', false) }); + + expect(calls).toEqual(['title', 'background', 'frame']); + // No rebuild: none of these is structural. + expect(steel.Radial).toHaveBeenCalledTimes(1); + }); + + it('carries the background and frame through a rebuild, without calling their setters', () => { + const steel = (globalThis as unknown as { steelseries: Record }).steelseries; + const bgSetter = vi.fn(); + const frameSetter = vi.fn(); + steel.Linear = vi.fn(function (this: FakeGauge & Record) { + this.setValue = vi.fn(); + this.setValueAnimated = vi.fn(); + this.setBackgroundColor = bgSetter; + this.setFrameDesign = frameSetter; + }); + + fixture.componentRef.setInput('widgetUUID', 'uuid-carry-colors'); + fixture.componentRef.setInput('subType', 'linear'); + fixture.componentRef.setInput('backgroundColor', 'carbon'); + fixture.componentRef.setInput('frameColor', 'anthracite'); + fixture.componentRef.setInput('minValue', 0); + fixture.componentRef.setInput('maxValue', 100); + + const internals = component as unknown as { + startGauge: (f?: boolean) => void; + ngOnChanges: (c: Record) => void; + gaugeOptions: { backgroundColor?: string; frameDesign?: string }; + }; + internals.startGauge(true); + + fixture.componentRef.setInput('backgroundColor', 'white'); + fixture.componentRef.setInput('frameColor', 'brass'); + fixture.componentRef.setInput('maxValue', 200); + internals.ngOnChanges({ + backgroundColor: new SimpleChange('carbon', 'white', false), + frameColor: new SimpleChange('anthracite', 'brass', false), + maxValue: new SimpleChange(100, 200, false), + }); + + expect(internals.gaugeOptions.backgroundColor).toBe(SteelBackgroundColors['white']); + expect(internals.gaugeOptions.frameDesign).toBe(SteelFrameColors['brass']); + expect(bgSetter).not.toHaveBeenCalled(); + expect(frameSetter).not.toHaveBeenCalled(); + }); + it('still animates a value change that stands alone', () => { const animated: number[] = []; const steel = (globalThis as unknown as { steelseries: Record }).steelseries; diff --git a/src/app/widgets/gauge-steel/gauge-steel.component.ts b/src/app/widgets/gauge-steel/gauge-steel.component.ts index 373e99d3..50c9111d 100644 --- a/src/app/widgets/gauge-steel/gauge-steel.component.ts +++ b/src/app/widgets/gauge-steel/gauge-steel.component.ts @@ -60,6 +60,9 @@ export const SteelFrameColors = { 'glossyMetal': steelseries.FrameDesign.GLOSSY_METAL } +/** Inputs the steelseries gauge exposes a live setter for; every other input needs a rebuild. */ +const LIVE_APPLIED_INPUTS = ['value', 'title', 'backgroundColor', 'frameColor']; + @Component({ selector: 'gauge-steel', templateUrl: './gauge-steel.component.html', @@ -97,6 +100,7 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy { private lastSizeSignature = ''; private resizeTimer: number | null = null; private pendingStructuralRebuild = false; + private lastRect: { width: number; height: number } | null = null; ngOnInit(): void { this.buildOptions(); @@ -273,20 +277,35 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy { } } - onResized(event: ResizeObserverEntry):void { - if (event.contentRect.height < 50 || event.contentRect.width < 50) return; - let signature: string; + /** + * Write the geometry keys the current gauge class reads, and drop the other class's. The radial + * takes `size`; the linear pair takes `width`/`height` and ignores `size`, falling back to the + * canvas element's current dimensions when they are absent — which the outgoing radial had set + * square. Leaving a stale key behind therefore builds a linear gauge at the radial's side length + * in a tile that is not square, and no resize follows to correct it. + */ + private applyGeometry(): string { + const rect = this.lastRect; + if (!rect) return this.lastSizeSignature; if (this.subType() === 'radial') { - const size = Math.floor(Math.min(event.contentRect.height, event.contentRect.width)); + const size = Math.floor(Math.min(rect.height, rect.width)); + delete this.gaugeOptions['width']; + delete this.gaugeOptions['height']; this.gaugeOptions['size'] = size; - signature = 'radial:' + size; - } else { - const w = Math.floor(event.contentRect.width); - const h = Math.floor(event.contentRect.height); - this.gaugeOptions['width'] = w; - this.gaugeOptions['height'] = h; - signature = `linear:${w}x${h}`; + return 'radial:' + size; } + const w = Math.floor(rect.width); + const h = Math.floor(rect.height); + delete this.gaugeOptions['size']; + this.gaugeOptions['width'] = w; + this.gaugeOptions['height'] = h; + return `linear:${w}x${h}`; + } + + onResized(event: ResizeObserverEntry):void { + if (event.contentRect.height < 50 || event.contentRect.width < 50) return; + this.lastRect = { width: event.contentRect.width, height: event.contentRect.height }; + const signature = this.applyGeometry(); if (signature === this.lastSizeSignature) return; // no meaningful change this.lastSizeSignature = signature; if (this.resizeTimer) window.clearTimeout(this.resizeTimer); @@ -304,13 +323,28 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy { // with its own now-stale scale, sections and size — and wins the last frame. Under a steady // reading nothing repaints afterwards, leaving the old face on screen for good. The server's // measure resolving after the first value delivers exactly this batch on an ordinary boot. - const rebuilding = !!(changes.zones || changes.radialSize || changes.units || changes.minValue || changes.maxValue); - if (changes.value && !changes.value.firstChange && !rebuilding) { - this.gauge.setValueAnimated(changes.value.currentValue); - } - if (changes.zones) { + // Listed by what the library CAN apply to a live gauge, so anything else rebuilds by default. + // Inverted deliberately: an input added later costs a needless rebuild at worst, where a missing + // entry in a rebuild list does nothing at all and shows nothing on screen — which is #558. + // subType and barGauge pick the gauge class itself; decimals is captured into a closure at + // construction; zone sections are converted with `units` and clamped to [minValue, maxValue], so + // a scale move has to recompute the bands rather than the axis alone or they desync from the + // value; `theme` supplies the section colours. One startGauge(true) applies the whole batch in a + // single pass, since buildOptions re-reads every input. A rebuild while units is still '' (boot) + // self-corrects when the first value sets it. + const rebuilding = Object.keys(changes).some(key => !LIVE_APPLIED_INPUTS.includes(key)); + if (rebuilding) { + // The geometry keys are per-class, so a subType flip has to re-derive them from the last + // observed rect; no resize follows the flip to do it. + if (changes.subType) { + this.lastSizeSignature = this.applyGeometry(); + } this.pendingStructuralRebuild = true; - this.startGauge(true); // sections require rebuild + this.startGauge(true); + return; + } + if (changes.value && !changes.value.firstChange) { + this.gauge.setValueAnimated(changes.value.currentValue); } if (changes.title) { this.gauge.setTitleString(changes.title.currentValue); @@ -321,19 +355,6 @@ export class GaugeSteelComponent implements OnInit, OnChanges, OnDestroy { if(changes.frameColor) { this.gauge.setFrameDesign(SteelFrameColors[changes.frameColor.currentValue]); } - if (changes.radialSize){ - this.pendingStructuralRebuild = true; - this.startGauge(true); // radial geometry change - } - if (changes.units || changes.minValue || changes.maxValue) { - // Zone sections are converted with `units` and clamped to [minValue, maxValue]. When the - // server-resolved measure lands (units) or the reinterpreted scale bounds move (min/max), the - // sections must be rebuilt, not just the axis, or the bands desync from the scale and value. - // startGauge(true) applies the new min/max via buildOptions and recomputes the sections in one - // pass; a rebuild while units is still '' (boot) self-corrects when the first value sets it. - this.pendingStructuralRebuild = true; - this.startGauge(true); - } } ngOnDestroy(): void {