Add comprehensive unit tests for connectivity relays and machine store:

- Implemented tests for self-serve connectivity relay API wrappers, covering status retrieval, set operations, and lane commands.
- Added tests for the machine connectivity store, including relay toggling, polling, force operations, and error handling.
- Refactored relay functionality to improve maintainability and testability, introducing helper functions for lane and relay commands.
- Updated machine and connectivity-related components to align with new relay definitions and shared store logic.
This commit is contained in:
Jeppe Bundgaard
2026-03-25 13:20:51 +01:00
parent de28f2cd49
commit 620c4f0859
11 changed files with 1361 additions and 178 deletions
@@ -0,0 +1,33 @@
<script setup>
import SelfServeMachine from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeMachine.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import {
usePaginatedList,
PaginatedListKey
} from "@/components/pagination/paginatedList.vue";
import { provide } from "vue";
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
list,
loadList,
setEndpoint,
} = paginatedList;
setEndpoint("/department/lanes", false);
loadList();
</script>
<template>
<TableLabeledPagination :label="'Department Self-Serve Lanes'">
<div class="columns is-multiline">
<template v-for="machine in list" :key="machine.id">
<div class="column is-one-third">
<SelfServeMachine :machine="machine" />
</div>
</template>
</div>
</TableLabeledPagination>
</template>
@@ -3,30 +3,12 @@ import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import SelfServeMachine from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeMachine.vue";
import type { MachineStatus } from "@/views/dashboards/superUserDashboard/selfserve/types/MachineStatusType.vue";
type Machine = {
id: number;
name: string;
status: typeof MachineStatus[keyof typeof MachineStatus];
};
const machines = [
{ id: 1, name: "Machine A", status: 'OFFLINE' },
{ id: 3, name: "Machine C", status: 'MAINTENANCE' },
{ id: 2, name: "Machine B", status: 'ONLINE' },
] as Machine[];
import DepartmentSelfServePagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentSelfServePagination.vue";
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<SuperUserDashboardNavigation />
<p>Dept. self-serve</p>
<div class="columns is-multiline">
<template v-for="machine in machines" :key="machine.id">
<div class="column is-one-third">
<SelfServeMachine :machine="machine" />
</div>
</template>
</div>
<DepartmentSelfServePagination />
</RestrictedPageWrapper>
</template>
@@ -1,77 +1,203 @@
<script lang="ts">
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
export const relayKinds = {
MACHINE: "MACHINE",
PROGRAM_PICKER: "PROGRAM_PICKER",
CLEANER: "CLEANER",
} as const;
export type RelayKind = keyof typeof relayKinds;
export type RelayStatus = {
lane_id: number;
relay: string;
relay_id: string;
online: boolean;
on: boolean;
};
export type LaneCommand = "START" | "STOP" | "RESET" | "RESERVE" | "RELEASE";
export type LaneCommandPayload = {
customer_number?: number | null;
license_plate?: string | null;
};
const relayStatusGetters: Record<RelayKind, (laneId: number) => Promise<any>> = {
MACHINE: (laneId: number) => SessionUser.request(
"/modules/self-serve/lane/relay/machine/status",
"GET",
{
lane_id: laneId,
}
),
PROGRAM_PICKER: (laneId: number) => SessionUser.request(
"/modules/self-serve/lane/relay/machine_program_picker/status",
"GET",
{
lane_id: laneId,
}
),
CLEANER: (laneId: number) => SessionUser.request(
"/modules/self-serve/lane/relay/machine_cleaner/status",
"GET",
{
lane_id: laneId,
}
),
};
const relaySetters: Record<RelayKind, (laneId: number, on: boolean) => Promise<any>> = {
MACHINE: (laneId: number, on: boolean) => SessionUser.request(
"/modules/self-serve/lane/relay/machine/set",
"POST",
{
lane_id: laneId,
on,
}
),
PROGRAM_PICKER: (laneId: number, on: boolean) => SessionUser.request(
"/modules/self-serve/lane/relay/machine_program_picker/set",
"POST",
{
lane_id: laneId,
on,
}
),
CLEANER: (laneId: number, on: boolean) => SessionUser.request(
"/modules/self-serve/lane/relay/machine_cleaner/set",
"POST",
{
lane_id: laneId,
on,
}
),
};
const normalizeLaneId = (laneId: number): number => {
const parsedLaneId = Number.parseInt(String(laneId), 10);
if (!Number.isInteger(parsedLaneId) || parsedLaneId <= 0) {
throw new Error(`Invalid lane id: ${laneId}`);
}
return parsedLaneId;
};
const normalizeLicensePlate = (licensePlate: string | null = null): string | null => {
if (typeof licensePlate !== "string") {
return null;
}
const normalized = licensePlate.trim().toUpperCase();
return normalized.length > 0 ? normalized : null;
};
const executeLaneCommand = (
laneId: number,
command: LaneCommand,
payload: LaneCommandPayload = {}
) => SessionUser.request(
"/modules/self-serve/lane/command",
"POST",
{
lane_id: normalizeLaneId(laneId),
command,
...payload,
}
);
/**
* Get relay status
* GET /modules/self-serve/lane/relay/machine/status
* POST /modules/self-serve/lane/relay/machine/set (with lane_id + on)
*/
export const relays = {
get: {
status: {
/**
* Get machine relay status
* Example response:
* {
* "lane_id": 3,
* "relay": "MACHINE",
* "relay_id": "e4b3231cce40",
* "online": true,
* "on": true
* }
* @param laneId
* @constructor
*/
MACHINE: (laneId: number) => SessionUser.request(
"/modules/self-serve/lane/relay/machine/status",
"GET",
{
lane_id: laneId
}
),
PROGRAM_PICKER: (laneId: number) => SessionUser.request(
"/modules/self-serve/lane/relay/machine_program_picker/status",
"GET",
{
lane_id: laneId
}
),
CLEANER: (laneId: number) => SessionUser.request(
"/modules/self-serve/lane/relay/machine_cleaner/status",
"GET",
{
lane_id: laneId
}
)
}
...relayStatusGetters,
all: async (laneId: number) => {
const normalizedLaneId = normalizeLaneId(laneId);
const [machine, programPicker, cleaner] = await Promise.all([
relayStatusGetters.MACHINE(normalizedLaneId),
relayStatusGetters.PROGRAM_PICKER(normalizedLaneId),
relayStatusGetters.CLEANER(normalizedLaneId),
]);
return {
MACHINE: machine,
PROGRAM_PICKER: programPicker,
CLEANER: cleaner,
};
},
},
},
set: {
MACHINE: (laneId: number, on: boolean) => SessionUser.request(
"/modules/self-serve/lane/relay/machine/set",
"POST",
{
lane_id: laneId,
on: on
}
),
PROGRAM_PICKER: (laneId: number, on: boolean) => SessionUser.request(
"/modules/self-serve/lane/relay/machine_program_picker/set",
"POST",
{
lane_id: laneId,
on: on
}
),
CLEANER: (laneId: number, on: boolean) => SessionUser.request(
"/modules/self-serve/lane/relay/machine_cleaner/set",
"POST",
{
lane_id: laneId,
on: on
}
)
}
}
...relaySetters,
byRelay: (relay: RelayKind, laneId: number, on: boolean) => relaySetters[relay](laneId, on),
},
constants: relayKinds,
};
export default { relays }
</script>
export const lane = {
get: {
status: (laneId?: number) => SessionUser.request(
"/modules/self-serve/lane/status",
"GET",
laneId ? { lane_id: normalizeLaneId(laneId) } : {}
),
},
command: {
execute: executeLaneCommand,
START: (laneId: number, payload: LaneCommandPayload = {}) => executeLaneCommand(laneId, "START", payload),
STOP: (laneId: number, payload: LaneCommandPayload = {}) => executeLaneCommand(laneId, "STOP", payload),
RESET: (laneId: number, payload: LaneCommandPayload = {}) => executeLaneCommand(laneId, "RESET", payload),
RESERVE: (laneId: number, payload: LaneCommandPayload = {}) => executeLaneCommand(laneId, "RESERVE", payload),
RELEASE: (laneId: number, payload: LaneCommandPayload = {}) => executeLaneCommand(laneId, "RELEASE", payload),
},
force: {
machine: {
enable: (
laneId: number,
options: {
duration?: number | null;
licensePlate?: string | null;
} = {}
) => {
const payload: {
lane_id: number;
duration?: number;
license_plate?: string;
} = {
lane_id: normalizeLaneId(laneId),
};
if (typeof options.duration === "number" && Number.isFinite(options.duration)) {
payload.duration = Math.max(0, Math.round(options.duration));
}
const normalizedLicensePlate = normalizeLicensePlate(options.licensePlate ?? null);
if (normalizedLicensePlate) {
payload.license_plate = normalizedLicensePlate;
}
return SessionUser.request(
"/modules/self-serve/lane/force/machine/enable",
"POST",
payload
);
},
disable: (laneId: number, licensePlate: string | null = null) => {
const payload: {
lane_id: number;
license_plate?: string;
} = {
lane_id: normalizeLaneId(laneId),
};
const normalizedLicensePlate = normalizeLicensePlate(licensePlate);
if (normalizedLicensePlate) {
payload.license_plate = normalizedLicensePlate;
}
return SessionUser.request(
"/modules/self-serve/lane/force/machine/disable",
"POST",
payload
);
},
},
},
};
export default { relays, lane, relayKinds }
</script>
@@ -1,7 +1,492 @@
<script lang="ts">
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { relays } from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeConnectivityRelay.vue";
import { computed, reactive } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
lane,
relayKinds,
relays,
type LaneCommand,
type LaneCommandPayload,
type RelayKind,
type RelayStatus,
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeConnectivityRelay.vue";
export { relays };
export default { relays }
</script>
export { lane, relayKinds, relays };
type RelayStatusMap = Record<RelayKind, RelayStatus | null>;
type RelayLoadingMap = Record<RelayKind, boolean>;
type LaneCommandLoadingState = {
command: boolean;
forceEnable: boolean;
forceDisable: boolean;
};
type RelayDisplayStatus = "ON" | "OFF" | "OFFLINE" | "MAINTENANCE";
type ForceEnableOptions = {
duration?: number | null;
licensePlate?: string | null;
};
const DEFAULT_POLL_INTERVAL_MS = 7000;
const laneRelayStatuses = reactive<Record<number, RelayStatusMap>>({});
const laneRelayLoading = reactive<Record<number, RelayLoadingMap>>({});
const laneCommandLoading = reactive<Record<number, LaneCommandLoadingState>>({});
const laneErrors = reactive<Record<number, string | null>>({});
const laneLastUpdatedAt = reactive<Record<number, number | null>>({});
const lanePollSubscribers = reactive<Record<number, number>>({});
const lanePollIntervals = new Map<number, ReturnType<typeof setInterval>>();
const createInitialRelayStatusMap = (): RelayStatusMap => ({
MACHINE: null,
PROGRAM_PICKER: null,
CLEANER: null,
});
const createInitialRelayLoadingMap = (): RelayLoadingMap => ({
MACHINE: false,
PROGRAM_PICKER: false,
CLEANER: false,
});
const createInitialCommandLoadingState = (): LaneCommandLoadingState => ({
command: false,
forceEnable: false,
forceDisable: false,
});
const toLaneId = (laneId: number): number => {
const parsedLaneId = Number.parseInt(String(laneId), 10);
if (!Number.isInteger(parsedLaneId) || parsedLaneId <= 0) {
throw new Error(`Invalid lane id: ${laneId}`);
}
return parsedLaneId;
};
const ensureLaneState = (laneId: number): void => {
if (!laneRelayStatuses[laneId]) {
laneRelayStatuses[laneId] = createInitialRelayStatusMap();
}
if (!laneRelayLoading[laneId]) {
laneRelayLoading[laneId] = createInitialRelayLoadingMap();
}
if (!laneCommandLoading[laneId]) {
laneCommandLoading[laneId] = createInitialCommandLoadingState();
}
if (!Object.prototype.hasOwnProperty.call(laneErrors, laneId)) {
laneErrors[laneId] = null;
}
if (!Object.prototype.hasOwnProperty.call(laneLastUpdatedAt, laneId)) {
laneLastUpdatedAt[laneId] = null;
}
if (!Object.prototype.hasOwnProperty.call(lanePollSubscribers, laneId)) {
lanePollSubscribers[laneId] = 0;
}
};
const parseErrorMessage = (error: unknown): string => {
try {
if (SessionUser?.functions?.parseErrorMessage) {
const parsed = SessionUser.functions.parseErrorMessage(error as any);
if (typeof parsed === "string" && parsed.trim().length > 0) {
return parsed;
}
if (parsed !== undefined && parsed !== null) {
return String(parsed);
}
}
} catch (parsingError) {
console.error("Failed to parse SessionUser error message", parsingError);
}
if (error instanceof Error && error.message) {
return error.message;
}
return "Unknown connectivity error";
};
const extractPayload = (response: any): any => {
if (!response || typeof response !== "object") {
return response;
}
const responseData = response.data;
if (!responseData || typeof responseData !== "object") {
return responseData ?? response;
}
if (Object.prototype.hasOwnProperty.call(responseData, "data")) {
return responseData.data ?? responseData;
}
return responseData;
};
const normalizeRelayStatus = (
laneId: number,
relay: RelayKind,
payload: unknown
): RelayStatus | null => {
if (!payload || typeof payload !== "object") {
return null;
}
const source = payload as Partial<RelayStatus> & Record<string, unknown>;
const hasRelayState =
Object.prototype.hasOwnProperty.call(source, "online")
|| Object.prototype.hasOwnProperty.call(source, "on");
if (!hasRelayState) {
return null;
}
const relayId = typeof source.relay_id === "string" ? source.relay_id : "";
const relayType = typeof source.relay === "string" ? source.relay : relay;
const lane = Number.isFinite(Number(source.lane_id)) ? Number(source.lane_id) : laneId;
return {
lane_id: lane,
relay: relayType,
relay_id: relayId,
online: Boolean(source.online),
on: Boolean(source.on),
};
};
const relayStatusGetters: Record<RelayKind, (laneId: number) => Promise<any>> = {
MACHINE: relays.get.status.MACHINE,
PROGRAM_PICKER: relays.get.status.PROGRAM_PICKER,
CLEANER: relays.get.status.CLEANER,
};
const relaySetters: Record<RelayKind, (laneId: number, on: boolean) => Promise<any>> = {
MACHINE: relays.set.MACHINE,
PROGRAM_PICKER: relays.set.PROGRAM_PICKER,
CLEANER: relays.set.CLEANER,
};
const RELAY_ORDER: RelayKind[] = [
relayKinds.MACHINE,
relayKinds.PROGRAM_PICKER,
relayKinds.CLEANER,
];
const updateLaneRelayStatus = (
laneId: number,
relay: RelayKind,
payload: unknown
): RelayStatus | null => {
const normalized = normalizeRelayStatus(laneId, relay, payload);
laneRelayStatuses[laneId][relay] = normalized;
return normalized;
};
const updateLaneLastUpdatedAt = (laneId: number): void => {
laneLastUpdatedAt[laneId] = Date.now();
};
export const parseRelayStatus = (status: RelayStatus | null): RelayDisplayStatus => {
if (!status) {
return "MAINTENANCE";
}
if (!status.online) {
return "OFFLINE";
}
return status.on ? "ON" : "OFF";
};
export const fetchRelayStatus = async (
laneId: number,
relay: RelayKind
): Promise<RelayStatus | null> => {
const normalizedLaneId = toLaneId(laneId);
ensureLaneState(normalizedLaneId);
laneRelayLoading[normalizedLaneId][relay] = true;
try {
const response = await relayStatusGetters[relay](normalizedLaneId);
const status = updateLaneRelayStatus(
normalizedLaneId,
relay,
extractPayload(response)
);
laneErrors[normalizedLaneId] = null;
updateLaneLastUpdatedAt(normalizedLaneId);
return status;
} catch (error) {
laneErrors[normalizedLaneId] = parseErrorMessage(error);
throw error;
} finally {
laneRelayLoading[normalizedLaneId][relay] = false;
}
};
export const fetchAllRelayStatuses = async (
laneId: number
): Promise<RelayStatusMap> => {
const normalizedLaneId = toLaneId(laneId);
ensureLaneState(normalizedLaneId);
RELAY_ORDER.forEach((relay) => {
laneRelayLoading[normalizedLaneId][relay] = true;
});
const results = await Promise.allSettled(
RELAY_ORDER.map((relay) => relayStatusGetters[relay](normalizedLaneId))
);
let successfulResponses = 0;
let firstError: unknown = null;
results.forEach((result, index) => {
const relay = RELAY_ORDER[index];
if (result.status === "fulfilled") {
updateLaneRelayStatus(normalizedLaneId, relay, extractPayload(result.value));
successfulResponses += 1;
return;
}
if (!firstError) {
firstError = result.reason;
}
});
RELAY_ORDER.forEach((relay) => {
laneRelayLoading[normalizedLaneId][relay] = false;
});
if (successfulResponses > 0) {
laneErrors[normalizedLaneId] = firstError ? parseErrorMessage(firstError) : null;
updateLaneLastUpdatedAt(normalizedLaneId);
return laneRelayStatuses[normalizedLaneId];
}
laneErrors[normalizedLaneId] = parseErrorMessage(firstError);
throw firstError ?? new Error(`Failed to fetch relay statuses for lane ${normalizedLaneId}`);
};
export const setRelayState = async (
laneId: number,
relay: RelayKind,
on: boolean
): Promise<RelayStatus | null> => {
const normalizedLaneId = toLaneId(laneId);
ensureLaneState(normalizedLaneId);
laneRelayLoading[normalizedLaneId][relay] = true;
try {
const response = await relaySetters[relay](normalizedLaneId, on);
const payload = extractPayload(response);
const status = updateLaneRelayStatus(normalizedLaneId, relay, payload);
if (!status) {
// Some endpoints only return a success envelope; refresh the relay state after write.
await fetchRelayStatus(normalizedLaneId, relay);
} else {
updateLaneLastUpdatedAt(normalizedLaneId);
}
laneErrors[normalizedLaneId] = null;
return laneRelayStatuses[normalizedLaneId][relay];
} catch (error) {
laneErrors[normalizedLaneId] = parseErrorMessage(error);
throw error;
} finally {
laneRelayLoading[normalizedLaneId][relay] = false;
}
};
type CommandLoadingKey = keyof LaneCommandLoadingState;
const runLaneAction = async <T>(
laneId: number,
loadingKey: CommandLoadingKey,
action: () => Promise<T>
): Promise<T> => {
ensureLaneState(laneId);
laneCommandLoading[laneId][loadingKey] = true;
try {
const result = await action();
laneErrors[laneId] = null;
return result;
} catch (error) {
laneErrors[laneId] = parseErrorMessage(error);
throw error;
} finally {
laneCommandLoading[laneId][loadingKey] = false;
}
};
export const executeLaneCommand = async (
laneId: number,
command: LaneCommand,
payload: LaneCommandPayload = {}
): Promise<any> => {
const normalizedLaneId = toLaneId(laneId);
return runLaneAction(normalizedLaneId, "command", async () => {
const response = await lane.command.execute(normalizedLaneId, command, payload);
return extractPayload(response);
});
};
export const forceEnableMachine = async (
laneId: number,
options: ForceEnableOptions = {}
): Promise<any> => {
const normalizedLaneId = toLaneId(laneId);
return runLaneAction(normalizedLaneId, "forceEnable", async () => {
const response = await lane.force.machine.enable(normalizedLaneId, options);
return extractPayload(response);
});
};
export const forceDisableMachine = async (
laneId: number,
licensePlate: string | null = null
): Promise<any> => {
const normalizedLaneId = toLaneId(laneId);
return runLaneAction(normalizedLaneId, "forceDisable", async () => {
const response = await lane.force.machine.disable(normalizedLaneId, licensePlate);
return extractPayload(response);
});
};
export const stopWash = (laneId: number): Promise<any> => executeLaneCommand(laneId, "STOP");
export const startPolling = async (
laneId: number,
intervalMs = DEFAULT_POLL_INTERVAL_MS
): Promise<void> => {
const normalizedLaneId = toLaneId(laneId);
ensureLaneState(normalizedLaneId);
lanePollSubscribers[normalizedLaneId] += 1;
if (lanePollIntervals.has(normalizedLaneId)) {
return;
}
const pollInterval = Math.max(1000, Math.round(intervalMs));
await fetchAllRelayStatuses(normalizedLaneId).catch(() => {
// Polling should continue even if one fetch iteration fails.
});
const intervalRef = setInterval(() => {
fetchAllRelayStatuses(normalizedLaneId).catch(() => {
// Keep polling resilient to temporary backend/network failures.
});
}, pollInterval);
lanePollIntervals.set(normalizedLaneId, intervalRef);
};
export const stopPolling = (laneId: number): void => {
const normalizedLaneId = toLaneId(laneId);
ensureLaneState(normalizedLaneId);
lanePollSubscribers[normalizedLaneId] = Math.max(0, lanePollSubscribers[normalizedLaneId] - 1);
if (lanePollSubscribers[normalizedLaneId] > 0) {
return;
}
const intervalRef = lanePollIntervals.get(normalizedLaneId);
if (intervalRef) {
clearInterval(intervalRef);
}
lanePollIntervals.delete(normalizedLaneId);
};
export const clearLaneError = (laneId: number): void => {
const normalizedLaneId = toLaneId(laneId);
ensureLaneState(normalizedLaneId);
laneErrors[normalizedLaneId] = null;
};
export const useMachineConnectivity = (laneId: number) => {
const normalizedLaneId = toLaneId(laneId);
ensureLaneState(normalizedLaneId);
const statuses = computed(() => laneRelayStatuses[normalizedLaneId]);
const loading = computed(() => laneRelayLoading[normalizedLaneId]);
const commandLoading = computed(() => laneCommandLoading[normalizedLaneId]);
const error = computed(() => laneErrors[normalizedLaneId]);
const lastUpdatedAt = computed(() => laneLastUpdatedAt[normalizedLaneId]);
const isPolling = computed(() => lanePollIntervals.has(normalizedLaneId));
return {
laneId: normalizedLaneId,
relayKinds,
statuses,
loading,
commandLoading,
error,
lastUpdatedAt,
isPolling,
fetchRelayStatus: (relay: RelayKind) => fetchRelayStatus(normalizedLaneId, relay),
fetchAllRelayStatuses: () => fetchAllRelayStatuses(normalizedLaneId),
toggleRelay: (relay: RelayKind, on: boolean) => setRelayState(normalizedLaneId, relay, on),
executeLaneCommand: (command: LaneCommand, payload: LaneCommandPayload = {}) => executeLaneCommand(normalizedLaneId, command, payload),
stopWash: () => stopWash(normalizedLaneId),
forceEnableMachine: (options: ForceEnableOptions = {}) => forceEnableMachine(normalizedLaneId, options),
forceDisableMachine: (licensePlate: string | null = null) => forceDisableMachine(normalizedLaneId, licensePlate),
startPolling: (intervalMs = DEFAULT_POLL_INTERVAL_MS) => startPolling(normalizedLaneId, intervalMs),
stopPolling: () => stopPolling(normalizedLaneId),
clearError: () => clearLaneError(normalizedLaneId),
getDisplayStatus: (relay: RelayKind) => parseRelayStatus(statuses.value[relay]),
};
};
export const machineConnectivityStore = {
state: {
laneRelayStatuses,
laneRelayLoading,
laneCommandLoading,
laneErrors,
laneLastUpdatedAt,
lanePollSubscribers,
},
functions: {
fetchRelayStatus,
fetchAllRelayStatuses,
setRelayState,
executeLaneCommand,
forceEnableMachine,
forceDisableMachine,
stopWash,
startPolling,
stopPolling,
clearLaneError,
},
};
export const __resetMachineConnectivityStoreForTests = (): void => {
lanePollIntervals.forEach((intervalRef) => clearInterval(intervalRef));
lanePollIntervals.clear();
const resetReactiveObject = (target: Record<string, unknown>) => {
Object.keys(target).forEach((key) => {
delete target[key];
});
};
resetReactiveObject(laneRelayStatuses as unknown as Record<string, unknown>);
resetReactiveObject(laneRelayLoading as unknown as Record<string, unknown>);
resetReactiveObject(laneCommandLoading as unknown as Record<string, unknown>);
resetReactiveObject(laneErrors as unknown as Record<string, unknown>);
resetReactiveObject(laneLastUpdatedAt as unknown as Record<string, unknown>);
resetReactiveObject(lanePollSubscribers as unknown as Record<string, unknown>);
};
export default {
relays,
lane,
relayKinds,
parseRelayStatus,
useMachineConnectivity,
machineConnectivityStore,
}
</script>
@@ -8,7 +8,7 @@ import SelfServeMachineControls
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import SelfServeMachineRelayControls
from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeMachineRelayControls.vue";
import { departments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
// Props = machine type
defineProps<{
machine: Machine;
@@ -21,7 +21,9 @@ defineProps<{
<WhiteBoxCard :default-open="true" :has-border="true" :hasSelectionStyle="true">
<template #header>
<div class="card-header-title">
<p>{{machine.name}}</p>
<!-- Department name -->
<p class="is-6">{{getDepartmentName(machine.department)}}</p>
<p class="is-4 ml-2 has-text-grey">{{machine.name}}</p>
</div>
<div class="card-header-icon">
<SelfServeMachineStatus :machineStatus="machine.status" />
@@ -1,18 +1,81 @@
<script setup lang="ts">
import {defineProps} from 'vue';
import {BButton} from "buefy";
import { relays } from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue";
import MachineType from "@/views/dashboards/superUserDashboard/selfserve/types/MachineType.vue";
import { computed, defineProps } from "vue";
import {
useMachineConnectivity,
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue";
import type { Machine } from "@/views/dashboards/superUserDashboard/selfserve/types/MachineType.vue";
defineProps<{machine: MachineType}>();
const props = defineProps<{ machine: Machine }>();
const connectivity = useMachineConnectivity(props.machine.id);
const commandLoading = computed(() => connectivity.commandLoading.value);
const error = computed(() => connectivity.error.value);
const hasCommandLoading = computed(() => (
commandLoading.value.command
|| commandLoading.value.forceEnable
|| commandLoading.value.forceDisable
));
const forceEnableMachine = async () => {
try {
await connectivity.forceEnableMachine();
await connectivity.fetchRelayStatus("MACHINE");
} catch (actionError) {
console.error("Failed to force enable machine relay", actionError);
}
};
const forceDisableMachine = async () => {
try {
await connectivity.forceDisableMachine();
await connectivity.fetchRelayStatus("MACHINE");
} catch (actionError) {
console.error("Failed to force disable machine relay", actionError);
}
};
const stopWash = async () => {
try {
await connectivity.stopWash();
await connectivity.fetchAllRelayStatuses();
} catch (actionError) {
console.error("Failed to stop wash", actionError);
}
};
</script>
<template>
<div class="flex gap-2">
<div>
<div class="buttons are-small is-right">
<button
class="button is-success"
:class="{ 'is-loading': commandLoading.forceEnable }"
:disabled="hasCommandLoading"
@click="forceEnableMachine"
>
Force enable
</button>
<button
class="button is-danger"
:class="{ 'is-loading': commandLoading.forceDisable }"
:disabled="hasCommandLoading"
@click="forceDisableMachine"
>
Force disable
</button>
<button
class="button is-warning"
:class="{ 'is-loading': commandLoading.command }"
:disabled="hasCommandLoading"
@click="stopWash"
>
Stop wash
</button>
</div>
<p v-if="error" class="help is-danger mt-2">{{ error }}</p>
</div>
</template>
<style scoped>
</style>
</style>
@@ -1,73 +1,52 @@
<script setup lang="ts">
import {defineProps, onMounted, onUnmounted, ref} from 'vue';
import {BSwitch} from "buefy";
import { relays } from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue";
import { computed, defineProps, onMounted, onUnmounted } from "vue";
import SelfServeMachineStatus
from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeMachineStatus.vue";
import type MachineType from "@/views/dashboards/superUserDashboard/selfserve/types/MachineType.vue";
const props = defineProps<{machine: MachineType}>();
import {
parseRelayStatus,
relayKinds,
useMachineConnectivity,
type RelayKind,
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue";
import type { Machine } from "@/views/dashboards/superUserDashboard/selfserve/types/MachineType.vue";
import {BButton, BSwitch} from "buefy";
const machineRelayStatus = ref(null);
const programPickerRelayStatus = ref(null);
const cleanerRelayStatus = ref(null);
const props = defineProps<{ machine: Machine }>();
const interval = ref<NodeJS.Timeout | null>(null);
const connectivity = useMachineConnectivity(props.machine.id);
const fetchAllStatuses = async () => {
const id = props.machine.id;
const statuses = computed(() => connectivity.statuses.value);
const loading = computed(() => connectivity.loading.value);
const error = computed(() => connectivity.error.value);
const toggleRelay = async (relay: RelayKind, on: boolean) => {
try {
machineRelayStatus.value = (await relays.get.status.MACHINE(id)).data.data;
programPickerRelayStatus.value = (await relays.get.status.PROGRAM_PICKER(id)).data.data;
cleanerRelayStatus.value = (await relays.get.status.CLEANER(id)).data.data;
} catch (e) {
console.error('Failed to fetch relay statuses', e);
await connectivity.toggleRelay(relay, on);
} catch (toggleError) {
console.error(`Failed to toggle relay ${relay}`, toggleError);
}
};
const toggleRelay = async (type: 'MACHINE' | 'PROGRAM_PICKER' | 'CLEANER', on: boolean) => {
const id = props.machine.id;
onMounted(async () => {
try {
await (relays.set as any)[type](id, on);
const statusResp = await (relays.get.status as any)[type](id);
const status = statusResp.data.data;
switch (type) {
case 'MACHINE':
machineRelayStatus.value = status;
break;
case 'PROGRAM_PICKER':
programPickerRelayStatus.value = status;
break;
case 'CLEANER':
cleanerRelayStatus.value = status;
break;
}
} catch (e) {
console.error(`Failed to toggle ${type} relay`, e);
await connectivity.fetchAllRelayStatuses();
} catch (fetchError) {
console.error("Failed to fetch relay statuses", fetchError);
}
};
// Get status on mount
onMounted(() => {
fetchAllStatuses();
interval.value = setInterval(fetchAllStatuses, 7000);
await connectivity.startPolling();
});
onUnmounted(() => {
if (interval.value) {
clearInterval(interval.value);
}
connectivity.stopPolling();
});
const parseStatus = (status: any) => {
if (!status) return "MAINTENANCE";
if (!status.online) return "OFFLINE";
return status.on ? "ON" : "OFF";
}
</script>
<template>
<div>
<p>Relay Controls</p>
<div class="divider">
<p class="divider-text">Relay Controls</p>
</div>
<div class="columns is-multiline is-mobile">
<div class="column is-half">
<p>Machine Relay</p>
@@ -75,13 +54,13 @@ const parseStatus = (status: any) => {
<div class="column is-half">
<div class="columns is-mobile">
<div class="column is-half">
<SelfServeMachineStatus :machine-status="parseStatus(machineRelayStatus)" />
<SelfServeMachineStatus :machine-status="parseRelayStatus(statuses.MACHINE)" />
</div>
<div class="column is-half">
<b-switch
:model-value="machineRelayStatus?.on ?? false"
:disabled="!machineRelayStatus || !machineRelayStatus.online"
@update:model-value="toggleRelay('MACHINE', $event)"
:model-value="statuses.MACHINE?.on ?? false"
:disabled="!statuses.MACHINE || !statuses.MACHINE.online || loading.MACHINE"
@update:model-value="toggleRelay(relayKinds.MACHINE, $event)"
size="is-small"
/>
</div>
@@ -93,13 +72,13 @@ const parseStatus = (status: any) => {
<div class="column is-half">
<div class="columns is-mobile">
<div class="column is-half">
<SelfServeMachineStatus :machine-status="parseStatus(programPickerRelayStatus)" />
<SelfServeMachineStatus :machine-status="parseRelayStatus(statuses.PROGRAM_PICKER)" />
</div>
<div class="column is-half">
<b-switch
:model-value="programPickerRelayStatus?.on ?? false"
:disabled="!programPickerRelayStatus || !programPickerRelayStatus.online"
@update:model-value="toggleRelay('PROGRAM_PICKER', $event)"
:model-value="statuses.PROGRAM_PICKER?.on ?? false"
:disabled="!statuses.PROGRAM_PICKER || !statuses.PROGRAM_PICKER.online || loading.PROGRAM_PICKER"
@update:model-value="toggleRelay(relayKinds.PROGRAM_PICKER, $event)"
size="is-small"
/>
</div>
@@ -111,22 +90,34 @@ const parseStatus = (status: any) => {
<div class="column is-half">
<div class="columns is-mobile">
<div class="column is-half">
<SelfServeMachineStatus :machine-status="parseStatus(cleanerRelayStatus)" />
<SelfServeMachineStatus :machine-status="parseRelayStatus(statuses.CLEANER)" />
</div>
<div class="column is-half">
<b-switch
:model-value="cleanerRelayStatus?.on ?? false"
:disabled="!cleanerRelayStatus || !cleanerRelayStatus.online"
@update:model-value="toggleRelay('CLEANER', $event)"
:model-value="statuses.CLEANER?.on ?? false"
:disabled="!statuses.CLEANER || !statuses.CLEANER.online || loading.CLEANER"
@update:model-value="toggleRelay(relayKinds.CLEANER, $event)"
size="is-small"
/>
</div>
</div>
</div>
</div>
<div class="divider">
<p class="divider-text">Gate Controls</p>
</div>
<div class="columns is-mobile">
<div class="column is-half">
<!-- Open Entrance Gate Button -->
</div>
<div class="column is-half">
<!-- Open Exit Gate Button -->
</div>
</div>
<p v-if="error" class="help is-danger mt-2">{{ error }}</p>
</div>
</template>
<style scoped>
</style>
</style>
@@ -23,12 +23,19 @@ const type = {
<template>
<div class="flex items-center gap-2">
<b-icon
:icon="icon[machineStatus]"
:type="type[machineStatus]"
pack="fas"
/>
<span class="text-sm font-medium">{{ machineStatus }}</span>
<span class="is-small label">
<span>
<b-icon
:icon="icon[machineStatus]"
:type="type[machineStatus]"
pack="fas"
size="is-small"
/>
</span>
<!-- If maintenance, show maintenance -->
<span v-if="machineStatus === 'MAINTENANCE'">N/A</span>
<span v-else>{{ machineStatus }}</span>
</span>
</div>
</template>
@@ -1,18 +1,18 @@
<script lang="ts">
// Machine interface
import MachineStatusType from "@/views/dashboards/superUserDashboard/selfserve/types/MachineStatusType.vue";
import MachineRelay from "@/views/dashboards/superUserDashboard/selfserve/types/MachineRelayType.vue";
import type MachineRelay from "@/views/dashboards/superUserDashboard/selfserve/types/MachineRelayType.vue";
type Machine = {
id: number;
name: string;
status: typeof MachineStatusType[keyof typeof MachineStatusType];
relays: {
machine: MachineRelay,
program_picker: MachineRelay,
cleaner: MachineRelay,
}
relays?: {
machine?: MachineRelay | null,
program_picker?: MachineRelay | null,
cleaner?: MachineRelay | null,
};
}
export type { Machine };
export default Machine;
</script>
</script>
@@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
request: vi.fn(),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: mocks.request,
},
}));
import {
lane,
relayKinds,
relays,
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeConnectivityRelay.vue";
const statusEndpoints = {
MACHINE: "/modules/self-serve/lane/relay/machine/status",
PROGRAM_PICKER: "/modules/self-serve/lane/relay/machine_program_picker/status",
CLEANER: "/modules/self-serve/lane/relay/machine_cleaner/status",
};
const setEndpoints = {
MACHINE: "/modules/self-serve/lane/relay/machine/set",
PROGRAM_PICKER: "/modules/self-serve/lane/relay/machine_program_picker/set",
CLEANER: "/modules/self-serve/lane/relay/machine_cleaner/set",
};
describe("self-serve connectivity relay api wrappers", () => {
beforeEach(() => {
mocks.request.mockReset();
mocks.request.mockResolvedValue({ data: { success: true } });
});
it("requests individual relay statuses for the given lane", async () => {
await relays.get.status.MACHINE(5);
await relays.get.status.PROGRAM_PICKER(5);
await relays.get.status.CLEANER(5);
expect(mocks.request).toHaveBeenNthCalledWith(
1,
statusEndpoints.MACHINE,
"GET",
{ lane_id: 5 }
);
expect(mocks.request).toHaveBeenNthCalledWith(
2,
statusEndpoints.PROGRAM_PICKER,
"GET",
{ lane_id: 5 }
);
expect(mocks.request).toHaveBeenNthCalledWith(
3,
statusEndpoints.CLEANER,
"GET",
{ lane_id: 5 }
);
});
it("fetches all relay statuses and returns keyed responses", async () => {
mocks.request.mockImplementation(async (endpoint) => ({ data: { endpoint } }));
const result = await relays.get.status.all("7");
expect(mocks.request).toHaveBeenCalledTimes(3);
expect(result.MACHINE.data.endpoint).toBe(statusEndpoints.MACHINE);
expect(result.PROGRAM_PICKER.data.endpoint).toBe(statusEndpoints.PROGRAM_PICKER);
expect(result.CLEANER.data.endpoint).toBe(statusEndpoints.CLEANER);
});
it("dispatches relay set operations by relay key", async () => {
await relays.set.byRelay(relayKinds.MACHINE, 8, true);
await relays.set.byRelay(relayKinds.PROGRAM_PICKER, 8, false);
await relays.set.byRelay(relayKinds.CLEANER, 8, true);
expect(mocks.request).toHaveBeenNthCalledWith(
1,
setEndpoints.MACHINE,
"POST",
{ lane_id: 8, on: true }
);
expect(mocks.request).toHaveBeenNthCalledWith(
2,
setEndpoints.PROGRAM_PICKER,
"POST",
{ lane_id: 8, on: false }
);
expect(mocks.request).toHaveBeenNthCalledWith(
3,
setEndpoints.CLEANER,
"POST",
{ lane_id: 8, on: true }
);
});
it("gets lane status with and without a lane id filter", async () => {
await lane.get.status();
await lane.get.status("9");
expect(mocks.request).toHaveBeenNthCalledWith(
1,
"/modules/self-serve/lane/status",
"GET",
{}
);
expect(mocks.request).toHaveBeenNthCalledWith(
2,
"/modules/self-serve/lane/status",
"GET",
{ lane_id: 9 }
);
});
it("executes lane command helpers with normalized lane ids", async () => {
await lane.command.START("11", { customer_number: 123, license_plate: "AA12345" });
await lane.command.STOP(11);
await lane.command.execute(11, "RESET", { license_plate: "BB54321" });
expect(mocks.request).toHaveBeenNthCalledWith(
1,
"/modules/self-serve/lane/command",
"POST",
{
lane_id: 11,
command: "START",
customer_number: 123,
license_plate: "AA12345",
}
);
expect(mocks.request).toHaveBeenNthCalledWith(
2,
"/modules/self-serve/lane/command",
"POST",
{
lane_id: 11,
command: "STOP",
}
);
expect(mocks.request).toHaveBeenNthCalledWith(
3,
"/modules/self-serve/lane/command",
"POST",
{
lane_id: 11,
command: "RESET",
license_plate: "BB54321",
}
);
});
it("normalizes machine force action payloads", async () => {
await lane.force.machine.enable("4", { duration: 12.8, licensePlate: " ab12345 " });
await lane.force.machine.enable(4, { duration: -2, licensePlate: " " });
await lane.force.machine.disable(4, " cd67890 ");
await lane.force.machine.disable(4, " ");
expect(mocks.request).toHaveBeenNthCalledWith(
1,
"/modules/self-serve/lane/force/machine/enable",
"POST",
{
lane_id: 4,
duration: 13,
license_plate: "AB12345",
}
);
expect(mocks.request).toHaveBeenNthCalledWith(
2,
"/modules/self-serve/lane/force/machine/enable",
"POST",
{
lane_id: 4,
duration: 0,
}
);
expect(mocks.request).toHaveBeenNthCalledWith(
3,
"/modules/self-serve/lane/force/machine/disable",
"POST",
{
lane_id: 4,
license_plate: "CD67890",
}
);
expect(mocks.request).toHaveBeenNthCalledWith(
4,
"/modules/self-serve/lane/force/machine/disable",
"POST",
{
lane_id: 4,
}
);
});
it("rejects invalid lane ids for relay and lane commands", async () => {
await expect(relays.get.status.all(0)).rejects.toThrow("Invalid lane id");
expect(() => lane.command.START(0)).toThrow("Invalid lane id");
expect(() => lane.force.machine.enable(Number.NaN)).toThrow("Invalid lane id");
});
});
@@ -0,0 +1,292 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
request: vi.fn(),
parseErrorMessage: vi.fn((error) => error?.message || String(error || "Unknown error")),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: mocks.request,
functions: {
parseErrorMessage: mocks.parseErrorMessage,
},
},
}));
import {
__resetMachineConnectivityStoreForTests,
parseRelayStatus,
relayKinds,
useMachineConnectivity,
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue";
const statusEndpoints = {
MACHINE: "/modules/self-serve/lane/relay/machine/status",
PROGRAM_PICKER: "/modules/self-serve/lane/relay/machine_program_picker/status",
CLEANER: "/modules/self-serve/lane/relay/machine_cleaner/status",
};
const setEndpoints = {
MACHINE: "/modules/self-serve/lane/relay/machine/set",
PROGRAM_PICKER: "/modules/self-serve/lane/relay/machine_program_picker/set",
CLEANER: "/modules/self-serve/lane/relay/machine_cleaner/set",
};
const buildRelayStatus = (
laneId,
relay,
{
relayId = `${relay.toLowerCase()}-${laneId}`,
online = true,
on = false,
} = {}
) => ({
lane_id: laneId,
relay,
relay_id: relayId,
online,
on,
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("self-serve machine connectivity store", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.request.mockReset();
mocks.parseErrorMessage.mockReset();
mocks.parseErrorMessage.mockImplementation((error) => error?.message || String(error || "Unknown error"));
__resetMachineConnectivityStoreForTests();
});
afterEach(() => {
vi.useRealTimers();
__resetMachineConnectivityStoreForTests();
});
it("hydrates all relay statuses for a lane via shared store", async () => {
const laneId = 7;
mocks.request.mockImplementation(async (endpoint) => {
if (endpoint === statusEndpoints.MACHINE) {
return { data: { data: buildRelayStatus(laneId, relayKinds.MACHINE, { on: true }) } };
}
if (endpoint === statusEndpoints.PROGRAM_PICKER) {
return { data: { data: buildRelayStatus(laneId, relayKinds.PROGRAM_PICKER, { on: false }) } };
}
if (endpoint === statusEndpoints.CLEANER) {
return { data: { data: buildRelayStatus(laneId, relayKinds.CLEANER, { online: false, on: false }) } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.fetchAllRelayStatuses();
expect(connectivity.statuses.value.MACHINE).toMatchObject({ lane_id: laneId, on: true, online: true });
expect(connectivity.statuses.value.PROGRAM_PICKER).toMatchObject({ lane_id: laneId, on: false, online: true });
expect(connectivity.statuses.value.CLEANER).toMatchObject({ lane_id: laneId, on: false, online: false });
expect(connectivity.error.value).toBeNull();
});
it("toggles a relay and refreshes relay status when set response is envelope-only", async () => {
const laneId = 11;
let machineOn = false;
mocks.request.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === setEndpoints.MACHINE && method === "POST") {
machineOn = Boolean(payload?.on);
return { data: { success: true } };
}
if (endpoint === statusEndpoints.MACHINE && method === "GET") {
return { data: { data: buildRelayStatus(laneId, relayKinds.MACHINE, { on: machineOn }) } };
}
if (endpoint === statusEndpoints.PROGRAM_PICKER && method === "GET") {
return { data: { data: buildRelayStatus(laneId, relayKinds.PROGRAM_PICKER) } };
}
if (endpoint === statusEndpoints.CLEANER && method === "GET") {
return { data: { data: buildRelayStatus(laneId, relayKinds.CLEANER) } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.fetchAllRelayStatuses();
expect(connectivity.statuses.value.MACHINE?.on).toBe(false);
await connectivity.toggleRelay(relayKinds.MACHINE, true);
expect(connectivity.statuses.value.MACHINE?.on).toBe(true);
});
it("runs force and command actions through shared connectivity helpers", async () => {
const laneId = 15;
mocks.request.mockResolvedValue({ data: { success: true } });
const connectivity = useMachineConnectivity(laneId);
await connectivity.forceEnableMachine({ duration: 120, licensePlate: "ab12345" });
await connectivity.forceDisableMachine("cd67890");
await connectivity.stopWash();
expect(mocks.request).toHaveBeenNthCalledWith(
1,
"/modules/self-serve/lane/force/machine/enable",
"POST",
{
lane_id: laneId,
duration: 120,
license_plate: "AB12345",
}
);
expect(mocks.request).toHaveBeenNthCalledWith(
2,
"/modules/self-serve/lane/force/machine/disable",
"POST",
{
lane_id: laneId,
license_plate: "CD67890",
}
);
expect(mocks.request).toHaveBeenNthCalledWith(
3,
"/modules/self-serve/lane/command",
"POST",
{
lane_id: laneId,
command: "STOP",
}
);
});
it("starts and stops polling without leaking background status requests", async () => {
vi.useFakeTimers();
const laneId = 4;
mocks.request.mockImplementation(async (endpoint) => {
const relay = Object.entries(statusEndpoints).find(([, value]) => value === endpoint)?.[0];
if (!relay) {
throw new Error(`Unexpected endpoint: ${endpoint}`);
}
return { data: { data: buildRelayStatus(laneId, relay, { on: false }) } };
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.startPolling(1000);
expect(connectivity.isPolling.value).toBe(true);
await vi.advanceTimersByTimeAsync(2200);
const statusCallCountBeforeStop = mocks.request.mock.calls.length;
expect(statusCallCountBeforeStop).toBeGreaterThanOrEqual(9);
connectivity.stopPolling();
expect(connectivity.isPolling.value).toBe(false);
await vi.advanceTimersByTimeAsync(2200);
expect(mocks.request.mock.calls.length).toBe(statusCallCountBeforeStop);
});
it("keeps successful relay statuses and surfaces an error when one relay fetch fails", async () => {
const laneId = 22;
mocks.request.mockImplementation(async (endpoint) => {
if (endpoint === statusEndpoints.MACHINE) {
return { data: { data: buildRelayStatus(laneId, relayKinds.MACHINE, { on: true }) } };
}
if (endpoint === statusEndpoints.PROGRAM_PICKER) {
throw new Error("program picker unavailable");
}
if (endpoint === statusEndpoints.CLEANER) {
return { data: { data: buildRelayStatus(laneId, relayKinds.CLEANER, { on: false }) } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.fetchAllRelayStatuses();
expect(connectivity.statuses.value.MACHINE?.on).toBe(true);
expect(connectivity.statuses.value.CLEANER?.on).toBe(false);
expect(connectivity.statuses.value.PROGRAM_PICKER).toBeNull();
expect(connectivity.error.value).toContain("program picker unavailable");
});
it("throws and stores lane error when all relay status requests fail", async () => {
const laneId = 24;
mocks.request.mockRejectedValue(new Error("all relays offline"));
const connectivity = useMachineConnectivity(laneId);
await expect(connectivity.fetchAllRelayStatuses()).rejects.toThrow("all relays offline");
expect(connectivity.error.value).toContain("all relays offline");
});
it("tracks command loading while lane commands are in-flight and resets after completion", async () => {
const laneId = 28;
const deferred = createDeferred();
mocks.request.mockReturnValue(deferred.promise);
const connectivity = useMachineConnectivity(laneId);
const stopPromise = connectivity.stopWash();
expect(connectivity.commandLoading.value.command).toBe(true);
deferred.resolve({ data: { success: true } });
await stopPromise;
expect(connectivity.commandLoading.value.command).toBe(false);
expect(connectivity.error.value).toBeNull();
});
it("keeps polling active until all subscribers for the same lane have unsubscribed", async () => {
vi.useFakeTimers();
const laneId = 31;
mocks.request.mockImplementation(async (endpoint) => {
const relay = Object.entries(statusEndpoints).find(([, value]) => value === endpoint)?.[0];
if (!relay) {
throw new Error(`Unexpected endpoint: ${endpoint}`);
}
return { data: { data: buildRelayStatus(laneId, relay) } };
});
const first = useMachineConnectivity(laneId);
const second = useMachineConnectivity(laneId);
await first.startPolling(1000);
await second.startPolling(1000);
const callsAfterStart = mocks.request.mock.calls.length;
first.stopPolling();
expect(first.isPolling.value).toBe(true);
await vi.advanceTimersByTimeAsync(1100);
expect(mocks.request.mock.calls.length).toBeGreaterThan(callsAfterStart);
second.stopPolling();
expect(first.isPolling.value).toBe(false);
const callsAfterFinalStop = mocks.request.mock.calls.length;
await vi.advanceTimersByTimeAsync(1100);
expect(mocks.request.mock.calls.length).toBe(callsAfterFinalStop);
});
it("maps relay status payloads to display labels", () => {
expect(parseRelayStatus(null)).toBe("MAINTENANCE");
expect(parseRelayStatus(buildRelayStatus(1, relayKinds.MACHINE, { online: false }))).toBe("OFFLINE");
expect(parseRelayStatus(buildRelayStatus(1, relayKinds.MACHINE, { online: true, on: true }))).toBe("ON");
expect(parseRelayStatus(buildRelayStatus(1, relayKinds.MACHINE, { online: true, on: false }))).toBe("OFF");
});
});