umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben
Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt. - Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten. - Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege, Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt Zurueckspielen mit Rueckfrage. - Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen. - Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link. - Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3, shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/ JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk. - Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf. - 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3d2c9549fb
commit
907289d7dc
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
/** Rückfrage vor Schritten, die die Box umbauen (alles aktualisieren, zurückspielen, neu starten). */
|
||||
export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, children }: {
|
||||
offen: boolean
|
||||
titel: string
|
||||
text: string
|
||||
knopf: string
|
||||
gefahr?: boolean
|
||||
onJa: () => void
|
||||
onNein: () => void
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
|
||||
<DialogContent className="border-linie bg-panel sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="schild text-xl">{titel}</DialogTitle>
|
||||
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{children}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" onClick={onNein}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button variant={gefahr ? "gefahr" : "default"} onClick={onJa}>
|
||||
{knopf}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { lazy, Suspense, useState } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useHinweisAktion } from "@/lib/abfragen"
|
||||
import { post } from "@/lib/api"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import type { AktionsErgebnis, Hinweis, VerlaufEintrag } from "@/lib/typen"
|
||||
import { seit, vor } from "@/lib/zeit"
|
||||
|
||||
// Erst laden, wenn jemand ein Protokoll öffnet (Radix-Dialog gehört nicht ins Start-Bündel).
|
||||
const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster })))
|
||||
|
||||
/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */
|
||||
function Punkt({ h }: { h: Hinweis }) {
|
||||
const aktion = useHinweisAktion()
|
||||
const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null)
|
||||
const rot = h.stufe === "rot"
|
||||
|
||||
async function ausloesen(aid: string, label: string) {
|
||||
if (aid === "protokoll") {
|
||||
try {
|
||||
const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`)
|
||||
setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." })
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
}
|
||||
return
|
||||
}
|
||||
aktion.mutate({ hinweis: h.id, aktion: aid, label })
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="flex flex-col gap-2.5 border-t border-linie pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="min-w-0 font-sans text-[17px] leading-snug font-semibold">{h.titel}</h3>
|
||||
<span aria-hidden className="hidden h-0 flex-grow border-b-2 border-dotted border-linie-stark sm:block" />
|
||||
<span className={cn("schild shrink-0 text-base", rot ? "text-rot" : "text-bernstein")}>
|
||||
{rot ? "Jetzt" : "Prüfen"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="ziffern text-[13px] text-text-3">
|
||||
{seit(h.seit)} · zuletzt {vor(h.zuletzt)}
|
||||
</p>
|
||||
{h.text && <p className="text-[15px] leading-relaxed break-words text-text-2">{h.text}</p>}
|
||||
{h.aktionen.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2.5 pt-1">
|
||||
{h.aktionen.map((a, i) => (
|
||||
<Button
|
||||
key={a.id}
|
||||
variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"}
|
||||
size="sm"
|
||||
disabled={aktion.isPending}
|
||||
onClick={() => ausloesen(a.id, a.label)}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{protokoll && (
|
||||
<Suspense fallback={null}>
|
||||
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf: VerlaufEintrag[] }) {
|
||||
const heute = new Date().toDateString()
|
||||
const erledigtHeute = verlauf.filter(
|
||||
(v) => (v.art === "erledigt" || v.art === "auto") && new Date(v.ts * 1000).toDateString() === heute,
|
||||
)
|
||||
if (hinweise.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-t border-linie pt-4">
|
||||
<p className="text-[15px] text-text-2">Keine offenen Punkte. Der Wächter prüft jede Minute.</p>
|
||||
{erledigtHeute.length > 0 && (
|
||||
<ul className="flex flex-col gap-1 text-sm text-text-3">
|
||||
{erledigtHeute.slice(0, 4).map((v) => (
|
||||
<li key={`${v.id}-${v.ts}`}>
|
||||
<span className="ziffern">{vor(v.ts)}</span> · {v.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{hinweise.map((h) => (
|
||||
<Punkt key={h.id} h={h} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
export type ChipArt = "gruen" | "bernstein" | "cyan" | "rot" | "grau"
|
||||
|
||||
const ART: Record<ChipArt, string> = {
|
||||
gruen: "border-gruen-rand bg-gruen-grund text-gruen",
|
||||
bernstein: "border-bernstein/60 bg-bernstein-grund text-bernstein",
|
||||
cyan: "border-cyan-rand bg-cyan-grund text-cyan",
|
||||
rot: "border-rot-rand bg-rot-grund text-rot-text",
|
||||
grau: "border-linie bg-erhaben text-text-2",
|
||||
}
|
||||
|
||||
/** Kleines Zustandsschild („GELADEN“, „PASST“, „FEHLGESCHLAGEN“). */
|
||||
export function Chip({ art = "grau", children }: { art?: ChipArt; children: ReactNode }) {
|
||||
return (
|
||||
<span className={cn("schild inline-flex h-7 items-center rounded-full border px-2.5 text-xs whitespace-nowrap", ART[art])}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Einheitlicher Platzhalter, solange Daten laden oder wenn die Box nicht antwortet. */
|
||||
export function Zustandsfeld({ fehler, text }: { fehler?: boolean; text: string }) {
|
||||
return (
|
||||
<div
|
||||
role={fehler ? "alert" : "status"}
|
||||
className={cn(
|
||||
"rounded-2xl border px-5 py-8 text-center text-[15px]",
|
||||
fehler ? "border-rot-rand bg-rot-grund text-rot-text" : "border-linie bg-panel text-text-2",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { cn } from "cn"
|
||||
import type { FlugplanEintrag } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }> = {
|
||||
erledigt: { text: "Erledigt", farbe: "text-gruen" },
|
||||
fehler: { text: "Fehler", farbe: "text-rot" },
|
||||
geplant: { text: "Geplant", farbe: "text-text-3" },
|
||||
}
|
||||
|
||||
function Zeile({ e }: { e: FlugplanEintrag }) {
|
||||
const s = STATUS[e.status] ?? STATUS.geplant
|
||||
// Updates sind das Wichtigste im Plan — sie stehen in Cyan.
|
||||
const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan" : s.farbe
|
||||
return (
|
||||
<li className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5">
|
||||
<span className="ziffern text-sm text-text-3">{flugplanZeit(e.zeit)}</span>
|
||||
<span className="min-w-0 text-base">
|
||||
{e.titel}
|
||||
{e.text && <span className="text-text-3">, {e.text}</span>}
|
||||
</span>
|
||||
<span className={cn("schild text-[13px]", farbe)}>{s.text}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function Flugplan({ gelaufen, geplant }: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }) {
|
||||
const alle = [...gelaufen, ...geplant.slice(0, 5)]
|
||||
if (alle.length === 0) return <p className="text-[15px] text-text-2">Heute ist noch nichts gelaufen, und nichts ist geplant.</p>
|
||||
return <ol className="m-0 flex list-none flex-col p-0">{alle.map((e) => <Zeile key={`${e.titel}-${e.zeit}`} e={e} />)}</ol>
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Rundinstrument: 240°-Bogen wie ein Zeigerinstrument, Wert als Leuchtbogen, Zahl in der Mitte.
|
||||
import { bogenPfad } from "@/lib/anzeige"
|
||||
|
||||
export interface Zone {
|
||||
von: number
|
||||
bis: number
|
||||
farbe: string
|
||||
}
|
||||
|
||||
export function Instrument({
|
||||
anteil,
|
||||
farbe = "var(--cyan)",
|
||||
zonen = [],
|
||||
mitte,
|
||||
unter,
|
||||
beschriftung,
|
||||
ariaText,
|
||||
}: {
|
||||
anteil: number | null
|
||||
farbe?: string
|
||||
zonen?: Zone[]
|
||||
mitte: string
|
||||
unter?: string
|
||||
beschriftung: string
|
||||
ariaText: string
|
||||
}) {
|
||||
const zeigen = anteil != null && anteil > 0.004
|
||||
return (
|
||||
<figure className="m-0 flex min-w-0 flex-col items-center gap-1">
|
||||
<svg viewBox="0 0 200 150" className="h-auto w-full max-w-[200px]" role="img" aria-label={ariaText}>
|
||||
<path d={bogenPfad(0, 1, 75)} fill="none" stroke="var(--linie)" strokeWidth={12} strokeLinecap="round" />
|
||||
{zonen.map((z) => (
|
||||
<path key={`${z.von}-${z.bis}`} d={bogenPfad(z.von, z.bis, 86)} fill="none" stroke={z.farbe} strokeWidth={4} strokeLinecap="round" />
|
||||
))}
|
||||
{zeigen && (
|
||||
<path d={bogenPfad(0, anteil, 75)} fill="none" stroke={farbe} strokeWidth={12} strokeLinecap="round" />
|
||||
)}
|
||||
<text x="100" y="100" textAnchor="middle" className="ziffern" style={{ fontSize: 34, fontWeight: 500, fill: "var(--foreground)" }}>
|
||||
{mitte}
|
||||
</text>
|
||||
{unter && (
|
||||
<text x="100" y="122" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 14, fontWeight: 600, letterSpacing: "0.12em", fill: "var(--text-3)" }}>
|
||||
{unter.toUpperCase()}
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
<figcaption className="schild text-[15px] text-text-3">{beschriftung}</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cn } from "cn"
|
||||
import type { Lampe as LampenDaten, LampenZustand } from "@/lib/typen"
|
||||
|
||||
// Warnlampen wie im Flugzeug: Grün = läuft, dunkel = aus/auf Abruf, Bernstein = kümmern,
|
||||
// Cyan = Info, Rot = Störung. Bernstein und Rot leuchten, alles andere bleibt ruhig.
|
||||
const LAMPEN_STIL: Record<LampenZustand, { feld: string; name: string; wert: string }> = {
|
||||
ok: { feld: "border-gruen-rand bg-gruen-grund", name: "text-gruen", wert: "text-gruen-text" },
|
||||
aus: { feld: "border-aus-rand bg-aus-grund", name: "text-aus-text", wert: "text-text-3" },
|
||||
warn: { feld: "border-bernstein bg-bernstein-lampe lampe-atmet", name: "text-bernstein", wert: "text-[#f2c46b]" },
|
||||
info: { feld: "border-cyan-rand bg-cyan-grund", name: "text-cyan", wert: "text-cyan-text" },
|
||||
fehler: {
|
||||
feld: "border-rot bg-rot-grund shadow-[0_0_18px_rgba(255,90,79,0.35)]",
|
||||
name: "text-rot",
|
||||
wert: "text-rot-text",
|
||||
},
|
||||
}
|
||||
|
||||
const ZUSTAND_TEXT: Record<LampenZustand, string> = {
|
||||
ok: "in Ordnung",
|
||||
aus: "aus",
|
||||
warn: "braucht Aufmerksamkeit",
|
||||
info: "Info",
|
||||
fehler: "Störung",
|
||||
}
|
||||
|
||||
export function Lampe({ lampe }: { lampe: LampenDaten }) {
|
||||
const stil = LAMPEN_STIL[lampe.zustand] ?? LAMPEN_STIL.aus
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex min-h-[58px] flex-col items-center justify-center gap-0.5 rounded-lg border px-2 py-2 text-center",
|
||||
stil.feld,
|
||||
)}
|
||||
>
|
||||
<span className={cn("schild text-[17px] leading-none font-bold tracking-[0.18em]", stil.name)}>
|
||||
{lampe.label}
|
||||
</span>
|
||||
<span className={cn("ziffern text-xs uppercase", stil.wert)}>{lampe.wert}</span>
|
||||
<span className="sr-only">{ZUSTAND_TEXT[lampe.zustand]}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
/** Ein Feld des Instrumentenbretts: dunkle Platte, schmale Beschriftung oben links. */
|
||||
export function Panel({
|
||||
titel,
|
||||
rechts,
|
||||
children,
|
||||
className,
|
||||
id,
|
||||
}: {
|
||||
titel?: string
|
||||
rechts?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
id?: string
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
aria-label={titel}
|
||||
className={cn("flex min-w-0 flex-col gap-4 rounded-2xl border border-linie bg-panel p-5 sm:p-6", className)}
|
||||
>
|
||||
{(titel || rechts) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{titel && <h2 className="schild text-lg font-bold tracking-[0.22em] text-foreground">{titel}</h2>}
|
||||
{rechts}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
|
||||
/** Seitenschublade mit Protokolltext (Journal oder Hermes-Fehlerzeilen), unten zuerst gelesen. */
|
||||
export function Protokollfenster({ offen, titel, text, onSchliessen }: {
|
||||
offen: boolean
|
||||
titel: string
|
||||
text: string
|
||||
onSchliessen: () => void
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="right" className="w-full border-linie bg-panel sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Protokoll</SheetTitle>
|
||||
<SheetDescription className="text-text-2">{titel}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<pre className="ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
|
||||
{text}
|
||||
</pre>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cn } from "cn"
|
||||
import type { Lampe as LampenDaten, Start } from "@/lib/typen"
|
||||
import { hauptleuchte } from "@/lib/anzeige"
|
||||
import { Lampe } from "./Lampe"
|
||||
|
||||
type Zustand = Start["zustand"]
|
||||
|
||||
const LEUCHTE: Record<ReturnType<typeof hauptleuchte>["art"], string> = {
|
||||
ok: "border-gruen-rand bg-gruen-grund text-gruen",
|
||||
info: "border-cyan-rand bg-cyan-grund text-cyan",
|
||||
stumm: "border-aus-rand bg-aus-grund text-aus-text",
|
||||
gelb: "border-2 border-bernstein bg-bernstein-grund text-bernstein-hell lampe-atmet",
|
||||
rot: "border-2 border-rot bg-rot-grund text-rot shadow-[0_0_32px_rgba(255,90,79,0.3)]",
|
||||
}
|
||||
|
||||
export function Warnpanel({ zustand, lampen, onAnsehen }: {
|
||||
zustand: Zustand
|
||||
lampen: LampenDaten[]
|
||||
onAnsehen: () => void
|
||||
}) {
|
||||
const h = hauptleuchte(zustand)
|
||||
const aktiv = h.art === "gelb" || h.art === "rot"
|
||||
return (
|
||||
<section aria-label="Warnpanel" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAnsehen}
|
||||
disabled={!aktiv}
|
||||
aria-label={`${h.oben} ${h.mitte}`}
|
||||
className={cn(
|
||||
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
|
||||
LEUCHTE[h.art],
|
||||
aktiv ? "cursor-pointer" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<span className="schild text-sm tracking-[0.26em]">{h.oben}</span>
|
||||
<span className="text-[40px] leading-none font-bold tracking-[0.04em] sm:text-[44px]">{h.mitte}</span>
|
||||
<span className="font-sans text-sm opacity-80">{h.unten}</span>
|
||||
</button>
|
||||
<ul className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4">
|
||||
{lampen.map((l) => (
|
||||
<Lampe key={l.id} lampe={l} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { laufzeit } from "@/lib/zeit"
|
||||
|
||||
function Ziffer({ z }: { z: string }) {
|
||||
return (
|
||||
<span className="ziffern inline-flex h-14 w-[38px] items-center justify-center rounded-md border border-[#2a3036] bg-[#0b0d0f] text-[32px] font-medium">
|
||||
{z}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Laufzeit als mechanisches Zählwerk: TT T SS H. */
|
||||
export function Zaehlwerk({ sekunden }: { sekunden: number | null | undefined }) {
|
||||
const lz = laufzeit(sekunden)
|
||||
const tage = String(Math.min(lz?.[0] ?? 0, 99)).padStart(2, "0")
|
||||
const std = String(lz?.[1] ?? 0).padStart(2, "0")
|
||||
const text = lz ? `Laufzeit ohne Neustart: ${lz[0]} Tage, ${lz[1]} Stunden` : "Laufzeit unbekannt"
|
||||
return (
|
||||
<figure className="m-0 flex min-w-0 flex-col items-center justify-end gap-1">
|
||||
<div aria-label={text} role="img" className="flex h-[150px] max-w-full items-center gap-1.5 pb-5">
|
||||
<Ziffer z={lz ? tage[0] : "–"} />
|
||||
<Ziffer z={lz ? tage[1] : "–"} />
|
||||
<span className="schild mr-2 ml-0.5 text-lg font-bold text-text-3">T</span>
|
||||
<Ziffer z={lz ? std[0] : "–"} />
|
||||
<Ziffer z={lz ? std[1] : "–"} />
|
||||
<span className="schild ml-0.5 text-lg font-bold text-text-3">H</span>
|
||||
</div>
|
||||
<figcaption className="schild text-[15px] text-text-3">Ohne Neustart</figcaption>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { bogenPfad, hauptleuchte } from "@/lib/anzeige"
|
||||
import { Warnpanel } from "./Warnpanel"
|
||||
import type { Start } from "@/lib/typen"
|
||||
|
||||
const ruhig: Start["zustand"] = { stufe: "ok", anzahl: 0, waechter_wach: true, stand: 1, update_laeuft: false }
|
||||
|
||||
describe("bogenPfad", () => {
|
||||
it("beginnt links unten und endet bei vollem Ausschlag rechts unten", () => {
|
||||
expect(bogenPfad(0, 1, 75)).toBe("M 35.05 132.5 A 75 75 0 1 1 164.95 132.5")
|
||||
})
|
||||
|
||||
it("zeichnet 45 % Speicher als kleinen Bogen bis kurz vor die Spitze", () => {
|
||||
// 45 % von 240° = 108° → Endwinkel 102°, also knapp links der Senkrechten.
|
||||
expect(bogenPfad(0, 0.45, 75)).toBe("M 35.05 132.5 A 75 75 0 0 1 84.41 21.64")
|
||||
})
|
||||
|
||||
it("klemmt Werte außerhalb von 0…1", () => {
|
||||
expect(bogenPfad(-1, 2, 75)).toBe(bogenPfad(0, 1, 75))
|
||||
})
|
||||
})
|
||||
|
||||
describe("hauptleuchte", () => {
|
||||
it("bleibt ruhig, wenn nichts offen ist", () => {
|
||||
expect(hauptleuchte(ruhig)).toMatchObject({ art: "ok", mitte: "IN ORDNUNG" })
|
||||
})
|
||||
|
||||
it("zeigt Bernstein bei gelben und Rot bei roten Hinweisen", () => {
|
||||
expect(hauptleuchte({ ...ruhig, stufe: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 HINWEISE" })
|
||||
expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 HINWEIS" })
|
||||
})
|
||||
|
||||
it("meldet einen schweigenden Wächter vor allem anderen", () => {
|
||||
expect(hauptleuchte({ ...ruhig, waechter_wach: false, anzahl: 3, stufe: "rot" }).art).toBe("stumm")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Warnpanel", () => {
|
||||
it("zeigt jede Lampe mit Name und Wert", () => {
|
||||
render(
|
||||
<Warnpanel
|
||||
zustand={{ ...ruhig, stufe: "gelb", anzahl: 2 }}
|
||||
lampen={[
|
||||
{ id: "motor", label: "Motor", zustand: "ok", wert: "b11057" },
|
||||
{ id: "jobs", label: "Jobs", zustand: "warn", wert: "2 Hinweise" },
|
||||
]}
|
||||
onAnsehen={() => {}}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("Motor")).toBeInTheDocument()
|
||||
expect(screen.getByText("b11057")).toBeInTheDocument()
|
||||
expect(screen.getByText("braucht Aufmerksamkeit")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: /Achtung 2 HINWEISE/ })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user