mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-20 08:31:56 +00:00
Atlas: one-row phone toolbar, uniform card heights; Observers notes in a tip
This commit is contained in:
@@ -74,7 +74,7 @@ export function AtlasCard({
|
||||
const hours = sample.counts.length;
|
||||
const activeHours = sample.present.filter(Boolean).length;
|
||||
|
||||
// The list view holds the tiles' height so flipping never resizes the card.
|
||||
// The list view is fixed to the tiles' height and scrolls inside it, so flipping never resizes the card.
|
||||
const body = useRef<HTMLDivElement>(null);
|
||||
const [held, setHeld] = useState<number>();
|
||||
const toggleHeard = () => {
|
||||
@@ -130,7 +130,7 @@ export function AtlasCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card title={title} right={actions} wrap>
|
||||
<Card title={title} right={actions} wrap className="flex h-full flex-col">
|
||||
{query.isPending && <p role="status" className="text-sm text-text-muted">{t("common.loading")}</p>}
|
||||
{query.isError && isNotFound(query.error) && (
|
||||
<p role="alert" className="text-sm text-text-muted">{t("nodeDetail.notFound")}</p>
|
||||
@@ -144,9 +144,10 @@ export function AtlasCard({
|
||||
</p>
|
||||
)}
|
||||
{node && !heardOpen && (
|
||||
<div ref={body} className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div ref={body} className="flex flex-1 flex-col gap-3">
|
||||
<div className="grid flex-1 grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
compact
|
||||
label={t("signal.receptions")}
|
||||
value={formatCount(sample.reports.length)}
|
||||
accent={c.series[0]!}
|
||||
@@ -155,18 +156,21 @@ export function AtlasCard({
|
||||
sublabel={capped ? t("atlas.sampleCapped", { limit: REPORT_LIMIT }) : t(`atlas.ranges.${range}`)}
|
||||
/>
|
||||
<StatCard
|
||||
compact
|
||||
label={t("signal.meanSnr")}
|
||||
value={average(sample.snr, "dB")}
|
||||
accent={c.series[2]!}
|
||||
spark={sample.snrHourly}
|
||||
/>
|
||||
<StatCard
|
||||
compact
|
||||
label={t("signal.meanRssi")}
|
||||
value={average(sample.rssi, "dBm")}
|
||||
accent={c.series[1]!}
|
||||
spark={sample.rssiHourly}
|
||||
/>
|
||||
<StatCard
|
||||
compact
|
||||
label={t("signal.hoursWithRecords")}
|
||||
value={`${activeHours}/${hours}`}
|
||||
accent={c.series[3]!}
|
||||
@@ -182,13 +186,15 @@ export function AtlasCard({
|
||||
<span>
|
||||
{t("atlas.meanHops")} {average(sample.hops)}
|
||||
</span>
|
||||
<span>{t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })}</span>
|
||||
<InfoTip text={[t("atlas.sampleHelp", { limit: REPORT_LIMIT }), capped && t("atlas.cappedHelp")]} />
|
||||
<span className="whitespace-nowrap">
|
||||
{t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })}{" "}
|
||||
<InfoTip text={[t("atlas.sampleHelp", { limit: REPORT_LIMIT }), capped && t("atlas.cappedHelp")]} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{node && heardOpen && (
|
||||
<div className="flex flex-col font-mono text-xs" style={{ minHeight: held }}>
|
||||
<div className="flex flex-col overflow-hidden font-mono text-xs" style={{ height: held }}>
|
||||
{!latest ? (
|
||||
<p className="text-text-muted">{t("atlas.noReports")}</p>
|
||||
) : heard.isPending ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EmptyState } from "../../components/EmptyState";
|
||||
import { InfoTip } from "../../components/InfoTip";
|
||||
import { SearchBar, type SearchFieldOption } from "../../components/SearchBar";
|
||||
import { Tooltip } from "../../components/Tooltip";
|
||||
import { useIsMobile } from "../../hooks/useMediaQuery";
|
||||
import { formatHex } from "../../lib/formatters";
|
||||
import { nodeSearchParams } from "../nodes/node-search";
|
||||
import type { NodeSummary } from "../nodes/types";
|
||||
@@ -26,6 +27,8 @@ const SEARCH_MIN = 2;
|
||||
export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { active?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { saved, update, storageFailed } = useSavedAtlas();
|
||||
// Same single-row bar the other tabs use on a phone: the search shrinks, nothing wraps.
|
||||
const isMobile = useIsMobile();
|
||||
const [search, setSearch] = useState("");
|
||||
const [field, setField] = useState("name");
|
||||
const [heardKey, setHeardKey] = useState<string | null>(null);
|
||||
@@ -83,39 +86,45 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
|
||||
|
||||
return (
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col" aria-label={t("tabs.MyAtlas")}>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border bg-bg-surface px-4 py-2.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3 font-mono text-[11px] text-text-muted">
|
||||
<span>{t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })}</span>
|
||||
<span role="status" className={storageFailed ? "text-warn" : undefined}>
|
||||
{t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")}
|
||||
</span>
|
||||
</div>
|
||||
<Segmented
|
||||
className="shrink-0"
|
||||
options={rangeOptions}
|
||||
value={saved.range}
|
||||
onChange={(range) => update((value) => ({ ...value, range: range as AtlasRange }))}
|
||||
ariaLabel={t("atlas.range")}
|
||||
<div
|
||||
className={`flex shrink-0 items-center gap-1.5 gap-y-1.5 border-b border-border-subtle bg-bg-base px-4 py-2 ${isMobile ? "" : "flex-wrap"}`}
|
||||
role="toolbar"
|
||||
aria-label={t("atlas.addNodes")}
|
||||
>
|
||||
<SearchBar
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
fields={fields}
|
||||
field={field}
|
||||
onFieldChange={changeField}
|
||||
inputLabel={t("atlas.search")}
|
||||
/>
|
||||
{!isMobile && <InfoTip text={t("atlas.searchHelp", { min: SEARCH_MIN, limit: ATLAS_LIMIT })} />}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[11px] text-text-muted">
|
||||
<Tooltip label={t("atlas.localOnly")}>
|
||||
<span className={`rounded-sm border px-2.5 py-1 ${full ? "border-warn/15 bg-warn/7 text-warn" : "border-border bg-bg-surface"}`}>
|
||||
{t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{storageFailed && <span role="status" className="text-warn">{t("atlas.storageFailed")}</span>}
|
||||
{!isMobile && <span>{t("atlas.range")}</span>}
|
||||
<Segmented
|
||||
size="xs"
|
||||
options={rangeOptions}
|
||||
value={saved.range}
|
||||
onChange={(range) => update((value) => ({ ...value, range: range as AtlasRange }))}
|
||||
ariaLabel={t("atlas.range")}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1600px] flex-col gap-3.5 p-4">
|
||||
<Card
|
||||
title={t("atlas.addNodes")}
|
||||
right={<InfoTip text={t("atlas.searchHelp", { min: SEARCH_MIN, limit: ATLAS_LIMIT })} />}
|
||||
>
|
||||
<SearchBar
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
fields={fields}
|
||||
field={field}
|
||||
onFieldChange={changeField}
|
||||
inputLabel={t("atlas.search")}
|
||||
/>
|
||||
{hint && <p className="mt-2 font-mono text-xs text-text-muted">{t(hint, { min: SEARCH_MIN })}</p>}
|
||||
{ready && (
|
||||
<div className="mt-2 font-mono text-xs">
|
||||
{results.isPending ? (
|
||||
{typed.length > 0 && (
|
||||
<Card title={t("atlas.matches")}>
|
||||
<div className="font-mono text-xs">
|
||||
{hint ? (
|
||||
<p className="text-text-muted">{t(hint, { min: SEARCH_MIN })}</p>
|
||||
) : results.isPending ? (
|
||||
<p role="status" className="text-text-muted">{t("common.loading")}</p>
|
||||
) : results.isError ? (
|
||||
<p role="alert" className="text-danger">
|
||||
@@ -134,7 +143,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
|
||||
return (
|
||||
<li
|
||||
key={node.publicKey}
|
||||
className="flex items-center justify-between gap-2 border-t border-border-subtle py-1.5"
|
||||
className="flex items-center justify-between gap-2 border-t border-border-subtle py-1.5 first:border-t-0"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className={`truncate ${node.name ? "text-text-bright" : "italic text-text-dim"}`}>{label}</span>
|
||||
@@ -157,13 +166,13 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{results.data?.hasMore && (
|
||||
{results.data?.hasMore && !hint && (
|
||||
<p className="mt-1 text-text-muted">{t("atlas.refine", { limit: SEARCH_LIMIT })}</p>
|
||||
)}
|
||||
{full && <p className="mt-1 text-warn">{t("atlas.limitReached", { limit: ATLAS_LIMIT })}</p>}
|
||||
{full && !hint && <p className="mt-1 text-warn">{t("atlas.limitReached", { limit: ATLAS_LIMIT })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
{saved.nodes.length === 0 ? (
|
||||
<EmptyState title={t("atlas.emptyTitle")} subtitle={t("atlas.emptyDescription", { limit: ATLAS_LIMIT })} />
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { InfoTip } from "../../components/InfoTip";
|
||||
import { formatCount, formatUtc } from "../../lib/formatters";
|
||||
import { SCROLL_BOTTOM_THRESHOLD_PX } from "../../lib/constants";
|
||||
import { Segmented } from "../stats/Segmented";
|
||||
@@ -35,24 +36,30 @@ export function ObserverSidebar({ observers, filtered, isPending, isError, unsup
|
||||
}, [loadNearEnd, observers.length]);
|
||||
const messageClass = "px-3 py-3 text-center font-mono text-[11px] text-text-dim";
|
||||
const retry = <button type="button" onClick={onRetry} className={`${messageClass} w-full text-danger`}>{t("common.loadFailed")} · {t("observerPage.retry")}</button>;
|
||||
const partial = coverage != null && coverage.status !== "complete";
|
||||
// Incomplete coverage is a footnote in the tip; counts missing altogether changes the list, so that stays visible.
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 w-full flex-col bg-bg-base md:rounded-lg md:border md:border-border md:bg-bg-surface md:p-3.5">
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border-subtle px-4 py-2 md:mb-2.5 md:border-0 md:p-0">
|
||||
<div className="font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal">{t("tabs.Observers")}</div>
|
||||
<Segmented size="sm" ariaLabel={t("observerPage.sort")} value={sort} onChange={v => onSortChange(v as ObserverDirectorySort)}
|
||||
options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} />
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border-subtle px-4 py-2 md:mb-2.5 md:border-0 md:p-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="hidden font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal md:block">{t("tabs.Observers")}</div>
|
||||
{windowEnd != null && <InfoTip text={[
|
||||
partial && t("observerPage.countsPartial"),
|
||||
t("observerPage.directoryPeriod"),
|
||||
t("observerPage.countsThrough", { time: formatUtc(windowEnd) }),
|
||||
]} />}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{onRefresh && !unsupported && <button type="button" onClick={onRefresh} disabled={isPending || isFetchingNextPage}
|
||||
className="font-mono text-[10px] text-primary disabled:opacity-50">{t("observerPage.refresh")}</button>}
|
||||
<Segmented size="sm" ariaLabel={t("observerPage.sort")} value={sort} onChange={v => onSortChange(v as ObserverDirectorySort)}
|
||||
options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} />
|
||||
</div>
|
||||
</div>
|
||||
{onRefresh && !unsupported && <button type="button" onClick={onRefresh} disabled={isPending || isFetchingNextPage}
|
||||
className="mb-2 self-end px-3 font-mono text-[10px] text-primary disabled:opacity-50">{t("observerPage.refresh")}</button>}
|
||||
{coverage && coverage.status !== "complete" && <p className={messageClass}>
|
||||
{t(maxObservationCount != null ? "observerPage.countsPartial"
|
||||
: effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")}
|
||||
{partial && maxObservationCount == null && <p className={messageClass}>
|
||||
{t(effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")}
|
||||
</p>}
|
||||
{windowEnd != null && <div className="shrink-0 px-3 pb-2 font-mono text-[10px] text-text-dim">
|
||||
<p>{t("observerPage.directoryPeriod")}</p>
|
||||
<p>{t("observerPage.countsThrough", { time: formatUtc(windowEnd) })}</p>
|
||||
</div>}
|
||||
{unsupported ? <div className={messageClass}><p>{t("observerPage.upgradeRequired")}</p>
|
||||
<button type="button" onClick={onRetry} className="mt-2 text-primary">{t("observerPage.retry")}</button></div>
|
||||
: isPending ? <div className={messageClass}>{t("common.loading")}</div>
|
||||
|
||||
@@ -90,6 +90,7 @@ export function StatCard({
|
||||
markPeak,
|
||||
presence,
|
||||
sublabel,
|
||||
compact,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
@@ -98,15 +99,18 @@ export function StatCard({
|
||||
markPeak?: boolean;
|
||||
presence?: boolean[];
|
||||
sublabel?: ReactNode;
|
||||
compact?: boolean; // narrow tiles: value stays text-xl so "-77.2 dBm" never wraps
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-bg-surface px-3.5 py-3">
|
||||
<div className="flex flex-col rounded-lg border border-border bg-bg-surface px-3.5 py-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
|
||||
<span className="min-w-0 font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted">{label}</span>
|
||||
{sublabel && <span className="min-w-0 max-w-full truncate font-mono text-[9px] text-text-dim">{sublabel}</span>}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-xl font-bold tabular-nums text-text-bright sm:text-2xl">{value}</div>
|
||||
{presence ? <PresenceStrip hours={presence} color={accent} /> : spark ? <Sparkline values={spark} color={accent} markPeak={markPeak} /> : sparkSpacer}
|
||||
<div className={`mt-0.5 font-mono text-xl font-bold tabular-nums text-text-bright ${compact ? "" : "sm:text-2xl"}`}>{value}</div>
|
||||
<div className="mt-auto">
|
||||
{presence ? <PresenceStrip hours={presence} color={accent} /> : spark ? <Sparkline values={spark} color={accent} markPeak={markPeak} /> : sparkSpacer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -958,12 +958,14 @@
|
||||
},
|
||||
"atlas": {
|
||||
"addNodes": "Add nodes",
|
||||
"matches": "Matches",
|
||||
"search": "Find a node",
|
||||
"searchHelp": "Searches every region by name or public-key prefix (hex, at least {{min}} characters). Up to {{limit}} nodes.",
|
||||
"searchTooShort": "Type at least {{min}} characters.",
|
||||
"searchNotHex": "A public key prefix is hex only (0-9, a-f).",
|
||||
"savedCount_one": "{{count}} / {{limit}} saved",
|
||||
"savedCount_other": "{{count}} / {{limit}} saved",
|
||||
"savedCountShort": "{{count}}/{{limit}}",
|
||||
"localOnly": "Saved in this browser",
|
||||
"storageFailed": "Browser storage is unavailable; changes last only for this visit.",
|
||||
"limitReached": "All {{limit}} slots are in use. Remove a card to add another.",
|
||||
|
||||
@@ -959,6 +959,7 @@
|
||||
},
|
||||
"atlas": {
|
||||
"addNodes": "Ajouter des nœuds",
|
||||
"matches": "Résultats",
|
||||
"search": "Trouver un nœud",
|
||||
"searchHelp": "Recherche dans toutes les régions par nom ou préfixe de clé publique (hexadécimal, au moins {{min}} caractères). Jusqu’à {{limit}} nœuds.",
|
||||
"searchTooShort": "Saisissez au moins {{min}} caractères.",
|
||||
@@ -966,6 +967,7 @@
|
||||
"savedCount_one": "{{count}} / {{limit}} enregistré",
|
||||
"savedCount_many": "{{count}} / {{limit}} enregistrés",
|
||||
"savedCount_other": "{{count}} / {{limit}} enregistrés",
|
||||
"savedCountShort": "{{count}}/{{limit}}",
|
||||
"localOnly": "Enregistré dans ce navigateur",
|
||||
"storageFailed": "Le stockage du navigateur est indisponible ; les modifications ne durent que cette visite.",
|
||||
"limitReached": "Les {{limit}} emplacements sont utilisés. Retirez une fiche pour en ajouter une autre.",
|
||||
|
||||
@@ -98,6 +98,16 @@ describe("AtlasCard", () => {
|
||||
expect(actions.parentElement).toHaveClass("flex-wrap");
|
||||
});
|
||||
|
||||
it("fills its grid row so neighbouring cards stay the same height whatever their titles wrap to", async () => {
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "Renamed repeater" });
|
||||
const tiles = screen.getByText("Observations").closest(".grid")!;
|
||||
expect(tiles).toHaveClass("flex-1");
|
||||
expect(tiles.parentElement).toHaveClass("flex-1");
|
||||
expect(tiles.closest(".rounded-lg.border.p-3\\.5")).toHaveClass("flex", "h-full", "flex-col");
|
||||
expect(screen.getByText("-106.0 dBm")).toHaveClass("text-xl");
|
||||
});
|
||||
|
||||
it("labels an unnamed node by its ID prefix, dimmed, as the Nodes page does", async () => {
|
||||
vi.mocked(api.getNode).mockResolvedValue({ ...node, name: "" });
|
||||
mount({ pin: { ...pin, name: "" } });
|
||||
@@ -144,7 +154,8 @@ describe("AtlasCard", () => {
|
||||
expect(summary).toHaveTextContent(/ago$/);
|
||||
expect(screen.getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument();
|
||||
expect(summary.parentElement).toHaveStyle({ minHeight: "240px" });
|
||||
expect(summary.parentElement).toHaveStyle({ height: "240px" });
|
||||
expect(summary.nextElementSibling).toHaveClass("overflow-y-auto");
|
||||
expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1);
|
||||
expect(screen.getAllByText("YKF")[0]).toHaveClass("bg-primary/6");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Home listener" }));
|
||||
|
||||
@@ -65,6 +65,33 @@ describe("My Atlas", () => {
|
||||
expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal));
|
||||
});
|
||||
|
||||
it("keeps the search in a toolbar with the saved count chip beside the range picker, and lists matches only while typing", async () => {
|
||||
save();
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "Renamed repeater" });
|
||||
const bar = screen.getByRole("toolbar", { name: "Add nodes" });
|
||||
expect(within(bar).getByRole("textbox", { name: "Find a node" })).toBeInTheDocument();
|
||||
expect(within(bar).getByText("1 / 12 saved")).toHaveClass("rounded-sm");
|
||||
expect(within(bar).getByRole("button", { name: "3d" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Matches")).not.toBeInTheDocument();
|
||||
typeSearch("repeater");
|
||||
expect(await screen.findByRole("button", { name: "Renamed repeater is saved" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Matches")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the toolbar to one row on a phone: short count chip, picker, no info tip", async () => {
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({ matches: /max-width/.test(query), media: query, addEventListener: () => {}, removeEventListener: () => {} }));
|
||||
save();
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "Renamed repeater" });
|
||||
const bar = screen.getByRole("toolbar", { name: "Add nodes" });
|
||||
expect(bar).not.toHaveClass("flex-wrap");
|
||||
expect(within(bar).getByText("1/12")).toHaveClass("rounded-sm");
|
||||
expect(within(bar).queryByText("1 / 12 saved")).not.toBeInTheDocument();
|
||||
expect(within(bar).getByRole("button", { name: "3d" })).toBeInTheDocument();
|
||||
expect(within(bar).queryByText(/More information/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty state and queries nothing with no pins", () => {
|
||||
mount();
|
||||
expect(screen.getByText("No nodes saved")).toBeInTheDocument();
|
||||
|
||||
@@ -93,6 +93,17 @@ it.each(["partial", "unavailable"] as const)("renders available traffic and zero
|
||||
expect(rows[2].querySelector('[style]')).toHaveStyle({ width: "0%" });
|
||||
expect(screen.queryByText(/counts unavailable/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Name order/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/coverage is incomplete/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Available history, up to 7 days/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/More information: Counts use available history; coverage is incomplete\. Available history, up to 7 days Counts through 2026-10-0\d \d\d:00 UTC/)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Available history, up to 7 days")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the history notes in the info tip and the panel title off the phone layout", () => {
|
||||
render(<ObserverSidebar {...props} onRefresh={vi.fn()} windowEnd={1791162000000} />);
|
||||
expect(screen.getByText(/^More information: Available history, up to 7 days Counts through/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/coverage is incomplete/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Observers")).toHaveClass("hidden", "md:block");
|
||||
const controls = screen.getByRole("button", { name: "Refresh" }).parentElement!;
|
||||
expect(controls).toContainElement(screen.getByRole("button", { name: "Name" }));
|
||||
expect(controls).toHaveClass("ml-auto");
|
||||
expect(controls.parentElement).toHaveClass("flex-wrap");
|
||||
});
|
||||
|
||||
@@ -11,6 +11,15 @@ it("keeps the header on one line unless a caller asks for the right slot to wrap
|
||||
expect(screen.getByRole("button", { name: "Act" }).parentElement).toHaveClass("flex-wrap");
|
||||
});
|
||||
|
||||
it("keeps a compact tile's value small and its chart at the foot so stretched tiles stay uniform", () => {
|
||||
const { container } = render(<StatCard label="Mean RSSI" value="-77.2 dBm" accent="red" spark={[1, 2]} compact />);
|
||||
const tile = container.firstElementChild!;
|
||||
expect(tile).toHaveClass("flex", "flex-col");
|
||||
expect(screen.getByText("-77.2 dBm")).toHaveClass("text-xl");
|
||||
expect(screen.getByText("-77.2 dBm")).not.toHaveClass("sm:text-2xl");
|
||||
expect(container.querySelector("svg")!.parentElement).toHaveClass("mt-auto");
|
||||
});
|
||||
|
||||
it("marks the peak hour on a sparkline when asked", () => {
|
||||
const { container } = render(<StatCard label="Busiest" value="9" accent="red" spark={[1, 9, null, 4]} markPeak />);
|
||||
const dot = container.querySelector("[data-peak]")!;
|
||||
|
||||
Reference in New Issue
Block a user