mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 03:01:37 +00:00
11 files changed
+322
-12
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "beacon-web",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "beacon-web",
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@nazka/map-gl-js-spiderfy": "^2.0.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "beacon-web",
|
||||
"private": true,
|
||||
"version": "2.0.4",
|
||||
"version": "2.0.5",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { VARIANT_CLASSES, type BadgeVariant } from "./badge-utils";
|
||||
|
||||
export function Badge({ variant, children }: { variant: BadgeVariant; children: ReactNode }) {
|
||||
export function Badge({ variant, children, normalCase = false }: { variant: BadgeVariant; children: ReactNode; normalCase?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase ${VARIANT_CLASSES[variant]}`}
|
||||
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider ${normalCase ? "" : "uppercase "}${VARIANT_CLASSES[variant]}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useRef, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||
import { useState, useRef, useEffect, useId, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useHasHover } from "../hooks/useMediaQuery";
|
||||
|
||||
// Portals to <body> with fixed positioning so overflow parents (the data tables) can't clip it.
|
||||
// Hover reveals with a mouse; on touch it toggles on tap and dismisses on an outside tap.
|
||||
export function Tooltip({ label, children, className = "", wrap = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) {
|
||||
// `focusable` makes a non-interactive trigger a tab stop whose label is exposed to screen readers.
|
||||
export function Tooltip({ label, children, className = "", wrap = false, focusable = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean; focusable?: boolean }) {
|
||||
const hasHover = useHasHover();
|
||||
const descId = useId();
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const tipRef = useRef<HTMLSpanElement>(null);
|
||||
const [anchor, setAnchor] = useState<DOMRect | null>(null);
|
||||
@@ -62,9 +64,12 @@ export function Tooltip({ label, children, className = "", wrap = false }: { lab
|
||||
onClick={hasHover ? undefined : toggle}
|
||||
onFocus={hasHover ? show : undefined}
|
||||
onBlur={hasHover ? hide : undefined}
|
||||
tabIndex={focusable ? 0 : undefined}
|
||||
aria-describedby={focusable ? descId : undefined}
|
||||
className={`inline-flex ${className}`}
|
||||
>
|
||||
{children}
|
||||
{focusable && <span id={descId} hidden>{label}</span>}
|
||||
{anchor &&
|
||||
createPortal(
|
||||
<span
|
||||
|
||||
@@ -6,7 +6,7 @@ export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolea
|
||||
const { t } = useTranslation();
|
||||
if (possiblyForeign !== true) return null;
|
||||
return (
|
||||
<Tooltip label={t("nodes.possiblyForeignHint")}>
|
||||
<Tooltip label={t("nodes.possiblyForeignHint")} focusable>
|
||||
<Badge variant="default">{t("nodes.possiblyForeign")}</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Section, Field } from "../../components/DetailPanel";
|
||||
import { IataChip } from "../../components/IataChip";
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
import type { MeshMapperRepeater, MeshMapperScope } from "./types";
|
||||
|
||||
function ScopeRow({ scope }: { scope: MeshMapperScope }) {
|
||||
const { t } = useTranslation();
|
||||
const how = [
|
||||
scope.observedPackets != null ? t("nodeDetail.observedPackets", { count: scope.observedPackets }) : scope.observedAt != null ? t("nodeDetail.observed") : null,
|
||||
scope.reportedAt != null ? t("nodeDetail.reported") : null,
|
||||
scope.uploadedAt != null ? t("nodeDetail.uploaded") : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
const latest = Math.max(scope.observedAt ?? 0, scope.reportedAt ?? 0, scope.uploadedAt ?? 0);
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="text-text-normal truncate min-w-0" title={scope.scope}>{scope.scope}</span>
|
||||
{how && <span className="text-text-muted text-[11px] truncate min-w-0">{how}</span>}
|
||||
{latest > 0 && <Timestamp value={latest} className="ml-auto shrink-0 text-text-dim text-[11px]" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MeshMapperSection({ mm }: { mm: MeshMapperRepeater }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { site, defaultScope } = mm;
|
||||
const hasSite = Boolean(site.hardware || site.power || site.powerSource || site.antenna || site.heightM != null || site.notes);
|
||||
const hasScopes = mm.scopes.length > 0 || mm.scopesCheckedAt != null;
|
||||
// carpeater alone is covered by the badge next to the node name
|
||||
if (!hasSite && !hasScopes && mm.admins.length === 0 && !defaultScope) return null;
|
||||
|
||||
const num = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 });
|
||||
const height = site.heightM != null
|
||||
? [t("nodeDetail.heightAgl", { value: num.format(site.heightM) }), site.heightAslM != null ? t("nodeDetail.heightAsl", { value: num.format(site.heightAslM) }) : null].filter(Boolean).join(" · ")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Section title={t("nodeDetail.meshmapper")}>
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
|
||||
{(site.hardware || site.power || site.powerSource) && (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5">
|
||||
{site.hardware && <Field label={t("nodeDetail.hardware")} value={site.hardware} />}
|
||||
{site.power && <Field label={t("nodeDetail.power")} value={site.power} />}
|
||||
{site.powerSource && <Field label={t("nodeDetail.powerSource")} value={t(`nodeDetail.powerSources.${site.powerSource}`, { defaultValue: site.powerSource })} />}
|
||||
</div>
|
||||
)}
|
||||
{site.antenna && <Field label={t("nodeDetail.antenna")} value={site.antenna} />}
|
||||
{height && <Field label={t("nodeDetail.height")} value={height} />}
|
||||
{mm.admins.length > 0 && <Field label={t("nodeDetail.admins")} value={mm.admins.join(", ")} />}
|
||||
{defaultScope && (
|
||||
<Field
|
||||
label={t("nodeDetail.mmDefaultScope")}
|
||||
value={
|
||||
<>
|
||||
{defaultScope.state === "named" && defaultScope.name ? defaultScope.name : t(`nodeDetail.scopeState.${defaultScope.state === "none" ? "none" : "unknown"}`)}
|
||||
{(defaultScope.source || defaultScope.updatedAt != null) && (
|
||||
<span className="text-text-dim text-[11px]">
|
||||
{" "}({defaultScope.source && t(`nodeDetail.scopeSource.${defaultScope.source}`, { defaultValue: defaultScope.source })}
|
||||
{defaultScope.source && defaultScope.updatedAt != null && ", "}
|
||||
{defaultScope.updatedAt != null && <Timestamp value={defaultScope.updatedAt} />})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasScopes && (
|
||||
<div>
|
||||
<span className="text-text-dim">{t("nodeDetail.confirmedScopes")}</span>
|
||||
<div className="pl-3 flex flex-col gap-0.5">
|
||||
{mm.scopes.length > 0 ? (
|
||||
mm.scopes.map((s, i) => <ScopeRow key={`${s.scope}-${i}`} scope={s} />)
|
||||
) : (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-text-muted">{t("nodeDetail.noConfirmedScopes")}</span>
|
||||
<Timestamp value={mm.scopesCheckedAt!} className="ml-auto text-text-dim text-[11px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{site.notes && <p className="mt-1 text-text-muted whitespace-pre-line break-words">{site.notes}</p>}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1.5 font-mono text-[11px] text-text-dim">
|
||||
<span>{t("nodeDetail.fromMeshMapper")}</span>
|
||||
{mm.iata && <IataChip>{mm.iata}</IataChip>}
|
||||
<span className="ml-auto">{t("nodeDetail.updated")} <Timestamp value={mm.fetchedAt} /></span>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { Timestamp } from "../../components/Timestamp";
|
||||
import { hasMapLocation } from "../map/location";
|
||||
import type { NodeObservation, NodeNeighbor } from "./types";
|
||||
import { ForeignNodeBadge } from "./ForeignNodeBadge";
|
||||
import { MeshMapperSection } from "./MeshMapperSection";
|
||||
import { Tooltip } from "../../components/Tooltip";
|
||||
|
||||
function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -126,6 +128,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
|
||||
</span>
|
||||
<Badge variant="default">{node.nodeTypeName}</Badge>
|
||||
<ForeignNodeBadge possiblyForeign={node.possiblyForeign} />
|
||||
{node.meshmapper?.carpeater && (
|
||||
<Tooltip label={t("nodeDetail.carpeaterHint")} focusable>
|
||||
<Badge variant="trace" normalCase>{t("nodeDetail.carpeater")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-mono text-[13px] text-text-muted truncate min-w-0 flex-1" title={node.publicKey}>
|
||||
@@ -163,6 +170,8 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{node.meshmapper && <MeshMapperSection mm={node.meshmapper} />}
|
||||
|
||||
<Section title={t("nodeDetail.capabilities")}>
|
||||
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
|
||||
{node.minFirmwareVersion && <Field label={t("nodeDetail.minFirmware")} value={node.minFirmwareVersion} />}
|
||||
|
||||
@@ -39,6 +39,46 @@ export interface Node extends NodeSummary {
|
||||
clockDriftSeconds?: number;
|
||||
clockOutOfSync?: boolean;
|
||||
clockCheckedAt?: number; // epoch ms
|
||||
// MeshMapper's record for this repeater; node detail only, absent when the import is off or unmatched.
|
||||
meshmapper?: MeshMapperRepeater;
|
||||
}
|
||||
|
||||
// Imported from MeshMapper. Times are epoch ms; every string is admin-entered free text.
|
||||
export interface MeshMapperRepeater {
|
||||
iata?: string;
|
||||
fetchedAt: number;
|
||||
site: MeshMapperSite;
|
||||
carpeater: boolean;
|
||||
admins: string[];
|
||||
scopes: MeshMapperScope[];
|
||||
scopesCheckedAt?: number; // set with empty scopes = it answered with none; absent = never asked
|
||||
defaultScope?: MeshMapperDefaultScope; // separate from Node.defaultScope (Beacon's own match)
|
||||
}
|
||||
|
||||
export interface MeshMapperSite {
|
||||
power?: string;
|
||||
hardware?: string;
|
||||
antenna?: string;
|
||||
heightM?: number; // above ground
|
||||
heightAslM?: number;
|
||||
powerSource?: "mains" | "solar" | "battery" | "poe";
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface MeshMapperScope {
|
||||
scope: string;
|
||||
observedAt?: number;
|
||||
observedPackets?: number;
|
||||
proof?: "multibyte" | "floor";
|
||||
reportedAt?: number;
|
||||
uploadedAt?: number;
|
||||
}
|
||||
|
||||
export interface MeshMapperDefaultScope {
|
||||
name?: string; // only when state is "named"
|
||||
state: "named" | "unknown" | "none";
|
||||
updatedAt?: number;
|
||||
source?: "advert" | "reported";
|
||||
}
|
||||
|
||||
// First-hop neighbor of a node, from GET /nodes/{id}/neighbors (bare array, no pagination).
|
||||
|
||||
@@ -282,7 +282,42 @@
|
||||
"noRecentPackets": "No recent packets",
|
||||
"observations_one": "{{formatted}} obs",
|
||||
"observations_other": "{{formatted}} obs",
|
||||
"hops": "Hops"
|
||||
"hops": "Hops",
|
||||
"hardware": "Hardware",
|
||||
"power": "Power",
|
||||
"antenna": "Antenna",
|
||||
"powerSource": "Power source",
|
||||
"powerSources": {
|
||||
"mains": "Mains",
|
||||
"solar": "Solar",
|
||||
"battery": "Battery",
|
||||
"poe": "PoE"
|
||||
},
|
||||
"height": "Height",
|
||||
"heightAgl": "{{value}} m AGL",
|
||||
"heightAsl": "{{value}} m ASL",
|
||||
"admins": "Admins",
|
||||
"mmDefaultScope": "Default scope",
|
||||
"scopeState": {
|
||||
"unknown": "Unknown",
|
||||
"none": "None"
|
||||
},
|
||||
"scopeSource": {
|
||||
"advert": "advert",
|
||||
"reported": "reported"
|
||||
},
|
||||
"confirmedScopes": "Confirmed scopes",
|
||||
"noConfirmedScopes": "None confirmed",
|
||||
"observedPackets_one": "observed {{count}} packet",
|
||||
"observedPackets_other": "observed {{count}} packets",
|
||||
"observed": "observed",
|
||||
"reported": "reported",
|
||||
"uploaded": "uploaded",
|
||||
"meshmapper": "MeshMapper",
|
||||
"fromMeshMapper": "From MeshMapper",
|
||||
"updated": "updated",
|
||||
"carpeater": "CARpeater",
|
||||
"carpeaterHint": "Mobile repeater in a vehicle: its position moves."
|
||||
},
|
||||
"nodes": {
|
||||
"lastHeard": "last heard {{ago}}",
|
||||
|
||||
@@ -72,3 +72,19 @@ describe("Tooltip (mobile)", () => {
|
||||
expect(onParentClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tooltip focusable", () => {
|
||||
it("is a tab stop described by its label for keyboard and screen-reader users", () => {
|
||||
setMobile(false);
|
||||
render(<Tooltip label="tip text" focusable><span>target</span></Tooltip>);
|
||||
const trigger = screen.getByText("target").parentElement!;
|
||||
expect(trigger).toHaveAttribute("tabindex", "0");
|
||||
expect(trigger).toHaveAccessibleDescription("tip text");
|
||||
});
|
||||
|
||||
it("is not a tab stop by default", () => {
|
||||
setMobile(false);
|
||||
render(<Tooltip label="tip text"><span>target</span></Tooltip>);
|
||||
expect(screen.getByText("target").parentElement).not.toHaveAttribute("tabindex");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
|
||||
import { NodeDetailPanel } from "../../../src/features/nodes/NodeDetailPanel";
|
||||
import i18n from "../../../src/i18n";
|
||||
import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client";
|
||||
import type { Node, NodeNeighbor } from "../../../src/features/nodes/types";
|
||||
import type { MeshMapperRepeater, Node, NodeNeighbor } from "../../../src/features/nodes/types";
|
||||
|
||||
vi.mock("../../../src/api/client", () => ({
|
||||
getNode: vi.fn(),
|
||||
@@ -45,8 +45,8 @@ function renderPanel(onViewNode = vi.fn()) {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
render(<NodeDetailPanel nodeId="node-self" onClose={vi.fn()} onViewObserver={vi.fn()} onViewNode={onViewNode} />, { wrapper });
|
||||
return { onViewNode };
|
||||
const result = render(<NodeDetailPanel nodeId="node-self" onClose={vi.fn()} onViewObserver={vi.fn()} onViewNode={onViewNode} />, { wrapper });
|
||||
return { ...result, onViewNode };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -227,3 +227,117 @@ describe("NodeDetailPanel in French", () => {
|
||||
expect(await screen.findByText("Possiblement étranger")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeDetailPanel MeshMapper", () => {
|
||||
const meshmapper: MeshMapperRepeater = {
|
||||
iata: "YOW",
|
||||
fetchedAt: Date.now() - 60_000,
|
||||
site: {
|
||||
power: "1W", hardware: "RAK4631", antenna: "5.8 dBi fibreglass",
|
||||
heightM: 12, heightAslM: 341.6, powerSource: "solar",
|
||||
notes: "On the fire tower, east face",
|
||||
},
|
||||
carpeater: false,
|
||||
admins: ["MrAlders0n"],
|
||||
scopes: [
|
||||
{ scope: "yow", observedAt: Date.now() - 3_600_000, observedPackets: 37, proof: "multibyte", reportedAt: Date.now() - 7_200_000 },
|
||||
],
|
||||
scopesCheckedAt: Date.now() - 7_200_000,
|
||||
defaultScope: { name: "#yow", state: "named", updatedAt: Date.now() - 180_000, source: "advert" },
|
||||
};
|
||||
const empty: MeshMapperRepeater = { fetchedAt: 1, site: {}, carpeater: false, admins: [], scopes: [] };
|
||||
|
||||
it("shows MeshMapper's site details, admins and scopes", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper });
|
||||
renderPanel();
|
||||
|
||||
expect(await screen.findByText("MeshMapper")).toBeInTheDocument();
|
||||
expect(screen.getByText("RAK4631")).toBeInTheDocument();
|
||||
expect(screen.getByText("1W")).toBeInTheDocument();
|
||||
expect(screen.getByText("5.8 dBi fibreglass")).toBeInTheDocument();
|
||||
expect(screen.getByText("Solar")).toBeInTheDocument();
|
||||
expect(screen.getByText("12 m AGL · 341.6 m ASL")).toBeInTheDocument();
|
||||
expect(screen.getByText("MrAlders0n")).toBeInTheDocument();
|
||||
expect(screen.getByText("#yow")).toBeInTheDocument();
|
||||
expect(screen.getByText("yow")).toBeInTheDocument();
|
||||
expect(screen.getByText("observed 37 packets · reported")).toBeInTheDocument();
|
||||
expect(screen.getByText("On the fire tower, east face")).toBeInTheDocument();
|
||||
expect(screen.getByText(/From MeshMapper/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("CARpeater")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never shows MeshMapper's proof type", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", observedPackets: 3, proof: "floor" }] } });
|
||||
renderPanel();
|
||||
expect(await screen.findByText("observed 3 packets")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/floor/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("describes a scope confirmed only by report or upload", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", reportedAt: 1, uploadedAt: 2 }] } });
|
||||
renderPanel();
|
||||
expect(await screen.findByText("reported · uploaded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows height above ground alone when sea level is missing", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12 } } });
|
||||
renderPanel();
|
||||
expect(await screen.findByText("12 m AGL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("formats heights in the reader's locale", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12.25, heightAslM: 341.6 } } });
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
renderPanel();
|
||||
expect(await screen.findByText("12,3 m AGL · 341,6 m ASL")).toBeInTheDocument();
|
||||
await act(() => i18n.changeLanguage("en"));
|
||||
});
|
||||
|
||||
it("is hidden when the node has no MeshMapper record", async () => {
|
||||
renderPanel();
|
||||
await screen.findByText("Self Node");
|
||||
expect(screen.queryByText("MeshMapper")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["nothing set", empty],
|
||||
["only a CARpeater flag", { ...empty, carpeater: true }],
|
||||
["only a sea-level height", { ...empty, site: { heightAslM: 341.6 } }],
|
||||
])("is hidden when the record has %s", async (_, mm) => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper: mm });
|
||||
renderPanel();
|
||||
await screen.findByText("Self Node");
|
||||
expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an unknown default scope and an answered-but-empty scope list", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopesCheckedAt: 1, defaultScope: { state: "unknown" } } });
|
||||
renderPanel();
|
||||
expect(await screen.findByText("Unknown")).toBeInTheDocument();
|
||||
expect(screen.getByText("None confirmed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a default scope of none", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, defaultScope: { state: "none" } } });
|
||||
renderPanel();
|
||||
expect(await screen.findByText("None")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("badges a CARpeater next to the name, reachable by keyboard", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, carpeater: true } });
|
||||
renderPanel();
|
||||
const badge = await screen.findByText("CARpeater");
|
||||
expect(badge.className).not.toContain("uppercase");
|
||||
const trigger = badge.parentElement!;
|
||||
expect(trigger).toHaveAttribute("tabindex", "0");
|
||||
expect(trigger).toHaveAccessibleDescription("Mobile repeater in a vehicle: its position moves.");
|
||||
});
|
||||
|
||||
it("renders admin-entered markup as plain text", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, site: { notes: "<b>bold</b>" } } });
|
||||
const { container } = renderPanel();
|
||||
expect(await screen.findByText("<b>bold</b>")).toBeInTheDocument();
|
||||
expect(container.querySelector("b")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user