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
45 changes: 41 additions & 4 deletions src/app/core/services/dashboard.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ describe('DashboardService', () => {
expect(dashboards[0].name).toBe('Page 1');
expect(dashboards[0].id).toMatch(UUID_PATTERN);
expect(widgetsOf(dashboards[0])[0].input.widgetProperties.type).toBe('widget-tutorial');
// Pins today's aliasing: the blank dashboard shares its configuration array with the DefaultDashboard constant.
expect(dashboards[0].configuration).toBe(DefaultDashboard[0].configuration);
// The blank dashboard deep-clones the DefaultDashboard constant so later in-place edits cannot corrupt the shared default.
expect(dashboards[0].configuration).not.toBe(DefaultDashboard[0].configuration);
expect(widgetsOf(dashboards[0])[0]).not.toBe(DefaultDashboard[0].configuration![0]);
expect(dashboards[0].configuration).toEqual(DefaultDashboard[0].configuration);
expect(consoleWarn).toHaveBeenCalled();
});

Expand Down Expand Up @@ -165,6 +167,13 @@ describe('DashboardService', () => {
expect(service.activeDashboard()).toBe(1);
expect(consoleError).toHaveBeenCalledTimes(2);
});

it('rejects a fractional index instead of activating a non-integer dashboard', () => {
service.setActiveDashboardIndex(1);
service.setActiveDashboardIndex(1.5);
expect(service.activeDashboard()).toBe(1);
expect(consoleError).toHaveBeenCalledTimes(1);
});
});

describe('add and update', () => {
Expand Down Expand Up @@ -194,13 +203,41 @@ describe('DashboardService', () => {
});

describe('delete', () => {
it('removes the dashboard without remapping the active index', () => {
it('shifts the active index down so it follows the same dashboard when a lower one is removed', () => {
setup();
service.setActiveDashboardIndex(1);
service.delete(0);
expect(service.dashboards().map(d => d.id)).toEqual(['d-1', 'd-2']);
// Pins today's bookkeeping: the index is not shifted down, so it now points at the former third dashboard.
// The active dashboard (d-1) shifts from index 1 to index 0; the active index follows it.
expect(service.activeDashboard()).toBe(0);
expect(service.dashboards()[service.activeDashboard()!].id).toBe('d-1');
});

it('leaves the active index unchanged when a higher-indexed dashboard is removed', () => {
setup();
service.setActiveDashboardIndex(1);
service.delete(2);
expect(service.activeDashboard()).toBe(1);
expect(service.dashboards()[service.activeDashboard()!].id).toBe('d-1');
});

it('keeps the active index on the dashboard that slides up when the active, non-last one is deleted', () => {
setup();
service.setActiveDashboardIndex(1);
service.delete(1);
expect(service.dashboards().map(d => d.id)).toEqual(['d-0', 'd-2']);
expect(service.activeDashboard()).toBe(1);
expect(service.dashboards()[service.activeDashboard()!].id).toBe('d-2');
});

it('ignores an out-of-range index without touching the active dashboard', () => {
setup();
service.setActiveDashboardIndex(0);
service.delete(-1);
service.delete(5);
expect(service.dashboards().map(d => d.id)).toEqual(['d-0', 'd-1', 'd-2']);
expect(service.activeDashboard()).toBe(0);
expect(consoleError).toHaveBeenCalledTimes(2);
});

it('clamps the active index when it falls past the end', () => {
Expand Down
24 changes: 18 additions & 6 deletions src/app/core/services/dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class DashboardService {
if (!dashboardsConfig || dashboardsConfig.length === 0) {
console.warn('[Dashboard Service] No dashboards found in settings, creating blank dashboard');
const newBlankDashboard = DefaultDashboard.map(dashboard => ({
...dashboard,
...cloneDeep(dashboard),
id: UUID.create()
}));
this.dashboards.set([...newBlankDashboard]);
Expand Down Expand Up @@ -122,7 +122,7 @@ export class DashboardService {
if (itemIndex === this.activeDashboard()) return;
// No change if the same dashboard is selected to prevent unnecessary cascading updates

if (itemIndex >= 0 && itemIndex < this.dashboards().length) {
if (Number.isInteger(itemIndex) && itemIndex >= 0 && itemIndex < this.dashboards().length) {
this.activeDashboard.set(itemIndex);
} else {
console.error(`[Dashboard Service] Invalid dashboard ID: ${itemIndex}`);
Expand Down Expand Up @@ -180,14 +180,26 @@ export class DashboardService {
* @param itemIndex The index of the dashboard to delete.
*/
public delete(itemIndex: number): void {
if (itemIndex < 0 || itemIndex >= this.dashboards().length) {
console.error(`[Dashboard Service] Invalid dashboard ID: ${itemIndex}`);
return;
}

const active = this.activeDashboard();
this.dashboards.update(dashboards => dashboards.filter((_, i) => i !== itemIndex));

if (this.dashboards().length === 0) {
this.add('Page ' + (this.dashboards().length + 1), []);
this.activeDashboard.set(0);
} else if (this.activeDashboard() > this.dashboards().length - 1) {
this.activeDashboard.set(this.dashboards().length - 1);
return;
}

if (active === null) return;

// Deleting a dashboard below the active one shifts everything down; follow the
// active dashboard to its new index, then clamp if the active one was the last.
const shifted = itemIndex < active ? active - 1 : active;
this.activeDashboard.set(Math.min(shifted, this.dashboards().length - 1));
}

/**
Expand Down Expand Up @@ -229,11 +241,11 @@ export class DashboardService {
widget.id = UUID.create();
widget.input.widgetProperties.uuid = widget.id;
} else {
console.error("Dashboard Service] Widget configuration is missing required properties:", widget);
console.error("[Dashboard Service] Widget configuration is missing required properties:", widget);
}
});
} else {
console.error("Dashboard Service] Dashboard configuration is not an array:", newDashboard.configuration);
console.error("[Dashboard Service] Dashboard configuration is not an array:", newDashboard.configuration);
newDashboard.configuration = [];
}

Expand Down
28 changes: 28 additions & 0 deletions src/app/core/services/data.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,34 @@ describe('DataService', () => {
expect(latest!.state).toBe(States.Normal);
});

it('resets every source registration for a path on timeout, not just the first', () => {
let latestDefault: IPathUpdate | undefined;
let latestSource: IPathUpdate | undefined;
service
.subscribePath('self.electrical.batteries.10.voltage', 'default')
.subscribe(update => (latestDefault = update));
service
.subscribePath('self.electrical.batteries.10.voltage', 'test-source')
.subscribe(update => (latestSource = update));

dataPathUpdates$.next({
context: 'self',
path: 'electrical.batteries.10.voltage',
source: 'test-source',
timestamp: '2026-01-01T00:00:01.000Z',
value: 12.5,
});
expect(latestDefault!.data.value).toBe(12.5);
expect(latestSource!.data.value).toBe(12.5);

service.timeoutPathObservable('self.electrical.batteries.10.voltage', 'number');

// Both the default bucket and the per-source registration must clear on timeout.
expect(latestDefault!.data.value).toBeNull();
expect(latestSource!.data.value).toBeNull();
expect(latestSource!.state).toBe(States.Normal);
});

it('does not emit on timeout for an unrecognised pathType', () => {
const updates: IPathUpdate[] = [];
service
Expand Down
8 changes: 5 additions & 3 deletions src/app/core/services/data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,9 +716,11 @@ export class DataService implements OnDestroy {
* @memberof SignalKDataService
*/
public timeoutPathObservable(path: string, pathType: string): void {
const pathRegister = this._pathRegister.find(item => item.path == path);
if (pathRegister && ['string', 'Date', 'number', 'multiple'].includes(pathType)) {
pathRegister.pathDataUpdate$.next({
if (!['string', 'Date', 'number', 'multiple'].includes(pathType)) return;
const registrations = this._pathRegisterByPath.get(path);
if (!registrations) return;
for (const registration of registrations) {
registration.pathDataUpdate$.next({
data: {
value: null,
timestamp: null
Expand Down
37 changes: 33 additions & 4 deletions src/app/core/services/signalk-requests.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('SignalkRequestsService', () => {
});

it.each<[number, string]>([
[200, 'The request was successfully.'],
[200, 'The request was successful.'],
[401, 'Login failed. Your User ID or Password is incorrect.'],
[403, 'DENIED: Authorization with R/W or Admin permission level is required to send commands. Configure Sign In credential.'],
[405, 'The server does not support the request.'],
Expand Down Expand Up @@ -131,16 +131,45 @@ describe('SignalkRequestsService', () => {
expect(received[0].statusCode).toBe(200);
});

it('reports a described but unhandled status code (500) as unknown yet still dispatches it', () => {
it.each<[number, string]>([
[500, 'The request failed.'],
[502, 'Something went wrong carrying out the request on the server.'],
[504, 'Timeout on the server side trying to carry out the request.'],
])('surfaces a %i server error as an error toast with its description and dispatches it', (statusCode, description) => {
const received: skRequest[] = [];
service.subscribeRequest().subscribe(r => received.push(r));

const requestId = service.putRequest('navigation.lights', true, 'widget-1')!;
requestUpdates$.next({ requestId, state: 'COMPLETED', statusCode, message: 'disk full' });

expect(toastShow).toHaveBeenCalledWith(`${description} - disk full`, 0, false, 'error');
expect(received).toHaveLength(1);
expect(received[0].statusCode).toBe(statusCode);
expect(received[0].statusCodeDescription).toBe(description);
});

it('shows the bare description for a server error that carries no message', () => {
const received: skRequest[] = [];
service.subscribeRequest().subscribe(r => received.push(r));

const requestId = service.putRequest('navigation.lights', true, 'widget-1')!;
requestUpdates$.next({ requestId, state: 'COMPLETED', statusCode: 502 });

expect(toastShow).toHaveBeenCalledWith('Something went wrong carrying out the request on the server.', 0, false, 'error');
expect(received).toHaveLength(1);
expect(received[0].statusCodeDescription).toBe('Something went wrong carrying out the request on the server.');
});

it('reports a truly unknown status code as unknown yet still dispatches it', () => {
const received: skRequest[] = [];
service.subscribeRequest().subscribe(r => received.push(r));

const requestId = service.putRequest('navigation.lights', true, 'widget-1')!;
requestUpdates$.next({ requestId, state: 'COMPLETED', statusCode: 500 });
requestUpdates$.next({ requestId, state: 'COMPLETED', statusCode: 418 });

expect(toastShow).toHaveBeenCalledWith(expect.stringContaining('Unknown Request Status Code'), 0, false, 'error');
expect(received).toHaveLength(1);
expect(received[0].statusCode).toBe(500);
expect(received[0].statusCode).toBe(418);
expect(received[0].statusCodeDescription).toBeUndefined();
});

Expand Down
13 changes: 11 additions & 2 deletions src/app/core/services/signalk-requests.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { UUID } from '../utils/uuid.util'
import { ToastService } from './toast.service';

const deltaStatusCodes: Record<number, string> = {
200: "The request was successfully.",
200: "The request was successful.",
202: "Request accepted and pending completion.",
400: "Something is wrong with the client's request.",
401: "Login failed. Your User ID or Password is incorrect.",
Expand All @@ -19,7 +19,9 @@ const deltaStatusCodes: Record<number, string> = {
504: "Timeout on the server side trying to carry out the request."
}
// Codes dispatched as recognized responses; anything else raises the unknown-status error toast.
const handledStatusCodes = new Set([200, 202, 400, 401, 403, 405]);
const handledStatusCodes = new Set([200, 202, 400, 401, 403, 405, 500, 502, 504]);
// Server-side failures surfaced to the user as an error toast carrying their description.
const serverErrorStatusCodes = new Set([500, 502, 504]);
export interface skRequest {
requestId: string;
state: string;
Expand Down Expand Up @@ -132,6 +134,13 @@ export class SignalkRequestsService {
console.error("[Request Service] Status Code: " + this.requests[index].statusCode + " - " + this.requests[index].message);
}

if (serverErrorStatusCodes.has(this.requests[index].statusCode)) {
const description = this.requests[index].statusCodeDescription;
const detail = this.requests[index].message ? description + " - " + this.requests[index].message : description;
this.toast.show(detail, 0, false, 'error');
console.error("[Request Service] Status Code: " + this.requests[index].statusCode + " - " + description);
}

} else {
this.toast.show("Unknown Request Status Code received: " + this.requests[index].statusCode + " - " + deltaStatusCodes[this.requests[index].statusCode] + " - " + this.requests[index].message, 0, false, 'error');
console.error("[Request Service] Unknown Request Status Code received: " + this.requests[index].statusCode + " - " + deltaStatusCodes[this.requests[index].statusCode] + " - " + this.requests[index].message);
Expand Down
Loading