Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,8 @@ Every widget is a visual presentation control with configuration options of its
**Racing** — start-line and performance tools.

- **Racesteer** – Fuses polar performance data with live conditions to guide steering, tacking, and gybing angles. Needs the `signalk-polar-performance-plugin` plugin for its polar data. Skip lists it as **Racesteer (BETA)** in the Add Widget dialog.
- **Racer - Start Line Insight** – Set and adjust the start line ends, and see distance to the line, the favored end, and the bias. Integrates with Freeboard-SK. Needs the `signalk-racer` plugin.
- **Racer - Start Line Setup** – Set and adjust the start line ends, and see distance to the line, the favored end, and the bias. Integrates with Freeboard-SK. Needs the `signalk-racer` plugin.
- **Racer - Start Line Visualization** – A full-frame drawing of the start line with your boat against it at true scale, the start zone it sits in, and the approach the time to line is computed over. A ⋮ mode button selects the control mode: watching the line, setting its ends and switching between named lines, adjusting those ends, and adjusting the best VMGs. Needs the `signalk-racer` plugin.
- **Racer - Start Timer** – Racing countdown with OCS status and automatic switching to a target page at the start. Needs the `signalk-racer` plugin.
- **Countdown Timer** – Simple start countdown with start, pause, sync, and reset.
- **Wind Trends** – Live true wind trends on dual axes for direction and speed, with live values and moving averages.
Expand Down
122 changes: 122 additions & 0 deletions src/app/core/directives/widget-runtime.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
import { WidgetRuntimeDirective } from './widget-runtime.directive';
import type { IWidgetSvcConfig } from '../interfaces/widgets-interface';

/**
* The merge is what a placed widget actually runs on: its defaults under the config that
* was saved with it on the dashboard.
*/
describe('WidgetRuntimeDirective config merge', () => {
const build = (base: IWidgetSvcConfig, saved: IWidgetSvcConfig) =>
TestBed.runInInjectionContext(() => {
const d = new WidgetRuntimeDirective();
d.initialize(base, saved);
return d.options();
});

it('keeps the saved value for an editable path', () => {
const merged = build(
{ paths: { p: { description: 'P', path: 'self.a', source: 'default', pathType: 'number', isPathConfigurable: true } } },
{ paths: { p: { description: 'P', path: 'self.b', source: 'default', pathType: 'number', isPathConfigurable: true } } }
);
expect(merged?.paths?.['p'].path).toBe('self.b');
});

/**
* The regression this exists for: a widget placed on a dashboard stored the path its
* release happened to use, and because the path is fixed — not shown in the options
* dialog, not editable — a later correction to the widget's defaults could never reach
* it. The stored value is not a choice, so it does not get to win.
*/
it('takes a fixed path back from the defaults', () => {
const merged = build(
{ paths: { p: { description: 'P', path: 'self.racing.lines', source: 'default', pathType: 'object', isPathConfigurable: false, enableTimeout: false } } },
{ paths: { p: { description: 'P', path: 'self.racing.lines.lines', source: 'default', pathType: null, isPathConfigurable: false } } }
);
expect(merged?.paths?.['p'].path).toBe('self.racing.lines');
expect(merged?.paths?.['p'].pathType).toBe('object');
expect(merged?.paths?.['p'].enableTimeout).toBe(false);
});

/**
* A widget that retires a setting must actually be rid of it: the saved config carries
* whatever it was when the widget was placed, and the merge lets the saved value win.
*/
it('drops a retired widget setting the defaults no longer declare', () => {
const merged = build(
{ paths: {} },
{ enableTimeout: true, dataTimeout: 5, paths: {} }
);
expect(merged?.enableTimeout).toBeUndefined();
expect(merged?.dataTimeout).toBeUndefined();
});

it('keeps it for a widget that still declares it', () => {
const merged = build(
{ enableTimeout: false, paths: {} },
{ enableTimeout: true, paths: {} }
);
expect(merged?.enableTimeout).toBe(true);
});

it('leaves a fixed path that offers options alone, the stored one being a choice', () => {
const merged = build(
{
paths: {
p: {
description: 'P', path: 'self.headingTrue', source: 'default', pathType: 'number',
isPathConfigurable: false,
pathOptions: [{ label: 'True', path: 'self.headingTrue' }, { label: 'Magnetic', path: 'self.headingMagnetic' }]
}
}
},
{ paths: { p: { description: 'P', path: 'self.headingMagnetic', source: 'default', pathType: 'number', isPathConfigurable: false } } }
);
expect(merged?.paths?.['p'].path).toBe('self.headingMagnetic');
});

it('leaves the editable parts of a fixed path alone', () => {
const merged = build(
{ paths: { p: { description: 'P', path: 'self.len', source: 'default', pathType: 'number', isPathConfigurable: false, convertUnitTo: 'm' } } },
{ paths: { p: { description: 'P', path: 'self.len', source: 'n2k', pathType: 'number', isPathConfigurable: false, convertUnitTo: 'feet' } } }
);
expect(merged?.paths?.['p'].convertUnitTo).toBe('feet');
expect(merged?.paths?.['p'].source).toBe('n2k');
});

/**
* Whether a path keeps the widget's unit or follows the server's preference is the
* widget's own decision, not a stored choice — a widget corrected to hold metres must
* not be dragged back to the server's nautical miles by what was saved with it.
*/
it('takes a fixed path’s unit policy back from the defaults', () => {
const merged = build(
{ paths: { p: { description: 'P', path: 'self.len', source: 'default', pathType: 'number', isPathConfigurable: false, convertUnitTo: 'm', showConvertUnitTo: false } } },
{ paths: { p: { description: 'P', path: 'self.len', source: 'default', pathType: 'number', isPathConfigurable: false, convertUnitTo: 'm', showConvertUnitTo: true } } }
);
expect(merged?.paths?.['p'].showConvertUnitTo).toBe(false);
});

/**
* And with the policy, the unit it decides: a path the widget does not expose the unit
* for is converted with its own fixed unit, so the stored one is as stale a snapshot as
* the path — restoring the policy alone would leave the widget converting to a unit it
* no longer declares.
*/
it('takes a structural fixed path’s unit back from the defaults too', () => {
const merged = build(
{ paths: { p: { description: 'P', path: 'self.len', source: 'default', pathType: 'number', isPathConfigurable: false, convertUnitTo: 'm', showConvertUnitTo: false } } },
{ paths: { p: { description: 'P', path: 'self.len', source: 'default', pathType: 'number', isPathConfigurable: false, convertUnitTo: 'nm', showConvertUnitTo: false } } }
);
expect(merged?.paths?.['p'].convertUnitTo).toBe('m');
});

it('keeps the saved unit where the widget exposes it for editing', () => {
const merged = build(
{ paths: { p: { description: 'P', path: 'self.len', source: 'default', pathType: 'number', isPathConfigurable: false, convertUnitTo: 'm', showConvertUnitTo: true } } },
{ paths: { p: { description: 'P', path: 'self.len', source: 'default', pathType: 'number', isPathConfigurable: false, convertUnitTo: 'feet', showConvertUnitTo: true } } }
);
expect(merged?.paths?.['p'].convertUnitTo).toBe('feet');
});
});
62 changes: 62 additions & 0 deletions src/app/core/directives/widget-runtime.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export class WidgetRuntimeDirective {
let merged: IWidgetSvcConfig | undefined;
if (base && user) {
merged = merge(cloneDeep(base), cloneDeep(user));
restoreFixedPaths(merged, base);
dropRetiredWiring(merged, base);
} else if (base && !user) {
merged = cloneDeep(base);
} else if (!base && user) {
Expand Down Expand Up @@ -85,3 +87,63 @@ export class WidgetRuntimeDirective {
if (savedCfg) this._runtimeConfig.set(savedCfg);
}
}

/**
* Take every fixed path's wiring back from the widget's own defaults.
*
* A widget's saved config is a snapshot of the merged config at the moment it was placed
* on a dashboard, so it carries the Signal K path and value type of whatever release that
* was. For a path the user can edit, that snapshot is their choice and wins. For a path
* marked `isPathConfigurable: false` it is not a choice at all - it is the widget's
* wiring, frozen - and it pins the widget to that path forever: correcting a wrong path in
* the widget's defaults then reaches new widgets only, while every dashboard already using
* it stays broken with no way for the user to see why, since a fixed path is not shown in
* the options dialog.
*
* A path that offers `pathOptions` is excluded: those are not configurable free-form, but
* the stored value is still a choice the user made from the list.
*
* Only the wiring is restored. `source` is left as stored because the data source stays
* editable on a fixed path. `showConvertUnitTo` IS restored: it is not a user setting but
* the widget's decision about whether the path follows the server's unit preference or
* keeps the widget's own unit, and a widget that gets that wrong ships a value in the wrong
* scale.
*
* `convertUnitTo` follows that same decision. Where the widget exposes the unit
* (`showConvertUnitTo` not false) the stored one is the user's choice and stays. Where it
* does not, the path is structural - WidgetStreamsDirective reads exactly this flag to
* decide, and converts a structural path with the widget's own fixed unit rather than the
* server's preference - so the stored unit is not a choice either, just as stale a snapshot
* as the path itself, and restoring the flag without it would leave the widget converting
* to a unit it no longer declares.
*/
function restoreFixedPaths(merged: IWidgetSvcConfig, base: IWidgetSvcConfig): void {
if (!merged.paths || !base.paths) return;
for (const [key, basePath] of Object.entries(base.paths)) {
if (!basePath || basePath.isPathConfigurable !== false || basePath.pathOptions) continue;
const mergedPath = merged.paths[key];
if (!mergedPath) continue;
mergedPath.path = basePath.path;
mergedPath.pathType = basePath.pathType;
mergedPath.enableTimeout = basePath.enableTimeout;
mergedPath.showConvertUnitTo = basePath.showConvertUnitTo;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (basePath.showConvertUnitTo === false) mergedPath.convertUnitTo = basePath.convertUnitTo;
}
}

/**
* Widget-level settings that are the widget's own wiring rather than a user preference.
*
* A saved config is a snapshot of the merged config when the widget was placed, so it
* carries whatever these were then - and because the merge lets the saved value win, a
* widget that later drops one is stuck with it, still behaving as it did and still
* offering the setting in its options dialog. Dropping them when the widget's defaults
* no longer declare them is what lets a widget retire one.
*/
const RETIRED_WIRING_KEYS = ['enableTimeout', 'dataTimeout'] as const;

function dropRetiredWiring(merged: IWidgetSvcConfig, base: IWidgetSvcConfig): void {
for (const key of RETIRED_WIRING_KEYS) {
if (base[key] === undefined) delete merged[key];
}
}
48 changes: 48 additions & 0 deletions src/app/core/directives/widget-streams.directive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ function makeCfg(opts: {
suppressBootstrapNull?: boolean;
displayName?: string;
enableTimeout?: boolean;
/** The path's own TTL opt-in/opt-out, which overrides the widget-level flag. */
pathEnableTimeout?: boolean;
dataTimeout?: number;
} = {}): IWidgetSvcConfig {
const key = opts.key ?? 'p';
Expand All @@ -101,6 +103,7 @@ function makeCfg(opts: {
pathSkUnitsFilter: null,
convertUnitTo: (opts.convertUnitTo ?? undefined) as unknown as string,
showConvertUnitTo: opts.showConvertUnitTo,
enableTimeout: opts.pathEnableTimeout,
supportsPut: false
}
};
Expand Down Expand Up @@ -640,6 +643,40 @@ describe('WidgetStreamsDirective', () => {
expect(dataSvc.timeoutCalls[0]).toEqual({ path: 'env.to', source: 'default', pathType: 'string', dataTimeoutMs: 5000 });
});

/**
* The widget-level flag is not offered in the options dialog, so a widget that does
* not declare one has none — and a live reading that stops arriving would sit frozen
* on screen looking current. The path says so itself instead.
*/
it('times out a path that asks for it, on a widget with no timeout of its own', async () => {
vi.useFakeTimers();
vi.spyOn(console, 'log');
const cfg = makeCfg({
path: 'env.live', source: null, pathType: 'string', updateInterval: 100,
displayName: 'Test', enableTimeout: false, pathEnableTimeout: true
});
directive.setStreamsConfig(cfg);
directive.observe('p', () => { /* value not under test */ });

await vi.advanceTimersByTimeAsync(5100);
expect(dataSvc.timeoutCalls.length).toBe(1);
expect(dataSvc.timeoutCalls[0].path).toBe('env.live');
});

it('still lets a path opt out of a widget that does have one', async () => {
vi.useFakeTimers();
vi.spyOn(console, 'log');
const cfg = makeCfg({
path: 'env.state', source: null, pathType: 'string', updateInterval: 100,
displayName: 'Test', enableTimeout: true, pathEnableTimeout: false
});
directive.setStreamsConfig(cfg);
directive.observe('p', () => { /* value not under test */ });

await vi.advanceTimersByTimeAsync(5100);
expect(dataSvc.timeoutCalls.length).toBe(0);
});

it('forwards a configured non-default source into timeoutPathObservable', async () => {
vi.useFakeTimers();
vi.spyOn(console, 'log');
Expand Down Expand Up @@ -1400,6 +1437,17 @@ describe('widgetPathSignature', () => {
expect(widgetPathSignature({ ...base, suppressBootstrapNull: false })).not.toBe(sig);
});

/**
* An omitted per-path timeout defers to the widget-level flag, so it is a third setting,
* not another spelling of `true`: flipping between any two has to rebuild the pipeline.
*/
it('separates a per-path timeout that is on, off, or left to the widget', () => {
const on = widgetPathSignature({ ...base, enableTimeout: true });
const off = widgetPathSignature({ ...base, enableTimeout: false });
const omitted = widgetPathSignature(base);
expect(new Set([on, off, omitted]).size).toBe(3);
});

it('normalizeWidgetPath yields undefined for anything that is not a usable path', () => {
expect(normalizeWidgetPath(' a.b ')).toBe('a.b');
expect(normalizeWidgetPath('')).toBeUndefined();
Expand Down
25 changes: 21 additions & 4 deletions src/app/core/directives/widget-streams.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface IPathIdentity {
convertUnitTo?: string | null;
source?: string | null;
suppressBootstrapNull?: boolean;
enableTimeout?: boolean;
}

/**
Expand Down Expand Up @@ -46,7 +47,12 @@ export function widgetPathSignature(pathCfg: IPathIdentity | undefined | null):
const normalizedPath = normalizeWidgetPath(pathCfg?.path);
if (!pathCfg || !normalizedPath) return null;
const src = (pathCfg.source?.trim() || 'default');
return [normalizedPath, pathCfg.pathType, pathCfg.convertUnitTo, src, pathCfg.suppressBootstrapNull ? '1' : '0'].join('|');
// All three timeout settings differ: an omitted one defers to the widget-level flag, so
// omitted and `true` are not the same subscription. Omitted stays '' so the signature of
// every path that does not set it is unchanged.
const timeout = pathCfg.enableTimeout === false ? 'nott' : pathCfg.enableTimeout === true ? 'tt' : '';
return [normalizedPath, pathCfg.pathType, pathCfg.convertUnitTo, src, pathCfg.suppressBootstrapNull ? '1' : '0',
timeout].join('|');
}

/**
Expand Down Expand Up @@ -154,7 +160,7 @@ export class WidgetStreamsDirective implements OnDestroy {
};
}

private computePathSignature(pathCfg: { path: string; pathType: string; convertUnitTo?: string; source?: string; suppressBootstrapNull?: boolean }): string {
private computePathSignature(pathCfg: { path: string; pathType: string; convertUnitTo?: string; source?: string; suppressBootstrapNull?: boolean; enableTimeout?: boolean }): string {
return widgetPathSignature(pathCfg) ?? '';
}

Expand Down Expand Up @@ -200,7 +206,7 @@ export class WidgetStreamsDirective implements OnDestroy {
}

/** Create (or reuse) base observable, assemble pipeline, and subscribe with diff-aware replacement. */
private buildAndSubscribe(pathName: string, next: (value: IPathUpdate) => void, cfg: IWidgetSvcConfig, pathCfg: { path: string; pathType: string; convertUnitTo?: string; showConvertUnitTo?: boolean; source?: string; suppressBootstrapNull?: boolean }, observePointer?: string): void {
private buildAndSubscribe(pathName: string, next: (value: IPathUpdate) => void, cfg: IWidgetSvcConfig, pathCfg: { path: string; pathType: string; convertUnitTo?: string; showConvertUnitTo?: boolean; source?: string; suppressBootstrapNull?: boolean; enableTimeout?: boolean }, observePointer?: string): void {
// The same test normalizeWidgetPath applies, kept as a split for its base path and pointer.
const split = splitPointerPath(pathCfg.path);
if (!split.valid || !split.basePath) {
Expand Down Expand Up @@ -236,7 +242,18 @@ export class WidgetStreamsDirective implements OnDestroy {
}
const base$ = this.streams!.get(pathName)!;

const enableTimeout = !!cfg.enableTimeout;
// A path may opt in or out of the stale-data TTL on its own, over whatever the widget
// says. The TTL assumes a path is fed continuously and nulls it when it goes quiet,
// which is right for a live reading and wrong for state: a start line is published when
// it changes and then not again, so the TTL erases a perfectly good line five seconds
// after it arrives.
//
// The per-path `true` is what a widget with no widget-level flag uses to keep its live
// readings honest - `enableTimeout` is not offered in the options dialog, so a widget
// that does not declare one has none, and a frozen reading would otherwise sit there
// looking live. Per-path `false` still wins over a widget-level `true`.
const enableTimeout = pathCfg.enableTimeout !== false
&& (pathCfg.enableTimeout === true || !!cfg.enableTimeout);
const dataTimeout = FIXED_DATA_TIMEOUT_MS;
const retryDelay = 5000;
const timeoutErrorMsg = `[Widget] ${cfg.displayName} - ${dataTimeout / 1000} second data update timeout reached for `;
Expand Down
Loading
Loading