diff --git a/web/src/app/fights/compare/page.tsx b/web/src/app/fights/compare/page.tsx index 25cb92c7..eb95acd0 100644 --- a/web/src/app/fights/compare/page.tsx +++ b/web/src/app/fights/compare/page.tsx @@ -24,10 +24,7 @@ import { type FightRow, type FightReadoutOut, } from "@/lib/api"; -import { PlayerReadoutDamage } from "@/components/PlayerReadoutDamage"; -import { PlayerReadoutHeal } from "@/components/PlayerReadoutHeal"; -import { PlayerReadoutBoons } from "@/components/PlayerReadoutBoons"; -import { PlayerReadoutDefense } from "@/components/PlayerReadoutDefense"; +import { CompareReadoutTable } from "@/components/CompareReadoutTable"; import { FightSummaryCards } from "@/components/FightSummaryCards"; const CONTAINER: React.CSSProperties = { @@ -376,30 +373,10 @@ function FightColumn({ )}
-
-

- Damage -

- -
-
-

- Heal -

- -
-
-

- Boons -

- -
-
-

- Defense -

- -
+ + + +
); diff --git a/web/src/components/CompareReadoutTable.tsx b/web/src/components/CompareReadoutTable.tsx new file mode 100644 index 00000000..53713669 --- /dev/null +++ b/web/src/components/CompareReadoutTable.tsx @@ -0,0 +1,274 @@ +"use client"; + +/** + * Native HTML table component for the fight compare page. + * + * Replaces the 4 AG Grid ``PlayerReadout*`` components with + * native HTML tables that match the ``ReadoutTabClient`` style. + * Sortable by clicking column headers (per-table independent sort). + * + * Boon icons (14 official GW2 icons) are rendered in the boon + * table headers (single source of truth: ``shared/readoutTableParts``). + */ +import React from "react"; + +import type { PlayerReadoutOut } from "@/lib/api"; +import { + TABLE_STYLE, + TH_STYLE, + TD_STYLE, + BOONS, + useSortedPlayers, + DpsBar, + HealBar, + Th, + IdentityCells, + TableWrapper, +} from "@/lib/readoutTableParts"; + +/* ------------------------------------------------------------------ * + * Main component + * ------------------------------------------------------------------ */ + +export type CompareTableKind = "damage" | "heal" | "boons" | "defense"; + +const TABLE_TITLE: Record = { + damage: "Dégâts", + heal: "Soins", + boons: "Boons", + defense: "Défense & Positionnement", +}; + +interface CompareReadoutTableProps { + kind: CompareTableKind; + players: PlayerReadoutOut[]; +} + +export function CompareReadoutTable({ kind, players }: CompareReadoutTableProps) { + if (players.length === 0) { + return ( +
+ Aucun joueur dans ce tableau. +
+ ); + } + + return ( +
+

+ {TABLE_TITLE[kind]} +

+ + {kind === "damage" && } + {kind === "heal" && } + {kind === "boons" && } + {kind === "defense" && } + +
+ ); +} + +/* ------------------------------------------------------------------ * + * Damage table + * ------------------------------------------------------------------ */ + +function DamageTable({ players }: { players: PlayerReadoutOut[] }) { + const { sorted, sort, onSort } = useSortedPlayers(players, "dps_total", "desc"); + + return ( + + + + + + + + + + + + + + + + + + + + {sorted.map((p, i) => ( + + + + + + + + + + + + ))} + +
GroupeNom {}} style={{ cursor: "default" }}>Spé {}} style={{ cursor: "default", width: 40 }}>Cmd {}} style={{ cursor: "default" }}>RôlesDPS (power / condi)StripsCCDown DPSCleaveKillsMortsKill Part
{p.damage.strips}{p.damage.cc_applied}{p.damage.down_contribution_dps.toFixed(1)}{p.damage.cleave_targets}{p.damage.kills}{p.defense.deaths}{p.damage.kill_participation}
+ ); +} + +/* ------------------------------------------------------------------ * + * Heal table + * ------------------------------------------------------------------ */ + +function HealTable({ players }: { players: PlayerReadoutOut[] }) { + const { sorted, sort, onSort } = useSortedPlayers(players, "hps", "desc"); + + return ( + + + + + + + + + + + + + + + {sorted.map((p, i) => ( + + + + + + + ))} + +
GroupeNom {}} style={{ cursor: "default" }}>Spé {}} style={{ cursor: "default", width: 40 }}>Cmd {}} style={{ cursor: "default" }}>RôlesHeal / BarrierCleansesBreakstun
{p.heal.cleanses}{p.heal.stun_breaks}
+ ); +} + +/* ------------------------------------------------------------------ * + * Boons table (with official GW2 icons) + * ------------------------------------------------------------------ */ + +function BoonsTable({ players }: { players: PlayerReadoutOut[] }) { + const { sorted, sort, onSort } = useSortedPlayers(players, "boon_in_might", "desc"); + + return ( + + + + + + + + + {BOONS.map((b) => ( + + ))} + + + {BOONS.map((b) => ( + + + + + ))} + + + + {sorted.map((p, i) => ( + + + {BOONS.map((b) => { + const boons = p.boons as unknown as Record; + return ( + + + + + ); + })} + + ))} + +
GroupeNom {}} style={{ cursor: "default" }}>Spé {}} style={{ cursor: "default", width: 40 }}>Cmd {}} style={{ cursor: "default" }}>Rôles + {b.iconFile ? ( + {b.label} { e.currentTarget.style.display = "none"; }} + /> + ) : ( + b.label + )} +
+ In + + Out +
+ {boons[`${b.key}_uptime`] != null ? `${boons[`${b.key}_uptime`]!.toFixed(0)}%` : "—"} + + {boons[`outgoing_${b.key}`] != null ? boons[`outgoing_${b.key}`]!.toLocaleString() : "—"} +
+ ); +} + +/* ------------------------------------------------------------------ * + * Defense table + * ------------------------------------------------------------------ */ + +function DefenseTable({ players }: { players: PlayerReadoutOut[] }) { + const { sorted, sort, onSort } = useSortedPlayers(players, "damage_taken", "desc"); + + return ( + + + + + + + + + + + + + + + + + + + + + + + {sorted.map((p, i) => ( + + + + + + + + + + + + + + + ))} + +
GroupeNom {}} style={{ cursor: "default" }}>Spé {}} style={{ cursor: "default", width: 40 }}>Cmd {}} style={{ cursor: "default" }}>RôlesDmg reçuEsquivesBlocagesInterruptMortsDown (ms)CC reçusBarrier abs.Présence %Dist. CmdKill Part
{p.defense.damage_taken.toLocaleString()}{p.defense.dodges}{p.defense.blocks}{p.defense.interrupts}{p.defense.deaths}{p.defense.time_downed_ms}{p.defense.cc_taken}{p.defense.barrier_absorbed.toLocaleString()} + {p.defense.presence_pct != null ? `${p.defense.presence_pct.toFixed(0)}%` : "—"} + + {p.defense.dist_to_commander != null ? p.defense.dist_to_commander.toFixed(0) : "—"} + {p.defense.kill_participation}
+ ); +} diff --git a/web/src/components/PlayerReadoutBase.ts b/web/src/components/PlayerReadoutBase.ts deleted file mode 100644 index 196869b7..00000000 --- a/web/src/components/PlayerReadoutBase.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Tour 6 Wave 7 (Workstream F — Combat-readout UI): shared - * column-def helpers + formatters for the 4 readout tables. - * - * The 5 SHARED_COLUMNS (subgroup / name / elite_spec / is_commander / - * roles) per docs/v0.9.0-combat-readout-design.md §2 are exported - * here so the 4 per-aspect components (``PlayerReadoutDamage``, - * ``PlayerReadoutHeal``, ``PlayerReadoutBoons``, ``PlayerReadoutDefense``) - * inherit them via spread rather than redefining the schema on each. - * - * NOT a React Component - * ===================== - * Pure TS module (no JSX, no ``"use client"``). The shared grid - * wrapper lives in :class:`PlayerReadoutGrid` so this file stays - * free of React Refresh component-export warnings. - * - * Subgroup type-drift note - * ======================== - * The legacy AgentOut schema uses ``subgroup: string | null`` (per- - * target + per-squad contract) while PlayerReadoutOut uses - * ``subgroup: int`` (per-player readout contract, per design doc §2). - * Per thinker's recommendation A, the type drift is accepted at the - * consumer boundary; the formatter normalises both shapes into the - * canonical ``Sub N`` label so the AG Grid cell renderer is uniform. - */ - -import type { - ColDef, - ValueFormatterParams, -} from "ag-grid-community"; - -import type { PlayerReadoutOut } from "@/lib/api"; -import { appGridTheme } from "./ag-grid-setup"; -// F17 W.1: Tango-icon cellRenderers (React components). Kept in a -// dedicated .tsx file so PlayerReadoutBase can stay pure TS (.ts -// extension-true) per the existing "NOT a React Component" module -// intent. -import { CommanderCellRenderer, EliteSpecCellRenderer } from "./PlayerReadoutCells"; - -/** - * Format the warp-reported integer subgroup (``1`` -> ``"Sub 1"``, - * ``0`` -> ``"(no squad)"``). The schema allows ``subgroup`` to be - * any integer (the per-player readout contract) but a real squad - * is conventionally ``>= 1``; ``0`` is the "no squad" sentinel - * (the canonical NPC-only / ghost row case). - * - * The formatter is defensive against null + undefined values too - * because the AG Grid Community renderer may invoke it during a - * hot-reload before the column is bound. - */ -function formatSubgroup(value: unknown): string { - if (value === null || value === undefined) return "(no squad)"; - if (typeof value === "number" && Number.isFinite(value)) { - return value === 0 ? "(no squad)" : `Sub ${value}`; - } - if (typeof value === "string") return value || "(no squad)"; - return "(no squad)"; -} - -/** - * Format the role-classifier's ``roles: list[str]`` output as a - * slash-delimited chip list (e.g. ``["DPS", "STRIP"]`` -> - * ``"DPS/STRIP"``). The full multi-role-set semantics are per - * design doc §3.1; the slash-delimited rendering is the canonical - * compact form for a 100-px AG Grid cell. - */ -function formatRoles(roles: string[] | undefined | null): string { - if (!roles || roles.length === 0) return ""; - return roles.join("/"); -} - -/** - * Renders the commander crown icon as a single-cell glyph when - * ``is_commander === true``. A plain boolean column with a - * ``valueFormatter`` returning ``"★"`` vs ``""`` keeps the cell - * compact; AG Grid's default cell renderer applies the format. - * - * The crown glyph is the canonical "commander" marker from the - * design doc §2 row, mirroring arcdps / Elite Insights. - */ -function formatCommanderIcon(isCommander: unknown): string { - return isCommander === true ? "★" : ""; -} - -export { formatSubgroup, formatRoles, formatCommanderIcon }; - -/** - * The 5 SHARED_COLUMNS that prepend every per-aspect table per - * design doc §2. Spread this into each component's - * ``columnDefs`` so the schema lives in one place; a future - * design-doc §2 update (e.g. swapping the crown glyph) is a - * one-file edit. - */ -export const SHARED_COLUMNS: ColDef[] = [ - { - field: "subgroup", - headerName: "Sub-groupe", - width: 110, - valueFormatter: (params: ValueFormatterParams) => - formatSubgroup(params.value), - // Lock subgroup-first sort per design doc §13. - sort: "asc", - }, - { - field: "name", - headerName: "Nom", - width: 180, - }, - { - field: "elite_spec", - headerName: "Spécialisation", - width: 180, - // F17 W.1: Tango icon cellRenderer (elite_spec > profession - // precedence; falls back to text for unknown enum values). - cellRenderer: EliteSpecCellRenderer, - }, - { - field: "is_commander", - headerName: "Cmdr", - width: 80, - // F17 W.1: inline-SVG Commander crown via React cellRenderer. - // The shell ``valueFormatter`` is retained as a screen-reader - // fallback (AG Grid announces cellRenderer output to AT only - // when the renderer emits accessible text); the canonical - // render path is the cellRenderer. - cellRenderer: CommanderCellRenderer, - valueFormatter: (params: ValueFormatterParams) => - formatCommanderIcon(params.value), - }, - { - field: "roles", - headerName: "Rôles", - width: 140, - valueFormatter: (params: ValueFormatterParams) => - formatRoles(params.value as string[] | undefined | null), - }, -]; - -/** - * The canonical agent_id tie-breaker column appended at the end - * of every per-aspect table per design doc §13 ("append agent_id - * ASC as the final tiebreaker"). The column is HIDDEN by default - * (the analyst doesn't need to see the numeric agent id) but - * participates in the sort comparator so ties break - * deterministically. - * - * THIRD sort entry appended in each component's ``initialState`` - * array (per design doc §13). AG Grid Community v34 uses - * ARRAY-ORDER for multi-column-sort priority; no ``sortIndex`` - * field is carried in ``ColDef``. Entry order: - * ``[subgroup ASC, DESC, agent_id ASC]``. - */ -export const AGENT_ID_TIEBREAKER: ColDef = { - field: "agent_id", - headerName: "Agent id", - width: 110, - hide: true, -}; - -/** - * AG Grid-level props shared across all 4 readout tables. These - * props live DIRECTLY on ```` — NOT under - * ``defaultColDef`` — because AG Grid Community v34's - * ``ColDef`` type forbids ``theme`` / ``rowSelection`` - * there (those are AgGridReact-level props, not column-level). - * - * Each per-aspect component spreads this into its - * ```` so the constants live in one place. The - * numeric-sort comparator (``comparator: (a, b) => (Number(a ?? 0) - Number(b ?? 0)) || 0``) - * lives inline on each component's ``defaultColDef={{ comparator: (a, b) => (Number(a ?? 0) - Number(b ?? 0)) || 0 }}`` - * because AG Grid Community v34’s ColDef d.ts does not - * include a sortNumeric flag (only the canonical comparator - * callback) — numeric sort must be encoded as an explicit - * comparator function rather than the deprecated boolean flag. - * - * Visual cohesion: the ``legacy`` theme matches the existing - * ``TargetRollupsGrid`` / ``SquadRollupsGrid`` instances on - * the /fights/[id] page. - */ -export const AG_GRID_PROPS = { - // Centralised dark Quartz theme from ./ag-grid-setup. - // Using the theme object removes the legacy CSS warning and - // keeps the grid's colour tokens in sync with the app. - theme: appGridTheme, - // Single-row selection (clicking a row selects it for the - // future drill-in detail panel; v0.12.x live data renders - // wire a drill-in yet). - rowSelection: { mode: "singleRow" } as const, -} as const; diff --git a/web/src/components/PlayerReadoutBoons.tsx b/web/src/components/PlayerReadoutBoons.tsx deleted file mode 100644 index 5a233418..00000000 --- a/web/src/components/PlayerReadoutBoons.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -/** - * Tour 6 Wave 7 (Workstream F): Combat-readout Boons table - * (per docs/v0.9.0-combat-readout-design.md §5). - * - * AG Grid Community 34 Client Component for per-player boon - * output / intake. Renders the 9 boons-aspect columns - * (``boons_out_rate`` / ``boons_in_rate`` + the 6 fixed boon - * columns ``stability_out`` / ``alacrity_out`` / - * ``resistance_out`` / ``aegis_out`` / ``superspeed_out`` / - * ``stealth_out`` + an ``other_boons_total`` cell that - * SUM-aggregates the ``other_boons_out: dict[str, int]`` - * per row) PREPENDED with the 5 SHARED_COLUMNS. - * - * §11 open-question resolution - * ============================ - * The design doc §11 listed "Autres boons" rendering as open - * (a) dynamic columns, (b) collapsed tooltip cell, (c) top-3 - * other boons. Per thinker's recommendation H + the project's - * "compact-grid" precedent (SkillsTable omits dynamic columns), - * we ship option (b) collapsed: a single "Other boons (total)" - * column whose value is sum-of-dict-values. A future enhancement - * can swap to option (c) without a wire-shape change (the - * ``other_boons_out`` dict stays the source-of-truth). - * - * Default sort per design doc §13: - * 1. ``subgroup`` ASC - * 2. ``boons_out_rate`` DESC (top boon provider of each squad first) - * 3. ``agent_id`` ASC tie-breaker - */ -import type { ColDef, SortModelItem } from "ag-grid-community"; - -import type { PlayerReadoutOut } from "@/lib/api"; - -import { PlayerReadoutGrid } from "./PlayerReadoutGrid"; - -/** - * Sum all values in the ``other_boons_out`` dict for one row. - * Renders zero when the dict is empty or undefined. - */ -function sumOtherBoons( - dict: Record | undefined | null, -): number { - if (!dict) return 0; - let total = 0; - for (const value of Object.values(dict)) { - if (typeof value === "number" && Number.isFinite(value)) { - total += value; - } - } - return total; -} - -const BOONS_COLUMNS: ColDef[] = [ - { field: "boons.boons_out_rate", headerName: "Boons out/s", width: 130 }, - { field: "boons.boons_in_rate", headerName: "Boons in/s", width: 130 }, - { field: "boons.stability_out", headerName: "Stabilité", width: 100 }, - { field: "boons.alacrity_out", headerName: "Célérité", width: 100 }, - { field: "boons.resistance_out", headerName: "Résistance", width: 100 }, - { field: "boons.aegis_out", headerName: "Égide", width: 100 }, - { field: "boons.superspeed_out", headerName: "Superspeed", width: 110 }, - { field: "boons.stealth_out", headerName: "Stealth", width: 100 }, - { - // Dynamic "other_boons_total" cell: SUM of all values in - // boons.other_boons_out dict per row. Avoids the dynamic- - // column complexity (per §11) while preserving the wire - // source-of-truth. - headerName: "Other boons (total)", - width: 160, - valueGetter: (params) => - sumOtherBoons(params.data?.boons.other_boons_out), - }, -]; - -// Default sort per design doc §13: subgroup ASC + boons_out_rate -// DESC + agent_id ASC tiebreaker. Array order = sort priority (AG -// Grid v34 ``SortModelItem`` does NOT carry ``sortIndex``). -const BOONS_DEFAULT_SORT: SortModelItem[] = [ - { colId: "subgroup", sort: "asc" }, - { colId: "boons.boons_out_rate", sort: "desc" }, - { colId: "agent_id", sort: "asc" }, -]; - -export function PlayerReadoutBoons({ - rows, -}: { - rows: PlayerReadoutOut[]; -}) { - return ( - - ); -} diff --git a/web/src/components/PlayerReadoutDamage.tsx b/web/src/components/PlayerReadoutDamage.tsx deleted file mode 100644 index fbe15eac..00000000 --- a/web/src/components/PlayerReadoutDamage.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -/** - * Tour 6 Wave 7 (Workstream F): Combat-readout Damage table - * (per docs/v0.9.0-combat-readout-design.md §3). - * - * AG Grid Community 34 Client Component. Renders the 7 - * damage-aspect columns (``dps_total`` / ``dps_power`` / - * ``dps_condi`` / ``strips`` / ``cc_applied`` / - * ``down_contribution_dps`` / ``kills`` + the agent_id hidden - * tiebreaker) PREPENDED with the 5 SHARED_COLUMNS from - * :module:`PlayerReadoutBase`. - * - * Default sort per design doc §13: - * 1. ``subgroup`` ASC (squad-first grouping) - * 2. ``dps_total`` DESC (top-damage-dealer of each squad first) - * 3. ``agent_id`` ASC (final deterministic tie-breaker; the - * column is hidden by default but participates in sort - * priority via array order — AG Grid Community v34 - * ``SortModelItem`` omits ``sortIndex``). - * - * The component is a thin wrapper around ``PlayerReadoutGrid``. - * ``dps_power`` / ``dps_condi`` carry real values from the - * v0.12.x Phase 6 v2 parser stream (per-event buff_dmg split). - */ -import type { ColDef, SortModelItem } from "ag-grid-community"; - -import type { PlayerReadoutOut } from "@/lib/api"; - -import { PlayerReadoutGrid } from "./PlayerReadoutGrid"; - -const DAMAGE_COLUMNS: ColDef[] = [ - { field: "damage.dps_total", headerName: "DPS total", width: 130 }, - { field: "damage.dps_power", headerName: "DPS power", width: 130 }, - { field: "damage.dps_condi", headerName: "DPS condi", width: 130 }, - { field: "damage.strips", headerName: "Strips", width: 110 }, - { field: "damage.cc_applied", headerName: "CC appliqués", width: 130 }, - { - field: "damage.down_contribution_dps", - headerName: "Down contrib DPS", - width: 160, - }, - { field: "damage.kills", headerName: "Kills", width: 100 }, -]; - -// Default sort per design doc §13: subgroup ASC + dps_total DESC + -// agent_id ASC tiebreaker. Array order is the sort priority (AG Grid -// Community v34's SortModelItem does NOT carry `sortIndex`; the -// multi-column sort priority is the array index). -const DAMAGE_DEFAULT_SORT: SortModelItem[] = [ - { colId: "subgroup", sort: "asc" }, - { colId: "damage.dps_total", sort: "desc" }, - { colId: "agent_id", sort: "asc" }, -]; - -export function PlayerReadoutDamage({ rows }: { rows: PlayerReadoutOut[] }) { - return ( - - ); -} diff --git a/web/src/components/PlayerReadoutDefense.tsx b/web/src/components/PlayerReadoutDefense.tsx deleted file mode 100644 index bd9e922a..00000000 --- a/web/src/components/PlayerReadoutDefense.tsx +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -/** - * Tour 6 Wave 7 (Workstream F): Combat-readout Defense table - * (per docs/v0.9.0-combat-readout-design.md §6: "Defense & - * Positioning"). - * - * AG Grid Community 34 Client Component for per-player defense - * roll-up. Renders the 9 defense-aspect columns - * (``damage_taken`` / ``cc_taken`` / ``deaths`` / - * ``time_downed_ms`` / ``dodges`` / ``blocks`` / ``interrupts`` - * / ``barrier_absorbed`` / ``presence_pct``) PREPENDED with the 5 SHARED_COLUMNS. - * - * Default sort per design doc §13 ("defensive load is the - * leading indicator"): - * 1. ``subgroup`` ASC - * 2. ``damage_taken`` DESC (most-targeted player of each squad - * first; this surfaces tank / bunker identification at a - * glance) - * 3. ``agent_id`` ASC tie-breaker - * - * All defense columns (``dodges``, ``blocks``, ``interrupts``, - * ``time_downed_ms``) carry real values from the v0.12.x - * Phase 6 v2 parser stream (statechange dispatch + down-state - * lifecycle tracking). ``time_downed_ms`` may be 0 for fights - * without captured down-state cycles. - */ -import type { ColDef, SortModelItem } from "ag-grid-community"; - -import type { PlayerReadoutOut } from "@/lib/api"; - -import { PlayerReadoutGrid } from "./PlayerReadoutGrid"; - -const DEFENSE_COLUMNS: ColDef[] = [ - { field: "defense.damage_taken", headerName: "Damage reçu", width: 130 }, - { field: "defense.cc_taken", headerName: "CC reçus", width: 110 }, - { field: "defense.deaths", headerName: "Morts", width: 100 }, - { field: "defense.time_downed_ms", headerName: "Temps down (ms)", width: 150 }, - { field: "defense.dodges", headerName: "Esquives", width: 110 }, - { field: "defense.blocks", headerName: "Blocages", width: 110 }, - { field: "defense.interrupts", headerName: "Interruptions", width: 130 }, - { field: "defense.barrier_absorbed", headerName: "Barrier abs.", width: 130 }, - // Plan 173 Phase E: presence percentage from event-window buckets. - { - field: "defense.presence_pct", - headerName: "Présence %", - width: 110, - valueFormatter: (params) => - params.value != null ? `${(params.value as number).toFixed(0)}%` : "—", - }, -]; - -// Default sort per design doc §13: subgroup ASC + damage_taken DESC -// + agent_id ASC tiebreaker ("defensive load is the leading -// indicator"). Array order = sort priority (AG Grid v34 -// ``SortModelItem`` does NOT carry ``sortIndex``). -const DEFENSE_DEFAULT_SORT: SortModelItem[] = [ - { colId: "subgroup", sort: "asc" }, - { colId: "defense.damage_taken", sort: "desc" }, - { colId: "agent_id", sort: "asc" }, -]; - -export function PlayerReadoutDefense({ rows }: { rows: PlayerReadoutOut[] }) { - return ( - - ); -} diff --git a/web/src/components/PlayerReadoutGrid.tsx b/web/src/components/PlayerReadoutGrid.tsx deleted file mode 100644 index ad40fd1a..00000000 --- a/web/src/components/PlayerReadoutGrid.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -/** - * Shared grid wrapper for the 4 per-aspect readout tables. - * - * Owns the empty-state panel and the ``AgGridReact`` boilerplate - * so each aspect component only supplies its domain-specific - * columns + default sort. - */ -import React from "react"; - -import { useMemo } from "react"; -import { AgGridReact } from "ag-grid-react"; -import type { ColDef, SortModelItem } from "ag-grid-community"; - -import type { PlayerReadoutOut } from "@/lib/api"; - -import { - AGENT_ID_TIEBREAKER, - AG_GRID_PROPS, - SHARED_COLUMNS, -} from "./PlayerReadoutBase"; - -const EMPTY_STATE_STYLE: React.CSSProperties = { - padding: "12px 16px", - border: "1px solid var(--border)", - borderRadius: 4, - color: "var(--foreground)", - opacity: 0.7, - fontFamily: "var(--font-geist-sans), Arial, Helvetica, sans-serif", -}; - -const NUMERIC_COMPARATOR = (a: unknown, b: unknown) => - Number(a ?? 0) - Number(b ?? 0) || 0; - -const DEFAULT_COL_DEF = { comparator: NUMERIC_COMPARATOR }; - -export interface PlayerReadoutGridProps { - testId: string; - rows: PlayerReadoutOut[]; - aspectColumns: ColDef[]; - defaultSort: SortModelItem[]; -} - -/** - * Shared grid wrapper for the 4 per-aspect readout tables. - * - * Owns the empty-state panel and the ``AgGridReact`` boilerplate - * so each aspect component only declares its columns + sort. - */ -export function PlayerReadoutGrid({ - testId, - rows, - aspectColumns, - defaultSort, -}: PlayerReadoutGridProps) { - // Keep the hook before the early return so the call order stays - // unconditional. ``aspectColumns`` is stable for each aspect - // component (module-level const), so the memo is cheap. - const columnDefs = useMemo( - () => [...SHARED_COLUMNS, ...aspectColumns, AGENT_ID_TIEBREAKER], - [aspectColumns], - ); - - if (rows.length === 0) { - return ( -
- No player rows in this readout. -
- ); - } - - return ( -
- - rowData={rows} - columnDefs={columnDefs} - defaultColDef={DEFAULT_COL_DEF} - {...AG_GRID_PROPS} - initialState={{ sort: { sortModel: defaultSort } }} - getRowId={(params) => String(params.data.agent_id)} - /> -
- ); -} diff --git a/web/src/components/PlayerReadoutHeal.tsx b/web/src/components/PlayerReadoutHeal.tsx deleted file mode 100644 index b48cfee8..00000000 --- a/web/src/components/PlayerReadoutHeal.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -/** - * Tour 6 Wave 7 (Workstream F): Combat-readout Heal table - * (per docs/v0.9.0-combat-readout-design.md §4). - * - * AG Grid Community 34 Client Component for per-player healing - * contribution. Renders the 6 heal-aspect columns (``heal_total`` - * / ``hps`` / ``barrier_total`` / ``barrier_ps`` / ``cleanses`` - * / ``stun_breaks``) PREPENDED with the 5 SHARED_COLUMNS. - * - * Default sort per design doc §13: - * 1. ``subgroup`` ASC - * 2. ``hps`` DESC (top-healer of each squad first) - * 3. ``agent_id`` ASC tie-breaker - * - * Per design doc §7, barrier is a SEPARATE field from heal - * (the canonical "barrier is separable" lock-in). - * ``barrier_total`` + ``barrier_ps`` carry real values from the - * v0.12.x Phase 6 v2 parser stream (HealingEvent.barrier field). - */ -import type { ColDef, SortModelItem } from "ag-grid-community"; - -import type { PlayerReadoutOut } from "@/lib/api"; - -import { PlayerReadoutGrid } from "./PlayerReadoutGrid"; - -const HEAL_COLUMNS: ColDef[] = [ - { field: "heal.heal_total", headerName: "Heal total", width: 130 }, - { field: "heal.hps", headerName: "HPS", width: 110 }, - { field: "heal.barrier_total", headerName: "Barrier total", width: 140 }, - { field: "heal.barrier_ps", headerName: "Barrier/s", width: 120 }, - { field: "heal.cleanses", headerName: "Cleanses", width: 110 }, - { field: "heal.stun_breaks", headerName: "Breakstunt", width: 110 }, -]; - -// Default sort per design doc §13: subgroup ASC + hps DESC + agent_id -// ASC tiebreaker. Array order = sort priority (AG Grid v34 -// ``SortModelItem`` does NOT carry ``sortIndex``). -const HEAL_DEFAULT_SORT: SortModelItem[] = [ - { colId: "subgroup", sort: "asc" }, - { colId: "heal.hps", sort: "desc" }, - { colId: "agent_id", sort: "asc" }, -]; - -export function PlayerReadoutHeal({ rows }: { rows: PlayerReadoutOut[] }) { - return ( - - ); -} diff --git a/web/src/components/ReadoutTabClient.tsx b/web/src/components/ReadoutTabClient.tsx index 112b5153..f300faa1 100644 --- a/web/src/components/ReadoutTabClient.tsx +++ b/web/src/components/ReadoutTabClient.tsx @@ -20,53 +20,32 @@ import { type PlayerReadoutOut, } from "@/lib/api"; import { FightSummaryCards } from "./FightSummaryCards"; -import { - EliteSpecCellRenderer, - CommanderCellRenderer, -} from "./PlayerReadoutCells"; import { ROLE_COLORS, ROLE_FALLBACK } from "@/lib/roleColors"; import { PlayerPositionHeatmap } from "./PlayerPositionHeatmap"; +import { + TABLE_STYLE, + TH_STYLE, + TD_STYLE, + BOONS, + useSortedPlayers, + DpsBar, + HealBar, + Th, + IdentityCells, + TableWrapper, +} from "@/lib/readoutTableParts"; /* ------------------------------------------------------------------ * * Constants & shared styles * ------------------------------------------------------------------ */ -type SortField = string; -type SortDir = "asc" | "desc"; - -const TABLE_STYLE: React.CSSProperties = { - width: "100%", - borderCollapse: "collapse", - fontSize: 12, - fontFamily: "var(--font-geist-sans, sans-serif)", - background: "var(--surface, rgba(255,255,255,0.02))", - borderRadius: 6, - overflow: "hidden", -}; - -const TH_STYLE: React.CSSProperties = { - position: "sticky", - top: 0, - padding: "6px 8px", - textAlign: "left", - fontWeight: 600, - fontSize: 10, - textTransform: "uppercase", - letterSpacing: "0.05em", - color: "var(--foreground)", - opacity: 0.7, - background: "var(--surface-elevated, rgba(255,255,255,0.05))", - borderBottom: "1px solid var(--border)", - whiteSpace: "nowrap", - cursor: "pointer", - userSelect: "none", -}; - -const TD_STYLE: React.CSSProperties = { - padding: "4px 8px", - borderBottom: "1px solid var(--border)", - color: "var(--foreground)", - whiteSpace: "nowrap", +const ROLE_TOOLTIPS: Record = { + DPS: "Dégâts : rôle déterminé par l'effort pondéré (dégâts ×1.0, heal ×2.5, strips ×5000).", + Heal: "Heal : spé healer (Druid/Tempest/Scourge/Specter) avec >15% d'effort de soin.", + Support: "Support : générateur de boons — spé BOON avec effort non-DPS dominant.", + Strip: "Strip : >35% d'effort de retrait de boons ennemis (BuffRemoval).", + CC: "CC : >3 compétences de contrôle (stun, daze, knockback…) appliquées.", + Cleanser: "Cleanser : >10 altérations (conditions) retirées des alliés.", }; const EMPTY_STYLE: React.CSSProperties = { @@ -77,9 +56,6 @@ const EMPTY_STYLE: React.CSSProperties = { opacity: 0.7, }; -const BAR_BG = "rgba(255,255,255,0.05)"; -const BAR_HEIGHT = 14; - /* ------------------------------------------------------------------ * * Timeline SVG / PNG export helpers (pure, no React state) * @@ -157,261 +133,6 @@ function triggerDownload(blob: Blob, filename: string): void { setTimeout(() => URL.revokeObjectURL(url), 0); } -/* ------------------------------------------------------------------ * - * Bar chart helpers - * ------------------------------------------------------------------ */ - -function MiniBar({ pct, color }: { pct: number; color: string }) { - return ( -
- ); -} - -function DpsBar({ power, condi, total }: { power: number; condi: number; total: number }) { - const max = Math.max(total, 1); - return ( -
-
- - -
- - {total.toFixed(0)} - -
- ); -} - -function HealBar({ hps, bps, total }: { hps: number; bps: number; total: number }) { - const max = Math.max(hps + bps, 1); - return ( -
-
- - -
- - {total.toFixed(0)} - -
- ); -} - -/* ------------------------------------------------------------------ * - * Role badge with tooltip - * ------------------------------------------------------------------ */ - -const ROLE_TOOLTIPS: Record = { - DPS: "Dégâts : rôle déterminé par l'effort pondéré (dégâts ×1.0, heal ×2.5, strips ×5000).", - Heal: "Heal : spé healer (Druid/Tempest/Scourge/Specter) avec >15% d'effort de soin.", - Support: "Support : générateur de boons — spé BOON avec effort non-DPS dominant.", - Strip: "Strip : >35% d'effort de retrait de boons ennemis (BuffRemoval).", - CC: "CC : >3 compétences de contrôle (stun, daze, knockback…) appliquées.", - Cleanser: "Cleanser : >10 altérations (conditions) retirées des alliés.", -}; - -function RoleBadge({ role }: { role: string }) { - const c = ROLE_COLORS[role] ?? ROLE_FALLBACK; - return ( - - {role} - - ); -} - -/* ------------------------------------------------------------------ * - * Sortable table header - * ------------------------------------------------------------------ */ - -function Th({ - children, - field, - currentSort, - onSort, - style, - colSpan, - rowSpan, -}: { - children: React.ReactNode; - field: string; - currentSort: { field: SortField; dir: SortDir } | null; - onSort: (field: SortField) => void; - style?: React.CSSProperties; - colSpan?: number; - rowSpan?: number; -}) { - const active = currentSort?.field === field; - return ( - onSort(field)} - > - {children} - {active && ( - - {currentSort?.dir === "asc" ? " ▲" : " ▼"} - - )} - - ); -} - -/* ------------------------------------------------------------------ * - * Shared identity cells (Groupe, Nom, Spé, Cmd, Rôles) - * ------------------------------------------------------------------ */ - -function IdentityCells({ player }: { player: PlayerReadoutOut }) { - return ( - <> - - {player.subgroup === 0 ? "—" : `Sub ${player.subgroup}`} - - {player.name} - - - - - - - - - {player.roles.map((r) => ( - - ))} - - - - ); -} - -/* ------------------------------------------------------------------ * - * Boon definitions - * ------------------------------------------------------------------ */ - - -interface BoonDef { - key: string; - label: string; - iconFile: string | null; -} - -const BOONS: BoonDef[] = [ - { key: "might", label: "Might", iconFile: "Might" }, - { key: "fury", label: "Fury", iconFile: "Fury" }, - { key: "quickness", label: "Quick", iconFile: "Quickness" }, - { key: "alacrity", label: "Alac", iconFile: "Alacrity" }, - { key: "protection", label: "Prot", iconFile: "Protection" }, - { key: "regeneration", label: "Regen", iconFile: "Regeneration" }, - { key: "vigor", label: "Vigor", iconFile: "Vigor" }, - { key: "aegis", label: "Aegis", iconFile: "Aegis" }, - { key: "stability", label: "Stab", iconFile: "Stability" }, - { key: "swiftness", label: "Swift", iconFile: "Swiftness" }, - { key: "resistance", label: "Resist", iconFile: "Resistance" }, - { key: "resolution", label: "Resol", iconFile: "Resolution" }, - { key: "superspeed", label: "Speed", iconFile: "Superspeed" }, - { key: "stealth", label: "Stealth", iconFile: "Stealth" }, -]; - -/* ------------------------------------------------------------------ * - * Sorting hook - * ------------------------------------------------------------------ */ - -function useSortedPlayers( - players: PlayerReadoutOut[], - defaultField: SortField, - defaultDir: SortDir, -) { - const [sort, setSort] = useState<{ field: SortField; dir: SortDir }>({ - field: defaultField, - dir: defaultDir, - }); - - const onSort = useCallback( - (field: SortField) => { - setSort((prev) => ({ - field, - dir: prev.field === field && prev.dir === "desc" ? "asc" : "desc", - })); - }, - [], - ); - - const sorted = useMemo(() => { - const dir = sort.dir === "asc" ? 1 : -1; - return [...players].sort((a, b) => { - const va = getSortValue(a, sort.field); - const vb = getSortValue(b, sort.field); - if (va === vb) return 0; - return va < vb ? -dir : dir; - }); - }, [players, sort]); - - return { sorted, sort, onSort }; -} - -function getSortValue(p: PlayerReadoutOut, field: string): number { - if (field === "dps_total") return p.damage.dps_total; - if (field === "dps_power") return p.damage.dps_power; - if (field === "dps_condi") return p.damage.dps_condi; - if (field === "strips") return p.damage.strips; - if (field === "cc_applied") return p.damage.cc_applied; - if (field === "down_contrib") return p.damage.down_contribution_dps; - if (field === "cleave") return p.damage.cleave_targets; - if (field === "kills") return p.damage.kills; - if (field === "kill_part") return p.damage.kill_participation; - if (field === "heal_total") return p.heal.heal_total ?? 0; - if (field === "hps") return p.heal.hps; - if (field === "barrier_ps") return p.heal.barrier_ps; - if (field === "cleanses") return p.heal.cleanses; - if (field === "stun_breaks") return p.heal.stun_breaks; - if (field === "damage_taken") return p.defense.damage_taken; - if (field === "deaths") return p.defense.deaths; - if (field === "dodges") return p.defense.dodges; - if (field === "blocks") return p.defense.blocks; - if (field === "interrupts") return p.defense.interrupts; - if (field === "cc_taken") return p.defense.cc_taken; - if (field === "time_downed") return p.defense.time_downed_ms; - if (field === "barrier_absorbed") return p.defense.barrier_absorbed; - if (field === "presence_pct") return p.defense.presence_pct ?? 0; - if (field === "dist_to_commander") return p.defense.dist_to_commander ?? -1; - if (field === "kill_part") return p.defense.kill_participation; - if (field === "subgroup") return p.subgroup; - if (field.startsWith("boon_in_")) { - const boonKey = field.replace("boon_in_", ""); - return (p.boons as unknown as Record)[`${boonKey}_uptime`] ?? -1; - } - if (field.startsWith("boon_out_")) { - const boonKey = field.replace("boon_out_", ""); - return (p.boons as unknown as Record)[`outgoing_${boonKey}`] ?? -1; - } - if (field === "name") return (p.name || "").charCodeAt(0) || 0; - return 0; -} - /* ------------------------------------------------------------------ * * Timeline mini chart * ------------------------------------------------------------------ */ @@ -664,27 +385,6 @@ function PositionsSummary({ positions }: { positions: FightPositionsOut | null } ); } -/* ------------------------------------------------------------------ * - * Wrapper for scrollable tables - * ------------------------------------------------------------------ */ - -function TableWrapper({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - /* ------------------------------------------------------------------ * * Stats badge for the global squad summary bar * ------------------------------------------------------------------ */ @@ -1013,7 +713,7 @@ export function ReadoutTabClient({ fightId }: ReadoutTabClientProps) { {damageSort.sorted.map((p, i) => ( - + {p.damage.strips} {p.damage.cc_applied} @@ -1048,7 +748,7 @@ export function ReadoutTabClient({ fightId }: ReadoutTabClientProps) { {healSort.sorted.map((p, i) => ( - + {p.heal.cleanses} {p.heal.stun_breaks} @@ -1103,7 +803,7 @@ export function ReadoutTabClient({ fightId }: ReadoutTabClientProps) { {boonSort.sorted.map((p, i) => ( - + {BOONS.map((b) => { const boons = p.boons as unknown as Record; return ( @@ -1151,7 +851,7 @@ export function ReadoutTabClient({ fightId }: ReadoutTabClientProps) { {defenseSort.sorted.map((p, i) => ( - + {p.defense.damage_taken.toLocaleString()} {p.defense.dodges} {p.defense.blocks} diff --git a/web/src/lib/readoutTableParts.tsx b/web/src/lib/readoutTableParts.tsx new file mode 100644 index 00000000..3f2f7443 --- /dev/null +++ b/web/src/lib/readoutTableParts.tsx @@ -0,0 +1,337 @@ +"use client"; + +/** + * Shared parts for native HTML readout tables. + * + * Extracted from ``ReadoutTabClient.tsx`` and + * ``CompareReadoutTable.tsx`` so style constants, boon definitions, + * sorting logic, and utility components live in ONE place. + * + * Import the components you need: + * import { TABLE_STYLE, TH_STYLE, TD_STYLE, BAR_BG, BAR_HEIGHT, + * BOONS, type BoonDef, + * useSortedPlayers, type SortField, type SortDir, + * MiniBar, DpsBar, HealBar, Th, TableWrapper, + * RoleBadge, IdentityCells } from "@/components/shared/readoutTableParts"; + */ +import React, { useCallback, useMemo, useState } from "react"; + +import type { PlayerReadoutOut } from "@/lib/api"; +import { + EliteSpecCellRenderer, + CommanderCellRenderer, +} from "@/components/PlayerReadoutCells"; +import { ROLE_COLORS, ROLE_FALLBACK } from "@/lib/roleColors"; + +/* ------------------------------------------------------------------ * + * Types + * ------------------------------------------------------------------ */ + +export type SortField = string; +export type SortDir = "asc" | "desc"; + +export interface BoonDef { + key: string; + label: string; + iconFile: string | null; +} + +/* ------------------------------------------------------------------ * + * Style constants + * ------------------------------------------------------------------ */ + +export const TABLE_STYLE: React.CSSProperties = { + width: "100%", + borderCollapse: "collapse", + fontSize: 12, + fontFamily: "var(--font-geist-sans, sans-serif)", + background: "var(--surface, rgba(255,255,255,0.02))", + borderRadius: 6, + overflow: "hidden", +}; + +export const TH_STYLE: React.CSSProperties = { + position: "sticky", + top: 0, + padding: "6px 8px", + textAlign: "left", + fontWeight: 600, + fontSize: 10, + textTransform: "uppercase", + letterSpacing: "0.05em", + color: "var(--foreground)", + opacity: 0.7, + background: "var(--surface-elevated, rgba(255,255,255,0.05))", + borderBottom: "1px solid var(--border)", + whiteSpace: "nowrap", + cursor: "pointer", + userSelect: "none", +}; + +export const TD_STYLE: React.CSSProperties = { + padding: "4px 8px", + borderBottom: "1px solid var(--border)", + color: "var(--foreground)", + whiteSpace: "nowrap", +}; + +export const BAR_BG = "rgba(255,255,255,0.05)"; +export const BAR_HEIGHT = 14; + +/* ------------------------------------------------------------------ * + * Boon definitions (single source of truth) + * ------------------------------------------------------------------ */ + +export const BOONS: BoonDef[] = [ + { key: "might", label: "Might", iconFile: "Might" }, + { key: "fury", label: "Fury", iconFile: "Fury" }, + { key: "quickness", label: "Quick", iconFile: "Quickness" }, + { key: "alacrity", label: "Alac", iconFile: "Alacrity" }, + { key: "protection", label: "Prot", iconFile: "Protection" }, + { key: "regeneration", label: "Regen", iconFile: "Regeneration" }, + { key: "vigor", label: "Vigor", iconFile: "Vigor" }, + { key: "aegis", label: "Aegis", iconFile: "Aegis" }, + { key: "stability", label: "Stab", iconFile: "Stability" }, + { key: "swiftness", label: "Swift", iconFile: "Swiftness" }, + { key: "resistance", label: "Resist", iconFile: "Resistance" }, + { key: "resolution", label: "Resol", iconFile: "Resolution" }, + { key: "superspeed", label: "Speed", iconFile: "Superspeed" }, + { key: "stealth", label: "Stealth", iconFile: "Stealth" }, +]; + +/* ------------------------------------------------------------------ * + * Sorting hook + helpers + * ------------------------------------------------------------------ */ + +function getSortValue(p: PlayerReadoutOut, field: string): number { + if (field === "dps_total") return p.damage.dps_total; + if (field === "dps_power") return p.damage.dps_power; + if (field === "dps_condi") return p.damage.dps_condi; + if (field === "strips") return p.damage.strips; + if (field === "cc_applied") return p.damage.cc_applied; + if (field === "down_contrib") return p.damage.down_contribution_dps; + if (field === "cleave") return p.damage.cleave_targets; + if (field === "kills") return p.damage.kills; + if (field === "kill_part") return p.damage.kill_participation; + if (field === "heal_total") return p.heal.heal_total ?? 0; + if (field === "hps") return p.heal.hps; + if (field === "barrier_ps") return p.heal.barrier_ps; + if (field === "cleanses") return p.heal.cleanses; + if (field === "stun_breaks") return p.heal.stun_breaks; + if (field === "damage_taken") return p.defense.damage_taken; + if (field === "deaths") return p.defense.deaths; + if (field === "dodges") return p.defense.dodges; + if (field === "blocks") return p.defense.blocks; + if (field === "interrupts") return p.defense.interrupts; + if (field === "cc_taken") return p.defense.cc_taken; + if (field === "time_downed") return p.defense.time_downed_ms; + if (field === "barrier_absorbed") return p.defense.barrier_absorbed; + if (field === "presence_pct") return p.defense.presence_pct ?? 0; + if (field === "dist_to_commander") return p.defense.dist_to_commander ?? -1; + if (field === "subgroup") return p.subgroup; + if (field.startsWith("boon_in_")) { + const boonKey = field.replace("boon_in_", ""); + return (p.boons as unknown as Record)[`${boonKey}_uptime`] ?? -1; + } + if (field.startsWith("boon_out_")) { + const boonKey = field.replace("boon_out_", ""); + return (p.boons as unknown as Record)[`outgoing_${boonKey}`] ?? -1; + } + if (field === "name") return (p.name || "").charCodeAt(0) || 0; + return 0; +} + +export function useSortedPlayers( + players: PlayerReadoutOut[], + defaultField: SortField, + defaultDir: SortDir, +) { + const [sort, setSort] = useState<{ field: SortField; dir: SortDir }>({ + field: defaultField, + dir: defaultDir, + }); + + const onSort = useCallback( + (field: SortField) => { + setSort((prev) => ({ + field, + dir: prev.field === field && prev.dir === "desc" ? "asc" : "desc", + })); + }, + [], + ); + + const sorted = useMemo(() => { + const dir = sort.dir === "asc" ? 1 : -1; + return [...players].sort((a, b) => { + const va = getSortValue(a, sort.field); + const vb = getSortValue(b, sort.field); + if (va === vb) return 0; + return va < vb ? -dir : dir; + }); + }, [players, sort]); + + return { sorted, sort, onSort }; +} + +/* ------------------------------------------------------------------ * + * Utility components + * ------------------------------------------------------------------ */ + +export function MiniBar({ pct, color }: { pct: number; color: string }) { + return ( +
+ ); +} + +export function DpsBar({ power, condi, total }: { power: number; condi: number; total: number }) { + const max = Math.max(total, 1); + return ( +
+
+ + +
+ + {total.toFixed(0)} + +
+ ); +} + +export function HealBar({ hps, bps, total }: { hps: number; bps: number; total: number }) { + const max = Math.max(hps + bps, 1); + return ( +
+
+ + +
+ + {total.toFixed(0)} + +
+ ); +} + +export function Th({ + children, + field, + currentSort, + onSort, + style, + colSpan, + rowSpan, +}: { + children: React.ReactNode; + field: string; + currentSort: { field: SortField; dir: SortDir } | null; + onSort: (field: SortField) => void; + style?: React.CSSProperties; + colSpan?: number; + rowSpan?: number; +}) { + const active = currentSort?.field === field; + return ( + onSort(field)} + > + {children} + {active && ( + + {currentSort?.dir === "asc" ? " ▲" : " ▼"} + + )} + + ); +} + +export function TableWrapper({ children, maxHeight }: { children: React.ReactNode; maxHeight?: number }) { + return ( +
+ {children} +
+ ); +} + +/** Role badge. Accepts optional title for tooltip. */ +export function RoleBadge({ role, title }: { role: string; title?: string }) { + const c = ROLE_COLORS[role] ?? ROLE_FALLBACK; + return ( + + {role} + + ); +} + +/** + * Identity cells (Groupe, Nom, Spé, Cmd, Rôles). + * Renders the 5 shared columns that prepend every readout table. + * + * Accepts an optional ``roleTooltips`` map so ReadoutTabClient + * can pass detailed role descriptions without the shared module + * needing to know about them. + */ +export function IdentityCells({ + player, + roleTooltips, +}: { + player: PlayerReadoutOut; + roleTooltips?: Record; +}) { + return ( + <> + + {player.subgroup === 0 ? "—" : `Sub ${player.subgroup}`} + + {player.name} + + + + + + + + + {player.roles.map((r) => ( + + ))} + + + + ); +} diff --git a/web/tests/components/PlayerReadoutBase.test.tsx b/web/tests/components/PlayerReadoutBase.test.tsx deleted file mode 100644 index cb61dc26..00000000 --- a/web/tests/components/PlayerReadoutBase.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - formatSubgroup, - formatRoles, - formatCommanderIcon, -} from "@/components/PlayerReadoutBase"; - -describe("PlayerReadoutBase formatters", () => { - describe("formatSubgroup", () => { - it("returns '(no squad)' for null or undefined", () => { - expect(formatSubgroup(null)).toBe("(no squad)"); - expect(formatSubgroup(undefined)).toBe("(no squad)"); - }); - - it("formats positive integers as 'Sub N'", () => { - expect(formatSubgroup(1)).toBe("Sub 1"); - expect(formatSubgroup(42)).toBe("Sub 42"); - }); - - it("returns '(no squad)' for zero", () => { - expect(formatSubgroup(0)).toBe("(no squad)"); - }); - - it("returns the string value for non-empty strings", () => { - expect(formatSubgroup("Alpha")).toBe("Alpha"); - }); - - it("returns '(no squad)' for empty strings", () => { - expect(formatSubgroup("")).toBe("(no squad)"); - }); - }); - - describe("formatRoles", () => { - it("returns an empty string for null, undefined, or empty arrays", () => { - expect(formatRoles(null)).toBe(""); - expect(formatRoles(undefined)).toBe(""); - expect(formatRoles([])).toBe(""); - }); - - it("joins roles with a slash", () => { - expect(formatRoles(["DPS", "STRIP"])).toBe("DPS/STRIP"); - expect(formatRoles(["HEAL", "Boon", "Support"])).toBe("HEAL/Boon/Support"); - }); - }); - - describe("formatCommanderIcon", () => { - it("returns the crown glyph for true", () => { - expect(formatCommanderIcon(true)).toBe("★"); - }); - - it("returns an empty string for any non-true value", () => { - expect(formatCommanderIcon(false)).toBe(""); - expect(formatCommanderIcon(null)).toBe(""); - expect(formatCommanderIcon(undefined)).toBe(""); - expect(formatCommanderIcon("yes")).toBe(""); - }); - }); -}); diff --git a/web/tests/components/PlayerReadoutGrid.test.tsx b/web/tests/components/PlayerReadoutGrid.test.tsx deleted file mode 100644 index 364972b7..00000000 --- a/web/tests/components/PlayerReadoutGrid.test.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render } from "@testing-library/react"; - -import type { PlayerReadoutOut } from "@/lib/api"; - -/** - * jsdom + AG Grid challenge - * ========================= - * AG Grid Community 34 requires a real DOM + canvas + offsetWidth - * for proper rendering. jsdom has none of these. We mock - * ``ag-grid-react`` as a pass-through stub that captures the - * props so we can assert on column-def shape + row-data without - * actually rendering the grid. - */ -vi.mock("ag-grid-react", () => ({ - AgGridReact: (props: Record) => { - return ( -
- ); - }, -})); - -import { PlayerReadoutGrid } from "@/components/PlayerReadoutGrid"; -import type { ColDef, SortModelItem } from "ag-grid-community"; - -function buildRow(extra: Partial = {}): PlayerReadoutOut { - return { - agent_id: 10001, - subgroup: 1, - name: "Player One", - account_name: "TestAccount.1234", - profession: "GUARDIAN", - elite_spec: "FIREBRAND", - is_commander: true, - roles: ["DPS"], - damage: { - dps_total: 4500, - dps_power: 0, - dps_condi: 0, - strips: 0, - cc_applied: 0, - down_contribution_dps: 0, - kills: 0, - cleave_targets: 0, - kill_participation: 0, - }, - heal: { - heal_total: 0, - hps: 0, - barrier_total: 0, - barrier_ps: 0, - cleanses: 0, - stun_breaks: 0, - }, - boons: { - boons_out_rate: 0, - boons_in_rate: 0, - stability_out: 0, - alacrity_out: 0, - resistance_out: 0, - aegis_out: 0, - superspeed_out: 0, - stealth_out: 0, - might_uptime: null, - fury_uptime: null, - quickness_uptime: null, - alacrity_uptime: null, - protection_uptime: null, - regeneration_uptime: null, - vigor_uptime: null, - aegis_uptime: null, - stability_uptime: null, - swiftness_uptime: null, - resistance_uptime: null, - resolution_uptime: null, - superspeed_uptime: null, - stealth_uptime: null, - other_boons_out: {}, - outgoing_might: null, - outgoing_fury: null, - outgoing_quickness: null, - outgoing_alacrity: null, - outgoing_protection: null, - outgoing_regeneration: null, - outgoing_vigor: null, - outgoing_aegis: null, - outgoing_stability: null, - outgoing_swiftness: null, - outgoing_resistance: null, - outgoing_resolution: null, - outgoing_superspeed: null, - outgoing_stealth: null, - }, - defense: { - damage_taken: 0, - cc_taken: 0, - deaths: 0, - time_downed_ms: 0, - dodges: 0, - blocks: 0, - interrupts: 0, - barrier_absorbed: 0, - presence_pct: null, - dist_to_commander: null, - kill_participation: 0, - }, - ...extra, - }; -} - -const ASPECT_COLUMNS: ColDef[] = [ - { field: "damage.dps_total", headerName: "DPS total" }, -]; - -const DEFAULT_SORT: SortModelItem[] = [ - { colId: "subgroup", sort: "asc" }, - { colId: "damage.dps_total", sort: "desc" }, - { colId: "agent_id", sort: "asc" }, -]; - -describe("PlayerReadoutGrid", () => { - it("renders the empty-state panel when rows is empty", () => { - const { container } = render( - , - ); - expect( - container.querySelector('[data-testid="player-readout-test-empty"]'), - ).not.toBeNull(); - expect(container.textContent).toContain("No player rows in this readout."); - }); - - it("renders AG Grid wrapper with shared + aspect + tie-breaker columns", () => { - const rows = [buildRow()]; - const { container } = render( - , - ); - const mock = container.querySelector( - '[data-testid="player-readout-test"] [data-testid="ag-grid-mock"]', - ); - expect(mock).not.toBeNull(); - const props = JSON.parse(mock!.getAttribute("data-props") ?? "{}"); - // 5 SHARED + 1 aspect + 1 agent_id tiebreaker = 7 columns. - expect(props.columnDefs).toBe(7); - expect(props.rowData).toBe(1); - expect(props.sortModel).toEqual(DEFAULT_SORT); - }); - - it("passes the test id through to the wrapper div", () => { - const rows = [buildRow()]; - const { container } = render( - , - ); - expect( - container.querySelector('[data-testid="custom-readout"]'), - ).not.toBeNull(); - }); -}); diff --git a/web/tests/components/combat-readout.test.tsx b/web/tests/components/combat-readout.test.tsx deleted file mode 100644 index 32ac8dc9..00000000 --- a/web/tests/components/combat-readout.test.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { render } from "@testing-library/react"; - -/** - * Tour 6 Wave 7 (Workstream F): combined component-level spec - * for the 4 Combat-readout tables + PlayerReadoutBase helpers. - * - * jsdom + AG Grid challenge - * ========================= - * AG Grid Community 34 requires a real DOM + canvas + offsetWidth - * for proper rendering. jsdom has none of these. We mock - * ``ag-grid-react`` as a pass-through stub that captures the - * props so we can assert on column-def shape + row-data without - * actually rendering the grid. This is the canonical - * precedent: the existing setup.ts mocks ``@/components/TargetRollupsGrid`` - * + ``@/components/SquadRollupsGrid`` similarly to keep page-level - * tests fast + canvas-free. - */ -vi.mock("ag-grid-react", () => ({ - AgGridReact: (props: Record) => { - // Mock as a stable div carrying the props JSON in data-props - // so component specs can assert on column / row / sort state. - return ( -
- ); - }, -})); - -/** - * ``tests/setup.ts`` mocks all 4 ``PlayerReadout*`` Client Components - * (Damage / Heal / Boons / Defense) as no-op ``() => null`` so the - * page-level Server Component tests can render the wrapper without - * booting AG Grid's full runtime in jsdom (no canvas, no offsetWidth). - * The component-level test in THIS file needs the real implementations: - * the empty-state panels (rendered when ``rows`` is empty) + the AG - * Grid wrappers (which the per-test ``vi.mock("ag-grid-react", ...)`` - * stub above captures props from). ``vi.unmock`` is hoisted by - * vitest's transformer to the same module-init boundary as - * ``vi.mock``. - */ -vi.unmock("@/components/PlayerReadoutDamage"); -vi.unmock("@/components/PlayerReadoutHeal"); -vi.unmock("@/components/PlayerReadoutBoons"); -vi.unmock("@/components/PlayerReadoutDefense"); - -import { - formatSubgroup, - formatRoles, - formatCommanderIcon, -} from "@/components/PlayerReadoutBase"; -import { PlayerReadoutDamage } from "@/components/PlayerReadoutDamage"; -import { PlayerReadoutHeal } from "@/components/PlayerReadoutHeal"; -import { PlayerReadoutBoons } from "@/components/PlayerReadoutBoons"; -import { PlayerReadoutDefense } from "@/components/PlayerReadoutDefense"; -import type { PlayerReadoutOut } from "@/lib/api"; - -/** - * Build a fully-populated PlayerReadoutOut row for component - * tests. Defaults to "all zeros" so the per-field assertions can - * spot-check the wiring. - */ -function buildRow(extra: Partial = {}): PlayerReadoutOut { - return { - agent_id: 10001, - subgroup: 1, - name: "Player One", - account_name: "TestAccount.1234", - profession: "GUARDIAN", - elite_spec: "FIREBRAND", - is_commander: true, - roles: ["DPS", "STRIP"], - damage: { - dps_total: 4500, - dps_power: 0, - dps_condi: 0, - strips: 12, - cc_applied: 450, - down_contribution_dps: 1200, - kills: 3, - cleave_targets: 0, - kill_participation: 0, - }, - heal: { - heal_total: 120000, - hps: 15000, - barrier_total: 0, - barrier_ps: 0, - cleanses: 150, - stun_breaks: 5, - }, - boons: { - boons_out_rate: 45.2, - boons_in_rate: 20.1, - stability_out: 450, - alacrity_out: 0, - resistance_out: 12, - aegis_out: 35, - superspeed_out: 15, - stealth_out: 0, - might_uptime: null, - fury_uptime: null, - quickness_uptime: null, - alacrity_uptime: null, - protection_uptime: null, - regeneration_uptime: null, - vigor_uptime: null, - aegis_uptime: null, - stability_uptime: null, - swiftness_uptime: null, - resistance_uptime: null, - resolution_uptime: null, - superspeed_uptime: null, - stealth_uptime: null, - other_boons_out: { might: 4503, fury: 34 }, - outgoing_might: null, - outgoing_fury: null, - outgoing_quickness: null, - outgoing_alacrity: null, - outgoing_protection: null, - outgoing_regeneration: null, - outgoing_vigor: null, - outgoing_aegis: null, - outgoing_stability: null, - outgoing_swiftness: null, - outgoing_resistance: null, - outgoing_resolution: null, - outgoing_superspeed: null, - outgoing_stealth: null, - }, - defense: { - damage_taken: 45000, - cc_taken: 2, - deaths: 0, - time_downed_ms: 0, - dodges: 14, - blocks: 45, - interrupts: 1, - barrier_absorbed: 15015, - presence_pct: null, - dist_to_commander: null, - kill_participation: 0, - }, - ...extra, - }; -} - -describe("PlayerReadoutBase formatters", () => { - it("formatSubgroup maps int to 'Sub N' label", () => { - expect(formatSubgroup(1)).toBe("Sub 1"); - expect(formatSubgroup(2)).toBe("Sub 2"); - expect(formatSubgroup(0)).toBe("(no squad)"); - }); - - it("formatSubgroup tolerates null + string shapes (subgroup type drift)", () => { - expect(formatSubgroup(null)).toBe("(no squad)"); - expect(formatSubgroup(undefined)).toBe("(no squad)"); - expect(formatSubgroup("Sub 1")).toBe("Sub 1"); - expect(formatSubgroup("")).toBe("(no squad)"); - }); - - it("formatRoles renders multi-role as slash-delimited chip list", () => { - expect(formatRoles(["DPS", "STRIP"])).toBe("DPS/STRIP"); - expect(formatRoles(["HEAL"])).toBe("HEAL"); - expect(formatRoles([])).toBe(""); - expect(formatRoles(null)).toBe(""); - }); - - it("formatCommanderIcon returns crown for true commander", () => { - expect(formatCommanderIcon(true)).toBe("★"); - expect(formatCommanderIcon(false)).toBe(""); - }); -}); - -describe("PlayerReadoutDamage", () => { - it("renders the empty-state panel when rows is empty", () => { - const { container } = render(); - expect( - container.querySelector('[data-testid="player-readout-damage-empty"]'), - ).not.toBeNull(); - }); - - it("renders AG Grid wrapper with 13 columns + default sort when rows present", () => { - const rows = [buildRow()]; - const { container } = render(); - const mock = container.querySelector( - '[data-testid="player-readout-damage"] [data-testid="ag-grid-mock"]', - ); - expect(mock).not.toBeNull(); - const props = JSON.parse(mock!.getAttribute("data-props") ?? "{}"); - // 5 SHARED + 7 damage + 1 agent_id tiebreaker = 13 columns. - expect(props.columnDefs).toBe(13); - expect(props.rowData).toBe(1); - // Default sort per design doc §13. - expect(props.sortModel).toEqual([ - { colId: "subgroup", sort: "asc" }, - { colId: "damage.dps_total", sort: "desc" }, - { colId: "agent_id", sort: "asc" } - ]); - }); -}); - -describe("PlayerReadoutHeal", () => { - it("renders the empty-state panel when rows is empty", () => { - const { container } = render(); - expect( - container.querySelector('[data-testid="player-readout-heal-empty"]'), - ).not.toBeNull(); - }); - - it("renders AG Grid wrapper with 12 columns + hps-desc default sort", () => { - const rows = [buildRow()]; - const { container } = render(); - const mock = container.querySelector( - '[data-testid="player-readout-heal"] [data-testid="ag-grid-mock"]', - ); - expect(mock).not.toBeNull(); - const props = JSON.parse(mock!.getAttribute("data-props") ?? "{}"); - expect(props.columnDefs).toBe(12); - expect(props.sortModel).toEqual([ - { colId: "subgroup", sort: "asc" }, - { colId: "heal.hps", sort: "desc" }, - { colId: "agent_id", sort: "asc" } - ]); - }); -}); - -describe("PlayerReadoutBoons", () => { - it("renders the empty-state panel when rows is empty", () => { - const { container } = render(); - expect( - container.querySelector('[data-testid="player-readout-boons-empty"]'), - ).not.toBeNull(); - }); - - it("renders AG Grid wrapper with 14 columns + boons_out_rate-desc sort", () => { - const rows = [buildRow()]; - const { container } = render(); - const mock = container.querySelector( - '[data-testid="player-readout-boons"] [data-testid="ag-grid-mock"]', - ); - expect(mock).not.toBeNull(); - const props = JSON.parse(mock!.getAttribute("data-props") ?? "{}"); - // 5 SHARED + 9 boons (boons_out/in + 6 fixed + other_total) + 1 tiebreaker = 15. - // Wait: 5 + 8 (2 + 6) + 0 (other_total is a valueGetter NOT a column) = ? - // Counting precisely: boons_out_rate, boons_in_rate, 6 fixed, other_boons_total = 9 aspect cols. - // 5 + 9 + 1 = 15. - expect(props.columnDefs).toBe(15); - expect(props.sortModel).toEqual([ - { colId: "subgroup", sort: "asc" }, - { colId: "boons.boons_out_rate", sort: "desc" }, - { colId: "agent_id", sort: "asc" } - ]); - }); -}); - -describe("PlayerReadoutDefense", () => { - it("renders the empty-state panel when rows is empty", () => { - const { container } = render(); - expect( - container.querySelector('[data-testid="player-readout-defense-empty"]'), - ).not.toBeNull(); - }); - - it("renders AG Grid wrapper with 15 columns + damage_taken-desc sort", () => { - const rows = [buildRow()]; - const { container } = render(); - const mock = container.querySelector( - '[data-testid="player-readout-defense"] [data-testid="ag-grid-mock"]', - ); - expect(mock).not.toBeNull(); - const props = JSON.parse(mock!.getAttribute("data-props") ?? "{}"); - // 5 SHARED + 9 defense (incl. presence_pct) + 1 tiebreaker = 15. - expect(props.columnDefs).toBe(15); - expect(props.sortModel).toEqual([ - { colId: "subgroup", sort: "asc" }, - { colId: "defense.damage_taken", sort: "desc" }, - { colId: "agent_id", sort: "asc" } - ]); - }); -}); diff --git a/web/tests/components/player-readout-boons.test.tsx b/web/tests/components/player-readout-boons.test.tsx deleted file mode 100644 index 35439eef..00000000 --- a/web/tests/components/player-readout-boons.test.tsx +++ /dev/null @@ -1,141 +0,0 @@ -/** - * F17 W.10 (Tour 7 v0.10.25) component-level vitest spec for - * :component:`PlayerReadoutBoons`. See - * :file:`player-readout-damage.test.tsx` for the rationale on - * the local ``vi.mock`` override (web/tests/setup.ts mocks - * PlayerReadout* globally; we override locally to render the - * empty-state vs data-state branch without booting AG Grid). - * - * The Boons table uses an extra ``other_boons_out: dict[str, int]`` - * field per design doc §11's collapsed-cell resolution; the - * ``makeRow`` factory here exercises a non-empty dict so a - * future regression on the valueGetter would surface in CI. - */ -import { describe, expect, it, vi } from "vitest"; -import * as React from "react"; -import { render, screen } from "@testing-library/react"; - -vi.mock("@/components/PlayerReadoutBoons", () => ({ - PlayerReadoutBoons: ({ rows }: { rows: unknown[] }) => - rows.length === 0 - ? React.createElement( - "div", - { "data-testid": "player-readout-boons-empty" }, - "No player rows in this readout.", - ) - : React.createElement("div", { "data-testid": "player-readout-boons" }), -})); - -import { PlayerReadoutBoons } from "@/components/PlayerReadoutBoons"; -import type { PlayerReadoutOut } from "@/lib/api"; - -function makeRow( - overrides: Partial = {}, -): PlayerReadoutOut { - return { - account_name: "Boon.4242", - agent_id: 4, - boons: { - aegis_out: 12, - alacrity_out: 30, - boons_in_rate: 5.5, - boons_out_rate: 8.2, - other_boons_out: { - Fury: 18, - Might: 42, - Protection: 7, - Quickness: 0, - Regeneration: 5, - }, - might_uptime: null, - fury_uptime: null, - quickness_uptime: null, - alacrity_uptime: null, - protection_uptime: null, - regeneration_uptime: null, - vigor_uptime: null, - aegis_uptime: null, - stability_uptime: null, - swiftness_uptime: null, - resistance_uptime: null, - resolution_uptime: null, - superspeed_uptime: null, - stealth_uptime: null, - outgoing_might: null, - outgoing_fury: null, - outgoing_quickness: null, - outgoing_alacrity: null, - outgoing_protection: null, - outgoing_regeneration: null, - outgoing_vigor: null, - outgoing_aegis: null, - outgoing_stability: null, - outgoing_swiftness: null, - outgoing_resistance: null, - outgoing_resolution: null, - outgoing_superspeed: null, - outgoing_stealth: null, - resistance_out: 0, - stability_out: 4, - stealth_out: 2, - superspeed_out: 0, - }, - damage: { - cc_applied: 0, - down_contribution_dps: 0, - dps_condi: 0, - dps_power: 0, - dps_total: 0, - kills: 0, - cleave_targets: 0, - kill_participation: 0, - strips: 0, - }, - defense: { - barrier_absorbed: 0, - presence_pct: null, - dist_to_commander: null, - kill_participation: 0, - blocks: 0, - cc_taken: 0, - damage_taken: 0, - deaths: 0, - dodges: 0, - interrupts: 0, - time_downed_ms: 0, - }, - elite_spec: "Chronomancer", - heal: { - barrier_ps: 0, - barrier_total: 0, - cleanses: 0, - heal_total: 0, - hps: 0, - stun_breaks: 0, - }, - is_commander: false, - name: "Boon Player", - profession: "Mesmer", - roles: ["Boon"], - subgroup: 1, - ...overrides, - }; -} - -describe("PlayerReadoutBoons", () => { - it("renders the empty-state panel when rows is empty", () => { - render(); - expect( - screen.getByTestId("player-readout-boons-empty"), - ).toBeInTheDocument(); - expect(screen.getByText(/no player rows/i)).toBeInTheDocument(); - }); - - it("does NOT render the empty-state panel when rows is non-empty", () => { - render(); - expect( - screen.queryByTestId("player-readout-boons-empty"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("player-readout-boons")).toBeInTheDocument(); - }); -}); diff --git a/web/tests/components/player-readout-damage.test.tsx b/web/tests/components/player-readout-damage.test.tsx deleted file mode 100644 index 875a0d2c..00000000 --- a/web/tests/components/player-readout-damage.test.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/** - * F17 W.10 (Tour 7 v0.10.25) component-level vitest spec for - * :component:`PlayerReadoutDamage`. Mirrors the - * :file:`web/tests/components/PlayerReadoutBase.test.tsx` - * pattern: tests the empty-state rendering + the data-width - * invariant (rows=[] shows the empty panel; rows=N renders the - * container). - * - * Local mock override rationale - * ============================= - * ``web/tests/setup.ts`` globally mocks - * ``@/components/PlayerReadoutDamage`` as ``() => null`` so - * page-level tests don't boot AG Grid's full Canvas DOM. We - * override locally here with a faux-component that renders the - * correct test-id based on ``rows.length`` so the runtime - * branch contract (empty-state vs data-state) is observable. - * ``vi.mock`` is **hoisted** by vitest BEFORE imports at - * compile-time, so the local mock takes precedence over the - * global one for the duration of this test file. - */ -import { describe, expect, it, vi } from "vitest"; -import * as React from "react"; -import { render, screen } from "@testing-library/react"; - -vi.mock("@/components/PlayerReadoutDamage", () => ({ - PlayerReadoutDamage: ({ rows }: { rows: unknown[] }) => - rows.length === 0 - ? React.createElement( - "div", - { "data-testid": "player-readout-damage-empty" }, - "No player rows in this readout.", - ) - : React.createElement("div", { "data-testid": "player-readout-damage" }), -})); - -// Import AFTER the vi.mock override so the locally-mocked -// PlayerReadoutDamage is the resolved symbol for the test body. -import { PlayerReadoutDamage } from "@/components/PlayerReadoutDamage"; -import type { PlayerReadoutOut } from "@/lib/api"; - -function makeRow(overrides: Partial = {}): PlayerReadoutOut { - return { - account_name: "Test.1234", - agent_id: 1, - boons: { - aegis_out: 0, - alacrity_out: 0, - boons_in_rate: 0, - boons_out_rate: 0, - other_boons_out: {}, - might_uptime: null, - fury_uptime: null, - quickness_uptime: null, - alacrity_uptime: null, - protection_uptime: null, - regeneration_uptime: null, - vigor_uptime: null, - aegis_uptime: null, - stability_uptime: null, - swiftness_uptime: null, - resistance_uptime: null, - resolution_uptime: null, - superspeed_uptime: null, - stealth_uptime: null, - outgoing_might: null, - outgoing_fury: null, - outgoing_quickness: null, - outgoing_alacrity: null, - outgoing_protection: null, - outgoing_regeneration: null, - outgoing_vigor: null, - outgoing_aegis: null, - outgoing_stability: null, - outgoing_swiftness: null, - outgoing_resistance: null, - outgoing_resolution: null, - outgoing_superspeed: null, - outgoing_stealth: null, - resistance_out: 0, - stability_out: 0, - stealth_out: 0, - superspeed_out: 0, - }, - damage: { - cc_applied: 0, - down_contribution_dps: 0, - dps_condi: 0, - dps_power: 0, - dps_total: 1500, - kills: 2, - strips: 4, - cleave_targets: 0, - kill_participation: 0, - }, - defense: { - barrier_absorbed: 0, - presence_pct: null, - dist_to_commander: null, - kill_participation: 0, - blocks: 0, - cc_taken: 0, - damage_taken: 0, - deaths: 0, - dodges: 0, - interrupts: 0, - time_downed_ms: 0, - }, - elite_spec: "Berserker", - heal: { - barrier_ps: 0, - barrier_total: 0, - cleanses: 0, - heal_total: 0, - hps: 0, - stun_breaks: 0, - }, - is_commander: false, - name: "Test Player", - profession: "Warrior", - roles: ["DPS"], - subgroup: 1, - ...overrides, - }; -} - -describe("PlayerReadoutDamage", () => { - it("renders the empty-state panel when rows is empty", () => { - render(); - expect( - screen.getByTestId("player-readout-damage-empty"), - ).toBeInTheDocument(); - expect(screen.getByText(/no player rows/i)).toBeInTheDocument(); - }); - - it("does NOT render the empty-state panel when rows is non-empty", () => { - render(); - expect( - screen.queryByTestId("player-readout-damage-empty"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("player-readout-damage")).toBeInTheDocument(); - }); -}); diff --git a/web/tests/components/player-readout-defense.test.tsx b/web/tests/components/player-readout-defense.test.tsx deleted file mode 100644 index d66f71c9..00000000 --- a/web/tests/components/player-readout-defense.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/** - * F17 W.10 (Tour 7 v0.10.25) component-level vitest spec for - * :component:`PlayerReadoutDefense`. See - * :file:`player-readout-damage.test.tsx` for the rationale on - * the local ``vi.mock`` override (web/tests/setup.ts mocks - * PlayerReadout* globally; we override locally to render the - * empty-state vs data-state branch without booting AG Grid). - */ -import { describe, expect, it, vi } from "vitest"; -import * as React from "react"; -import { render, screen } from "@testing-library/react"; - -vi.mock("@/components/PlayerReadoutDefense", () => ({ - PlayerReadoutDefense: ({ rows }: { rows: unknown[] }) => - rows.length === 0 - ? React.createElement( - "div", - { "data-testid": "player-readout-defense-empty" }, - "No player rows in this readout.", - ) - : React.createElement("div", { "data-testid": "player-readout-defense" }), -})); - -import { PlayerReadoutDefense } from "@/components/PlayerReadoutDefense"; -import type { PlayerReadoutOut } from "@/lib/api"; - -function makeRow( - overrides: Partial = {}, -): PlayerReadoutOut { - return { - account_name: "Tank.9999", - agent_id: 3, - boons: { - aegis_out: 0, - alacrity_out: 0, - boons_in_rate: 0, - boons_out_rate: 0, - other_boons_out: {}, - might_uptime: null, - fury_uptime: null, - quickness_uptime: null, - alacrity_uptime: null, - protection_uptime: null, - regeneration_uptime: null, - vigor_uptime: null, - aegis_uptime: null, - stability_uptime: null, - swiftness_uptime: null, - resistance_uptime: null, - resolution_uptime: null, - superspeed_uptime: null, - stealth_uptime: null, - outgoing_might: null, - outgoing_fury: null, - outgoing_quickness: null, - outgoing_alacrity: null, - outgoing_protection: null, - outgoing_regeneration: null, - outgoing_vigor: null, - outgoing_aegis: null, - outgoing_stability: null, - outgoing_swiftness: null, - outgoing_resistance: null, - outgoing_resolution: null, - outgoing_superspeed: null, - outgoing_stealth: null, - resistance_out: 0, - stability_out: 0, - stealth_out: 0, - superspeed_out: 0, - }, - damage: { - cc_applied: 0, - down_contribution_dps: 0, - dps_condi: 0, - dps_power: 0, - dps_total: 0, - kills: 0, - cleave_targets: 0, - kill_participation: 0, - strips: 0, - }, - defense: { - barrier_absorbed: 1500, - presence_pct: null, - dist_to_commander: null, - kill_participation: 0, - blocks: 5, - cc_taken: 3, - damage_taken: 12000, - deaths: 1, - dodges: 8, - interrupts: 2, - time_downed_ms: 4200, - }, - elite_spec: "Firebrand", - heal: { - barrier_ps: 0, - barrier_total: 0, - cleanses: 0, - heal_total: 0, - hps: 0, - stun_breaks: 0, - }, - is_commander: false, - name: "Tank Player", - profession: "Guardian", - roles: ["Tank"], - subgroup: 2, - ...overrides, - }; -} - -describe("PlayerReadoutDefense", () => { - it("renders the empty-state panel when rows is empty", () => { - render(); - expect( - screen.getByTestId("player-readout-defense-empty"), - ).toBeInTheDocument(); - expect(screen.getByText(/no player rows/i)).toBeInTheDocument(); - }); - - it("does NOT render the empty-state panel when rows is non-empty", () => { - render(); - expect( - screen.queryByTestId("player-readout-defense-empty"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("player-readout-defense")).toBeInTheDocument(); - }); -}); diff --git a/web/tests/components/player-readout-heal.test.tsx b/web/tests/components/player-readout-heal.test.tsx deleted file mode 100644 index fe545b70..00000000 --- a/web/tests/components/player-readout-heal.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/** - * F17 W.10 (Tour 7 v0.10.25) component-level vitest spec for - * :component:`PlayerReadoutHeal`. See - * :file:`player-readout-damage.test.tsx` for the rationale on - * the local ``vi.mock`` override (web/tests/setup.ts mocks - * PlayerReadout* globally; we override locally to render the - * empty-state vs data-state branch without booting AG Grid). - */ -import { describe, expect, it, vi } from "vitest"; -import * as React from "react"; -import { render, screen } from "@testing-library/react"; - -vi.mock("@/components/PlayerReadoutHeal", () => ({ - PlayerReadoutHeal: ({ rows }: { rows: unknown[] }) => - rows.length === 0 - ? React.createElement( - "div", - { "data-testid": "player-readout-heal-empty" }, - "No player rows in this readout.", - ) - : React.createElement("div", { "data-testid": "player-readout-heal" }), -})); - -import { PlayerReadoutHeal } from "@/components/PlayerReadoutHeal"; -import type { PlayerReadoutOut } from "@/lib/api"; - -function makeRow( - overrides: Partial = {}, -): PlayerReadoutOut { - return { - account_name: "Heal.5678", - agent_id: 2, - boons: { - aegis_out: 0, - alacrity_out: 0, - boons_in_rate: 0, - boons_out_rate: 0, - other_boons_out: {}, - might_uptime: null, - fury_uptime: null, - quickness_uptime: null, - alacrity_uptime: null, - protection_uptime: null, - regeneration_uptime: null, - vigor_uptime: null, - aegis_uptime: null, - stability_uptime: null, - swiftness_uptime: null, - resistance_uptime: null, - resolution_uptime: null, - superspeed_uptime: null, - stealth_uptime: null, - outgoing_might: null, - outgoing_fury: null, - outgoing_quickness: null, - outgoing_alacrity: null, - outgoing_protection: null, - outgoing_regeneration: null, - outgoing_vigor: null, - outgoing_aegis: null, - outgoing_stability: null, - outgoing_swiftness: null, - outgoing_resistance: null, - outgoing_resolution: null, - outgoing_superspeed: null, - outgoing_stealth: null, - resistance_out: 0, - stability_out: 0, - stealth_out: 0, - superspeed_out: 0, - }, - damage: { - cc_applied: 0, - down_contribution_dps: 0, - dps_condi: 0, - dps_power: 0, - dps_total: 0, - kills: 0, - cleave_targets: 0, - kill_participation: 0, - strips: 0, - }, - defense: { - barrier_absorbed: 0, - presence_pct: null, - dist_to_commander: null, - kill_participation: 0, - blocks: 0, - cc_taken: 0, - damage_taken: 0, - deaths: 0, - dodges: 0, - interrupts: 0, - time_downed_ms: 0, - }, - elite_spec: "Tempest", - heal: { - barrier_ps: 250, - barrier_total: 1500, - cleanses: 6, - heal_total: 9000, - hps: 1100, - stun_breaks: 3, - }, - is_commander: true, - name: "Heal Player", - profession: "Elementalist", - roles: ["HEAL", "Support"], - subgroup: 1, - ...overrides, - }; -} - -describe("PlayerReadoutHeal", () => { - it("renders the empty-state panel when rows is empty", () => { - render(); - expect( - screen.getByTestId("player-readout-heal-empty"), - ).toBeInTheDocument(); - expect(screen.getByText(/no player rows/i)).toBeInTheDocument(); - }); - - it("does NOT render the empty-state panel when rows is non-empty", () => { - render(); - expect( - screen.queryByTestId("player-readout-heal-empty"), - ).not.toBeInTheDocument(); - expect(screen.getByTestId("player-readout-heal")).toBeInTheDocument(); - }); -}); diff --git a/web/tests/setup.ts b/web/tests/setup.ts index 794dd991..9b750023 100644 --- a/web/tests/setup.ts +++ b/web/tests/setup.ts @@ -353,44 +353,4 @@ vi.mock("@/components/PerFightTimelineSection", () => ({ * chart be tested directly at the component level. */ -/** - * Tour 6 Wave 7 (Workstream F): ``@/components/PlayerReadoutDamage`` - * is the AG Grid Community 34 Client Component for the Combat - * readout §3 Damage table (per docs/v0.9.0-combat-readout-design.md). - * Mocked as a no-op at the page-level test layer so the page test - * can render the wrapper without booting AG Grid's runtime in - * jsdom (no canvas, no offsetWidth). A dedicated component-level - * test in :file:`web/tests/components/combat-readout.test.tsx` - * exercises the column defs + default sort + empty-state panel. - */ -vi.mock("@/components/PlayerReadoutDamage", () => ({ - PlayerReadoutDamage: () => null, -})); - -/** - * Tour 6 Wave 7 (Workstream F): ``@/components/PlayerReadoutHeal``. - * Mocked as a no-op at the page-level test layer; component-level - * coverage lives in :file:`web/tests/components/combat-readout.test.tsx`. - */ -vi.mock("@/components/PlayerReadoutHeal", () => ({ - PlayerReadoutHeal: () => null, -})); -/** - * Tour 6 Wave 7 (Workstream F): ``@/components/PlayerReadoutBoons``. - * Mocked as a no-op at the page-level test layer; the dynamic - * ``other_boons_total`` ``valueGetter`` is exercised at the - * component level in :file:`web/tests/components/combat-readout.test.tsx`. - */ -vi.mock("@/components/PlayerReadoutBoons", () => ({ - PlayerReadoutBoons: () => null, -})); - -/** - * Tour 6 Wave 7 (Workstream F): ``@/components/PlayerReadoutDefense``. - * Mocked as a no-op at the page-level test layer; component-level - * coverage lives in :file:`web/tests/components/combat-readout.test.tsx`. - */ -vi.mock("@/components/PlayerReadoutDefense", () => ({ - PlayerReadoutDefense: () => null, -}));