Bootssymbol hinzugefügt
Test and publish container images / test (push) Successful in 2m20s
Test and publish container images / publish (push) Failing after 4s

This commit is contained in:
BuTzZ
2026-07-26 12:31:49 +02:00
parent c9a20aacd7
commit 55ce94066e
19 changed files with 1969 additions and 122 deletions
+122
View File
@@ -9,6 +9,8 @@ vi.mock("../src/api", () => apiMocks);
import { useAnchorWatch } from "../src/hooks/useAnchorWatch";
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
const originalAudioContext = Object.getOwnPropertyDescriptor(window, "AudioContext");
const originalWebkitAudioContext = Object.getOwnPropertyDescriptor(window, "webkitAudioContext");
beforeEach(() => {
apiMocks.getNearestTide.mockReset();
@@ -20,9 +22,25 @@ afterEach(() => {
vi.useRealTimers();
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
else Reflect.deleteProperty(navigator, "vibrate");
restoreProperty(window, "AudioContext", originalAudioContext);
restoreProperty(window, "webkitAudioContext", originalWebkitAudioContext);
});
describe("useAnchorWatch", () => {
it("uses the current boat's bow roller height instead of a separate default", () => {
const gps = gpsFix({ lat: 53.2, lon: 7.1 });
const { result, rerender } = renderHook(
({ bowRollerHeightM }) =>
useAnchorWatch(gps, { bowRollerHeightM }),
{ initialProps: { bowRollerHeightM: 1.4 } }
);
expect(result.current.settings.bowRollerHeightM).toBe(1.4);
rerender({ bowRollerHeightM: 1.8 });
expect(result.current.settings.bowRollerHeightM).toBe(1.8);
});
it("captures an immutable anchor point only after an explicit action", async () => {
const gps = gpsFix({ lat: 53.2, lon: 7.1 });
const { result, rerender } = renderHook(
@@ -102,6 +120,61 @@ describe("useAnchorWatch", () => {
expect(vibrate).toHaveBeenCalledTimes(1);
});
it("repeats the audible drift alarm until it is explicitly acknowledged", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-26T02:00:00.000Z"));
const vibrate = vi.fn();
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
const audio = installAudioContextMock();
const initial = gpsFix({ lat: 0, lon: 0 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: initial } }
);
act(() => {
result.current.captureAnchor();
result.current.updateSettings({ alarmRadiusM: 100, deployedRodeLengthM: 100 });
});
await act(async () => {
expect(await result.current.arm()).toBe(true);
});
rerender({
value: gpsFix(
{ lat: 150 / 111_195.08, lon: 0 },
{ accuracyM: 10, timestampMs: Date.now() }
)
});
await act(async () => {
await Promise.resolve();
});
expect(result.current.driftAlarm).toBe(true);
expect(result.current.audibleDriftAlarmActive).toBe(true);
expect(audio.start).toHaveBeenCalledTimes(3);
expect(vibrate).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(1_300);
await Promise.resolve();
});
expect(audio.start).toHaveBeenCalledTimes(6);
expect(vibrate).toHaveBeenCalledTimes(2);
act(() => result.current.acknowledgeAlarm());
expect(result.current.alarmAcknowledged).toBe(true);
expect(result.current.audibleDriftAlarmActive).toBe(false);
const startsAfterAcknowledgement = audio.start.mock.calls.length;
await act(async () => {
vi.advanceTimersByTime(5_000);
await Promise.resolve();
});
expect(audio.start).toHaveBeenCalledTimes(startsAfterAcknowledgement);
expect(vibrate).toHaveBeenLastCalledWith(0);
});
it("keeps the positional watch usable but never confirms rode sufficiency from partial tide data", async () => {
apiMocks.getNearestTide.mockResolvedValue(tideFixture(6));
const { result } = renderHook(() => useAnchorWatch(gpsFix({ lat: 53.2, lon: 7.1 })));
@@ -146,3 +219,52 @@ function tideFixture(hours = 30): TideSummary {
updatedAt: new Date(now).toISOString()
};
}
function installAudioContextMock() {
const start = vi.fn();
const stop = vi.fn();
const context = {
state: "running",
currentTime: 0,
destination: {},
resume: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
createOscillator: vi.fn(() => ({
type: "sine",
frequency: { value: 0 },
connect: vi.fn(),
disconnect: vi.fn(),
start,
stop,
onended: null
})),
createGain: vi.fn(() => ({
gain: {
setValueAtTime: vi.fn(),
exponentialRampToValueAtTime: vi.fn()
},
connect: vi.fn(),
disconnect: vi.fn()
}))
};
function AudioContextMock() {
return context;
}
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: AudioContextMock
});
return { context, start, stop };
}
function restoreProperty(
target: object,
property: PropertyKey,
descriptor: PropertyDescriptor | undefined
) {
if (descriptor) {
Object.defineProperty(target, property, descriptor);
} else {
Reflect.deleteProperty(target, property);
}
}
+164
View File
@@ -0,0 +1,164 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BoatProfile } from "@watermaps/shared";
import { BoatProfilePanel } from "../src/components/BoatProfilePanel";
afterEach(cleanup);
describe("BoatProfilePanel", () => {
it("shows the current profile, all routing-relevant fields and cookie information", () => {
renderPanel();
expect(screen.getByRole("heading", { name: "Aktuelles Boot" })).toBeVisible();
expect(screen.getByLabelText("Name des Bootes")).toHaveValue("Seestern");
expect(screen.getByLabelText("Kategorie")).toHaveValue("sailboat");
expect(screen.getByLabelText("Länge über alles")).toHaveValue(10.4);
expect(screen.getByLabelText("Breite")).toHaveValue(3.3);
expect(
screen.getByLabelText("Durchfahrtshöhe ab Wasserlinie")
).toHaveValue(14.2);
expect(screen.getByLabelText("Tiefgang")).toHaveValue(1.8);
expect(screen.getByLabelText("Sicherheitsreserve")).toHaveValue(0.5);
expect(screen.getByLabelText("Reisegeschwindigkeit")).toHaveValue(6);
expect(screen.getByLabelText("Bugrolle über Wasserlinie")).toHaveValue(1.1);
expect(
screen.getByRole("group", { name: "Ankern" })
).toHaveTextContent("von der Durchfahrtshöhe getrennt");
expect(screen.getByText(/in einem Cookie dieses Browsers gespeichert/)).toBeVisible();
});
it("validates, normalizes and saves the edited profile", () => {
const onSave = vi.fn(() => true);
renderPanel({ onSave, hasActiveRoute: true });
expect(screen.getByText(/Beim Speichern wird sie verworfen/)).toBeVisible();
fireEvent.change(screen.getByLabelText("Name des Bootes"), {
target: { value: " Kleine Möwe " }
});
fireEvent.change(screen.getByLabelText("Kategorie"), {
target: { value: "motor-sailer" }
});
fireEvent.change(screen.getByLabelText("Breite"), {
target: { value: "3.6" }
});
fireEvent.change(screen.getByLabelText("Bugrolle über Wasserlinie"), {
target: { value: "1.25" }
});
fireEvent.click(
screen.getByRole("button", { name: "Bootsdaten speichern" })
);
expect(onSave).toHaveBeenCalledWith({
...PROFILE,
name: "Kleine Möwe",
category: "motor-sailer",
beamM: 3.6,
bowRollerHeightM: 1.25
});
expect(screen.getByRole("status")).toHaveTextContent(
"Die Bootsdaten wurden in diesem Browser gespeichert."
);
});
it("reports invalid values without calling the persistence callback", () => {
const onSave = vi.fn(() => true);
renderPanel({ onSave });
fireEvent.change(screen.getByLabelText("Reisegeschwindigkeit"), {
target: { value: "81" }
});
fireEvent.submit(
screen.getByRole("button", { name: "Bootsdaten speichern" }).closest("form")!
);
expect(onSave).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
"Reisegeschwindigkeit muss zwischen mehr als 0 und 80 liegen."
);
});
it("explains when the browser refuses to save the cookie", () => {
renderPanel({ onSave: vi.fn(() => false) });
fireEvent.click(
screen.getByRole("button", { name: "Bootsdaten speichern" })
);
expect(screen.getByRole("alert")).toHaveTextContent(
"nicht im Cookie gespeichert"
);
});
it("only offers deletion for stored data and requires inline confirmation", () => {
const onDelete = vi.fn(() => true);
const { rerender } = renderPanel({ stored: false, onDelete });
expect(
screen.queryByRole("button", { name: "Bootsdaten löschen" })
).not.toBeInTheDocument();
rerender(
<BoatProfilePanel
profile={PROFILE}
stored
onSave={vi.fn(() => true)}
onDelete={onDelete}
/>
);
fireEvent.click(
screen.getByRole("button", { name: "Bootsdaten löschen" })
);
const confirmation = screen.getByRole("group", {
name: "Löschen der Bootsdaten bestätigen"
});
expect(onDelete).not.toHaveBeenCalled();
fireEvent.click(
within(confirmation).getByRole("button", { name: "Abbrechen" })
);
expect(
screen.queryByRole("group", {
name: "Löschen der Bootsdaten bestätigen"
})
).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "Bootsdaten löschen" })
);
fireEvent.click(
screen.getByRole("button", { name: "Endgültig löschen" })
);
expect(onDelete).toHaveBeenCalledTimes(1);
expect(screen.getByRole("status")).toHaveTextContent(
"aus diesem Browser gelöscht"
);
});
});
const PROFILE: BoatProfile = {
name: "Seestern",
category: "sailboat",
lengthM: 10.4,
beamM: 3.3,
draughtM: 1.8,
airDraftM: 14.2,
safetyReserveM: 0.5,
cruiseSpeedKn: 6,
bowRollerHeightM: 1.1
};
function renderPanel(
overrides: Partial<Parameters<typeof BoatProfilePanel>[0]> = {}
) {
const props: Parameters<typeof BoatProfilePanel>[0] = {
profile: PROFILE,
stored: true,
onSave: vi.fn(() => true),
onDelete: vi.fn(() => true),
...overrides
};
return render(<BoatProfilePanel {...props} />);
}
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it } from "vitest";
import type { BoatProfile } from "@watermaps/shared";
import {
BOAT_PROFILE_COOKIE_NAME,
DEFAULT_BOAT_PROFILE,
readBoatProfileCookie,
serializeBoatProfileCookie,
toVesselProfile,
validateBoatProfile,
writeBoatProfileCookie
} from "../src/lib/boat-profile";
afterEach(() => {
document.cookie = `${BOAT_PROFILE_COOKIE_NAME}=; Path=/; Max-Age=0`;
});
describe("boat profile cookie", () => {
it("round-trips a validated Unicode profile", () => {
const profile: BoatProfile = {
...DEFAULT_BOAT_PROFILE,
name: "Möwe ⛵; 50%=gut",
category: "sailboat",
lengthM: 9.4,
airDraftM: 13.2
};
expect(writeBoatProfileCookie(profile)).toBe(true);
expect(readBoatProfileCookie()).toEqual(profile);
});
it("uses a versioned host cookie with safe attributes", () => {
const serialized = serializeBoatProfileCookie(DEFAULT_BOAT_PROFILE, {
secure: true,
updatedAt: "2026-07-26T12:00:00.000Z"
});
expect(serialized).toContain(`${BOAT_PROFILE_COOKIE_NAME}=`);
expect(serialized).toContain("Path=/");
expect(serialized).toContain("Max-Age=31536000");
expect(serialized).toContain("SameSite=Lax");
expect(serialized).toContain("Secure");
expect(serialized).not.toContain("Domain=");
});
it("rejects damaged, unknown-version and out-of-range data", () => {
expect(readBoatProfileCookie(`${BOAT_PROFILE_COOKIE_NAME}=%E0%A4%A`)).toBeNull();
expect(
readBoatProfileCookie(
`${BOAT_PROFILE_COOKIE_NAME}=${encodeURIComponent(JSON.stringify({
v: 2,
updatedAt: new Date().toISOString(),
profile: DEFAULT_BOAT_PROFILE
}))}`
)
).toBeNull();
expect(
validateBoatProfile({ ...DEFAULT_BOAT_PROFILE, draughtM: Number.NaN }).valid
).toBe(false);
expect(
validateBoatProfile({ ...DEFAULT_BOAT_PROFILE, airDraftM: 81 }).valid
).toBe(false);
});
it("maps only routing-relevant fields to the API profile", () => {
const vessel = toVesselProfile({
...DEFAULT_BOAT_PROFILE,
name: "Seestern",
category: "motorboat",
lengthM: 12
});
expect(vessel).toEqual({
draughtM: 1.4,
safetyReserveM: 0.5,
airDraftM: 2.5,
beamM: 3.2,
cruiseSpeedKn: 6
});
expect(vessel).not.toHaveProperty("name");
expect(vessel).not.toHaveProperty("category");
expect(vessel).not.toHaveProperty("lengthM");
});
});
+2 -2
View File
@@ -12,7 +12,7 @@ test("renders the iPhone PWA shell without overlapping core controls", async ({
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
await page.getByText("Routenoptionen: Zwischenziele").click();
await expect(page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" })).toBeVisible();
await expect(page.getByLabel("Abfahrt")).toBeVisible();
await page.getByText("Unterwegs: GPX · Offline · Kursalarm").click();
@@ -55,7 +55,7 @@ test("sets start and destination only after explicit map picking actions", async
await expect(page.getByText(/^Ziel \d/)).toBeVisible();
await expect(page.getByText("Route bereit zur Prüfung")).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
await page.getByText("Routenoptionen: Zwischenziele").click();
await page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" }).click();
await expect(page.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
+4 -1
View File
@@ -28,15 +28,18 @@ describe("NavigationToolRail", () => {
/>
);
const rail = screen.getByRole("navigation", { name: "Kartenwerkzeuge" });
const rail = screen.getByRole("navigation", { name: "Navigationswerkzeuge" });
const buttons = within(rail).getAllByRole("button");
expect(buttons.map((button) => button.dataset.tool)).toEqual([
"boat",
"anchor",
"conditions",
"upcoming",
"route"
]);
expect(screen.getByRole("button", { name: /Mein Boot, bereit, öffnen/ })).toBeVisible();
const anchor = screen.getByRole("button", { name: /Ankerwache, Alarm, öffnen/ });
expect(anchor).toHaveAttribute("data-status", "alarm");
expect(anchor).toHaveAttribute("aria-controls", "test-workspace");
+16 -5
View File
@@ -2,12 +2,13 @@ import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RoutePlanner } from "../src/components/RoutePlanner";
import { DEFAULT_BOAT_PROFILE } from "../src/lib/boat-profile";
import type { RouteWeatherReport } from "../src/routeWeatherReport";
afterEach(() => cleanup());
describe("RoutePlanner", () => {
it("submits vessel profile when start and destination exist", () => {
it("uses the current boat while submitting route coordinates", () => {
const onSubmit = vi.fn();
render(
<RoutePlanner
@@ -19,6 +20,16 @@ describe("RoutePlanner", () => {
weatherReport={null}
weatherLoading={false}
weatherError={null}
boatProfile={{
...DEFAULT_BOAT_PROFILE,
name: "Seestern",
category: "sailboat",
lengthM: 9.4,
draughtM: 1.8,
airDraftM: 13.2,
beamM: 3.3,
cruiseSpeedKn: 6.5
}}
loading={false}
error={null}
pickMode={null}
@@ -39,9 +50,10 @@ describe("RoutePlanner", () => {
start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.2, lon: 12.1 },
waypoints: [],
departureTime: expect.any(String),
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
departureTime: expect.any(String)
});
expect(screen.getByRole("region", { name: "Aktuelles Boot: Seestern" })).toBeVisible();
expect(screen.queryByLabelText("Tiefgang (m)")).not.toBeInTheDocument();
});
it("submits ordered waypoints together with the selected departure time", () => {
@@ -86,8 +98,7 @@ describe("RoutePlanner", () => {
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 51.6814536, lon: 7.8042615 },
waypoints,
departureTime: new Date(localDeparture).toISOString(),
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
departureTime: new Date(localDeparture).toISOString()
});
});