Add outside gate controls to SelfServeMachineRelayControls with comprehensive tests:

- Introduced `openOutsideEntranceGate` and `openOutsideExitGate` actions.
- Added dynamic button rendering based on department gate capabilities.
- Implemented unit tests for gate visibility and action triggers.
This commit is contained in:
Jeppe Bundgaard
2026-04-01 17:12:00 +02:00
parent 19501896e1
commit beaf95a5b1
2 changed files with 189 additions and 8 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from "vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import SelfServeMachineStatus
from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeMachineStatus.vue";
import {
@@ -12,6 +12,7 @@ import {
import type { Machine } from "@/views/dashboards/superUserDashboard/selfserve/types/MachineType.vue";
import {BSwitch, BTooltip} from "buefy";
import MachineStatusType from "@/views/dashboards/superUserDashboard/selfserve/types/MachineStatusType.vue";
import { getDepartmentOutsideGateCapabilities } from "@/composables/departmentOutsideGateCapabilities.js";
const props = defineProps<{ machine: Machine }>();
@@ -45,7 +46,15 @@ const hasGateLoading = computed(() => (
const hasOutsideGateLoading = computed(() => (
commandLoading.value.openOutsideEntranceGate || commandLoading.value.openOutsideExitGate
))
));
const hasOutsideEntranceGate = ref(false);
const hasOutsideExitGate = ref(false);
const hasAnyOutsideGates = computed(() => (
hasOutsideEntranceGate.value || hasOutsideExitGate.value
));
let outsideGateCapabilityRequestId = 0;
const normalizeDisplayValue = (value: unknown): string | null => {
if (typeof value === "string") {
@@ -141,6 +150,25 @@ const openEntranceGate = () => openGate("ENTRANCE");
const openExitGate = () => openGate("EXIT");
const openSharedGate = () => openGate("ENTRANCE");
const loadOutsideGateCapabilities = async () => {
const requestId = ++outsideGateCapabilityRequestId;
const capabilities = await getDepartmentOutsideGateCapabilities(props.machine.department);
if (requestId !== outsideGateCapabilityRequestId) {
return;
}
hasOutsideEntranceGate.value = capabilities.hasOutsideEntranceGate;
hasOutsideExitGate.value = capabilities.hasOutsideExitGate;
};
watch(
() => props.machine.department,
() => {
loadOutsideGateCapabilities();
},
{ immediate: true }
);
onMounted(async () => {
try {
await connectivity.fetchAllRelayStatuses();
@@ -152,6 +180,7 @@ onMounted(async () => {
});
onUnmounted(() => {
outsideGateCapabilityRequestId += 1;
connectivity.stopPolling();
});
</script>
@@ -370,19 +399,21 @@ onUnmounted(() => {
</div>
</div>
<!-- This sends a command to open the outside gate to the property, not to be confused with the entrance gate. -->
<div class="columns is-mobile">
<div class="column is-half">
<div v-if="hasAnyOutsideGates" class="columns is-mobile">
<div v-if="hasOutsideEntranceGate" class="column" :class="hasOutsideExitGate ? 'is-half' : 'is-full'">
<button
class="button is-warning is-small is-fullwidth"
data-testid="outside-entrance-gate-button"
:class="{ 'is-loading': commandLoading.openOutsideEntranceGate }"
:disabled="hasOutsideGateLoading"
@click="connectivity.openOutsideEntranceGate()">
Open outside entrance gate
</button>
</div>
<div class="column is-half">
<div v-if="hasOutsideExitGate" class="column" :class="hasOutsideEntranceGate ? 'is-half' : 'is-full'">
<button
class="button is-danger is-small is-fullwidth"
data-testid="outside-exit-gate-button"
:class="{ 'is-loading': commandLoading.openOutsideExitGate }"
:disabled="hasOutsideGateLoading"
@click="connectivity.openOutsideExitGate()">
@@ -5,11 +5,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
openGate: vi.fn(() => Promise.resolve()),
openOutsideEntranceGate: vi.fn(() => Promise.resolve()),
openOutsideExitGate: vi.fn(() => Promise.resolve()),
fetchAllRelayStatuses: vi.fn(() => Promise.resolve()),
startPolling: vi.fn(() => Promise.resolve()),
stopPolling: vi.fn(),
toggleRelay: vi.fn(() => Promise.resolve()),
useMachineConnectivity: vi.fn(),
getDepartmentOutsideGateCapabilities: vi.fn(),
}));
const buildConnectivity = () => ({
@@ -29,12 +32,16 @@ const buildConnectivity = () => ({
forceDisable: false,
openEntranceGate: false,
openExitGate: false,
openOutsideEntranceGate: false,
openOutsideExitGate: false,
}),
error: ref(null),
inProgressDetails: ref(null),
inProgressLoading: ref(false),
toggleRelay: mocks.toggleRelay,
openGate: mocks.openGate,
openOutsideEntranceGate: mocks.openOutsideEntranceGate,
openOutsideExitGate: mocks.openOutsideExitGate,
fetchAllRelayStatuses: mocks.fetchAllRelayStatuses,
startPolling: mocks.startPolling,
stopPolling: mocks.stopPolling,
@@ -53,6 +60,10 @@ vi.mock(
})
);
vi.mock("@/composables/departmentOutsideGateCapabilities.js", () => ({
getDepartmentOutsideGateCapabilities: mocks.getDepartmentOutsideGateCapabilities,
}));
import SelfServeMachineRelayControls
from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeMachineRelayControls.vue";
@@ -64,15 +75,24 @@ const flushMicrotasks = async () => {
describe("SelfServeMachineRelayControls gate controls", () => {
beforeEach(() => {
mocks.openGate.mockReset();
mocks.openOutsideEntranceGate.mockReset();
mocks.openOutsideExitGate.mockReset();
mocks.fetchAllRelayStatuses.mockReset();
mocks.startPolling.mockReset();
mocks.stopPolling.mockReset();
mocks.toggleRelay.mockReset();
mocks.useMachineConnectivity.mockReset();
mocks.getDepartmentOutsideGateCapabilities.mockReset();
mocks.openGate.mockResolvedValue(undefined);
mocks.openOutsideEntranceGate.mockResolvedValue(undefined);
mocks.openOutsideExitGate.mockResolvedValue(undefined);
mocks.fetchAllRelayStatuses.mockResolvedValue(undefined);
mocks.startPolling.mockResolvedValue(undefined);
mocks.useMachineConnectivity.mockImplementation(() => buildConnectivity());
mocks.getDepartmentOutsideGateCapabilities.mockResolvedValue({
hasOutsideEntranceGate: true,
hasOutsideExitGate: true,
});
});
it("shows a single full-width Open Gate button when entrance/exit share the same relay id", async () => {
@@ -195,9 +215,9 @@ describe("SelfServeMachineRelayControls gate controls", () => {
await flushMicrotasks();
expect(wrapper.text()).toContain("Customer:");
expect(wrapper.text()).toContain("Customer");
expect(wrapper.text()).toContain("John Doe");
expect(wrapper.text()).toContain("Vehicle:");
expect(wrapper.text()).toContain("Vehicle");
expect(wrapper.text()).toContain("AB12345");
});
@@ -234,8 +254,138 @@ describe("SelfServeMachineRelayControls gate controls", () => {
await flushMicrotasks();
expect(wrapper.text()).toContain("Customer:");
expect(wrapper.text()).toContain("Customer");
expect(wrapper.text()).toContain("Jane Doe");
expect(wrapper.text()).not.toContain("Loading in-progress details...");
});
it("shows and wires both outside gate buttons when department has outside entrance and exit gates", async () => {
const wrapper = mount(SelfServeMachineRelayControls, {
props: {
machine: {
id: 6,
department: 12,
name: "Lane 6",
status: "ONLINE",
relay_in_id: "in",
relay_out_id: "out",
},
},
global: {
stubs: {
SelfServeMachineStatus: { template: "<div />" },
BSwitch: { template: "<div />" },
},
},
});
await flushMicrotasks();
const outsideEntranceButton = wrapper.get("[data-testid='outside-entrance-gate-button']");
const outsideExitButton = wrapper.get("[data-testid='outside-exit-gate-button']");
expect(outsideEntranceButton.exists()).toBe(true);
expect(outsideExitButton.exists()).toBe(true);
await outsideEntranceButton.trigger("click");
await outsideExitButton.trigger("click");
expect(mocks.openOutsideEntranceGate).toHaveBeenCalledTimes(1);
expect(mocks.openOutsideExitGate).toHaveBeenCalledTimes(1);
});
it("shows only the outside entrance button when department only has an outside entrance gate", async () => {
mocks.getDepartmentOutsideGateCapabilities.mockResolvedValueOnce({
hasOutsideEntranceGate: true,
hasOutsideExitGate: false,
});
const wrapper = mount(SelfServeMachineRelayControls, {
props: {
machine: {
id: 7,
department: 13,
name: "Lane 7",
status: "ONLINE",
relay_in_id: "in",
relay_out_id: "out",
},
},
global: {
stubs: {
SelfServeMachineStatus: { template: "<div />" },
BSwitch: { template: "<div />" },
},
},
});
await flushMicrotasks();
expect(wrapper.find("[data-testid='outside-entrance-gate-button']").exists()).toBe(true);
expect(wrapper.find("[data-testid='outside-exit-gate-button']").exists()).toBe(false);
});
it("shows only the outside exit button when department only has an outside exit gate", async () => {
mocks.getDepartmentOutsideGateCapabilities.mockResolvedValueOnce({
hasOutsideEntranceGate: false,
hasOutsideExitGate: true,
});
const wrapper = mount(SelfServeMachineRelayControls, {
props: {
machine: {
id: 8,
department: 14,
name: "Lane 8",
status: "ONLINE",
relay_in_id: "in",
relay_out_id: "out",
},
},
global: {
stubs: {
SelfServeMachineStatus: { template: "<div />" },
BSwitch: { template: "<div />" },
},
},
});
await flushMicrotasks();
expect(wrapper.find("[data-testid='outside-entrance-gate-button']").exists()).toBe(false);
expect(wrapper.find("[data-testid='outside-exit-gate-button']").exists()).toBe(true);
});
it("hides outside gate controls when department has no outside gates", async () => {
mocks.getDepartmentOutsideGateCapabilities.mockResolvedValueOnce({
hasOutsideEntranceGate: false,
hasOutsideExitGate: false,
});
const wrapper = mount(SelfServeMachineRelayControls, {
props: {
machine: {
id: 9,
department: 15,
name: "Lane 9",
status: "ONLINE",
relay_in_id: "in",
relay_out_id: "out",
},
},
global: {
stubs: {
SelfServeMachineStatus: { template: "<div />" },
BSwitch: { template: "<div />" },
},
},
});
await flushMicrotasks();
expect(wrapper.find("[data-testid='outside-entrance-gate-button']").exists()).toBe(false);
expect(wrapper.find("[data-testid='outside-exit-gate-button']").exists()).toBe(false);
expect(wrapper.text()).not.toContain("Open outside entrance gate");
expect(wrapper.text()).not.toContain("Open outside exit gate");
});
});