Bootssymbol hinzugefügt
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user