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:
+33
@@ -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>
|
||||
|
||||
+195
-69
@@ -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>
|
||||
|
||||
+490
-5
@@ -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>
|
||||
|
||||
+4
-2
@@ -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" />
|
||||
|
||||
+70
-7
@@ -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>
|
||||
|
||||
+53
-62
@@ -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>
|
||||
|
||||
+13
-6
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user