Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions apps/web/src/features/coaching/CoachingTree.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { CoachingTree } from './CoachingTree';

const rootCoach = {
id: 'root-hc',
name: 'Dana Vale',
role: 'HC',
archetype: 'strategist',
traits: ['calm_clock'],
mentorCoachId: 'mentor-direct',
disciples: ['disciple-one'],
yearsUnderMentor: 4,
};

const directMentor = {
id: 'mentor-direct',
name: 'Mika Stone',
role: 'HC',
archetype: 'defensive_minded',
traits: ['red_zone_wall'],
mentorCoachId: 'mentor-indirect',
disciples: ['root-hc'],
yearsUnderMentor: 5,
};

const indirectMentor = {
id: 'mentor-indirect',
name: 'Alex North',
role: 'HC',
archetype: 'disciplinarian',
traits: ['accountability'],
mentorCoachId: null,
disciples: ['mentor-direct'],
yearsUnderMentor: 0,
};

const disciple = {
id: 'disciple-one',
name: 'River Cross',
role: 'OC',
archetype: 'offensive_minded',
traits: ['motion_packages'],
mentorCoachId: 'root-hc',
disciples: [],
yearsUnderMentor: 2,
};

const userTeam = {
id: 'user',
abbr: 'USR',
staff: { hc: rootCoach, oc: null, dc: null },
};

const mockState = {
game: {
year: 2032,
eventLog: [],
coachingHistory: [],
teams: {
user: userTeam,
north: { id: 'north', abbr: 'NTH', staff: { hc: directMentor, oc: null, dc: null } },
summit: { id: 'summit', abbr: 'SUM', staff: { hc: indirectMentor, oc: null, dc: null } },
metro: { id: 'metro', abbr: 'MET', staff: { hc: null, oc: disciple, dc: null } },
},
},
userTeam,
};

vi.mock('@mfd/engine', async (importOriginal) => {
const actual = await importOriginal<typeof import('@mfd/engine')>();
return {
...actual,
buildCoachingLegacy: () => ({
treeDepth: 3,
headCoachesProduced: 1,
coordinatorsPlaced: 1,
retiredWithEpilogue: 0,
notableProteges: [disciple],
}),
};
});

vi.mock('../../app/store/game-store', () => ({
useGameStore: (selector: (state: typeof mockState) => unknown) => selector(mockState),
selectUserTeam: (state: typeof mockState) => state.userTeam,
}));

describe('CoachingTree', () => {
it('renders archetype glyphs next to each coach card', () => {
const markup = renderToStaticMarkup(<CoachingTree />);

expect(markup.match(/data-coach-archetype-glyph=/g)).toHaveLength(4);
});

it('renders SVG connection lines between linked coaches', () => {
const markup = renderToStaticMarkup(<CoachingTree />);

expect(markup).toContain('data-coaching-tree-connection-lines="true"');
expect(markup).toContain('data-coaching-tree-line="true"');
});

it('uses gold stroke for direct mentor relationships', () => {
const markup = renderToStaticMarkup(<CoachingTree />);

expect(markup).toContain('data-line-kind="direct-mentor"');
expect(markup).toContain('stroke="var(--mfd-gold)"');
});

it('uses dashed connection lines for indirect relationships', () => {
const markup = renderToStaticMarkup(<CoachingTree />);

expect(markup).toContain('data-line-kind="indirect-mentor"');
expect(markup).toContain('stroke-dasharray="5 5"');
});

it('preserves the existing coaching-tree behavior surface', () => {
const markup = renderToStaticMarkup(<CoachingTree />);

expect(markup).toContain('COACHING TREE');
expect(markup).toContain('TREE DEPTH');
expect(markup).toContain('DANA VALE');
expect(markup).toContain('MIKA STONE');
expect(markup).toContain('RIVER CROSS');
});
});
169 changes: 118 additions & 51 deletions apps/web/src/features/coaching/CoachingTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
screenStackStyle,
teamThemeVars,
} from '../shared/pixelUi';
import { CoachArchetypeGlyphSvg } from './coachArchetypeGlyphSvg';

interface ResolvedCoach {
coachId: string;
Expand Down Expand Up @@ -209,11 +210,14 @@ function CoachCard({
? `${labelPrefix} ${entry.roleLabel}: ${entry.name}`
: `${entry.roleLabel}: ${entry.name}`;
return (
<div style={{ position: 'relative' }}>
<div style={{ position: 'relative', zIndex: 1 }}>
<PixelPanel title={title} accent={accent}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', paddingBottom: entry.retired ? '18px' : 0 }}>
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>
Team: {entry.teamAbbr} | Archetype: {entry.archetype}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
<CoachArchetypeGlyphSvg archetype={entry.archetype} label={`${entry.archetype} archetype`} />
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>
Team: {entry.teamAbbr} | Archetype: {entry.archetype}
</div>
</div>
{typeof entry.yearsUnderMentor === 'number' && entry.yearsUnderMentor > 0 ? (
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>
Expand All @@ -238,6 +242,66 @@ function CoachCard({
);
}

function lineWidth(entry: ResolvedCoach): number {
const years = entry.yearsUnderMentor ?? 1;
return Math.max(1, Math.min(3, 1 + Math.floor(years / 3)));
}

function CoachingTreeConnectionLines({
mentorChain,
disciples,
}: {
mentorChain: ResolvedCoach[];
disciples: ResolvedCoach[];
}) {
if (mentorChain.length === 0 && disciples.length === 0) return null;
return (
<svg
data-coaching-tree-connection-lines="true"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
opacity: 0.78,
}}
>
{mentorChain.map((entry, idx) => {
const direct = idx === 0;
return (
<path
key={`mentor-line-${entry.coachId}`}
data-coaching-tree-line="true"
data-line-kind={direct ? 'direct-mentor' : 'indirect-mentor'}
d={direct ? 'M34 34 C42 36 46 45 50 52' : `M22 ${24 + idx * 12} C34 ${28 + idx * 10} 41 ${38 + idx * 4} 50 52`}
fill="none"
stroke={direct ? 'var(--mfd-gold)' : 'var(--mfd-text-dim)'}
strokeWidth={lineWidth(entry)}
strokeLinecap="round"
strokeDasharray={direct ? undefined : '5 5'}
/>
);
})}
{disciples.map((entry, idx) => (
<path
key={`disciple-line-${entry.coachId}`}
data-coaching-tree-line="true"
data-line-kind="direct-disciple"
d={`M50 52 C60 ${44 + idx * 9} 68 ${37 + idx * 10} 78 ${31 + idx * 10}`}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp disciple connector coordinates to the SVG viewport

The disciple path formula pushes Y coordinates beyond the viewBox height once there are many disciples (31 + idx * 10 exceeds 100 at idx >= 7), so later disciples lose their connector lines even though cards still render. This affects long-running saves where a head coach has 8+ disciples and makes the new relationship overlay incomplete for valid data.

Useful? React with 👍 / 👎.

fill="none"
stroke="var(--mfd-cyan)"
strokeWidth={lineWidth(entry)}
strokeLinecap="round"
/>
))}
</svg>
);
}

function EmptyState() {
return (
<PixelPanel title="NO LINEAGE YET" accent="default">
Expand Down Expand Up @@ -350,54 +414,57 @@ export function CoachingTree() {
<PixelMetricCard label="Notable Proteges" value={legacy.notableProteges.length} accent="default" detail="Top five active branches ranked by titles and tenure" />
</div>

<div style={layoutStyle}>
{/* Mentor chain upward */}
<div style={{ ...columnStyle, flexBasis: '320px' }}>
<div style={{ ...pixelSm, color: 'var(--mfd-gold)' }}>MENTOR CHAIN</div>
{mentorChain.length === 0 ? (
<PixelPanel title="NO MENTOR ON RECORD" accent="default">
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>
Your head coach has no recorded mentor.
</div>
</PixelPanel>
) : (
mentorChain.map((entry, idx) => (
<CoachCard
key={entry.coachId}
entry={entry}
accent="cyan"
labelPrefix={`Mentor ${idx + 1}`}
/>
))
)}
</div>

{/* User head coach centered */}
<div style={{ ...columnStyle, flexBasis: '320px' }}>
<div style={{ ...pixelSm, color: 'var(--mfd-gold)' }}>YOUR HEAD COACH</div>
<CoachCard entry={root} accent="gold" labelPrefix="" />
{!hasLineage ? <EmptyState /> : null}
</div>

{/* Disciples fan downward */}
<div style={{ ...columnStyle, flexBasis: '320px' }}>
<div style={{ ...pixelSm, color: 'var(--mfd-gold)' }}>DISCIPLES</div>
{disciples.length === 0 ? (
<PixelPanel title="NO DISCIPLES YET" accent="default">
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>
No coaches have come up under this tree.
</div>
</PixelPanel>
) : (
disciples.map((entry) => (
<CoachCard
key={entry.coachId}
entry={entry}
accent="cyan"
labelPrefix="Disciple"
/>
))
)}
<div style={{ position: 'relative' }}>
<CoachingTreeConnectionLines mentorChain={mentorChain} disciples={disciples} />
<div style={layoutStyle}>
{/* Mentor chain upward */}
<div style={{ ...columnStyle, flexBasis: '320px' }}>
<div style={{ ...pixelSm, color: 'var(--mfd-gold)' }}>MENTOR CHAIN</div>
{mentorChain.length === 0 ? (
<PixelPanel title="NO MENTOR ON RECORD" accent="default">
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>
Your head coach has no recorded mentor.
</div>
</PixelPanel>
) : (
mentorChain.map((entry, idx) => (
<CoachCard
key={entry.coachId}
entry={entry}
accent="cyan"
labelPrefix={`Mentor ${idx + 1}`}
/>
))
)}
</div>

{/* User head coach centered */}
<div style={{ ...columnStyle, flexBasis: '320px' }}>
<div style={{ ...pixelSm, color: 'var(--mfd-gold)' }}>YOUR HEAD COACH</div>
<CoachCard entry={root} accent="gold" labelPrefix="" />
{!hasLineage ? <EmptyState /> : null}
</div>

{/* Disciples fan downward */}
<div style={{ ...columnStyle, flexBasis: '320px' }}>
<div style={{ ...pixelSm, color: 'var(--mfd-gold)' }}>DISCIPLES</div>
{disciples.length === 0 ? (
<PixelPanel title="NO DISCIPLES YET" accent="default">
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)' }}>
No coaches have come up under this tree.
</div>
</PixelPanel>
) : (
disciples.map((entry) => (
<CoachCard
key={entry.coachId}
entry={entry}
accent="cyan"
labelPrefix="Disciple"
/>
))
)}
</div>
</div>
</div>
</div>
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/features/coaching/coachArchetypeGlyphSvg.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { COACH_ARCHETYPE_GLYPHS, CoachArchetypeGlyphSvg } from './coachArchetypeGlyphSvg';

describe('CoachArchetypeGlyphSvg', () => {
it('renders the offensive_mind variant with route path data', () => {
const markup = renderToStaticMarkup(<CoachArchetypeGlyphSvg archetype="offensive_mind" label="Offense" />);

expect(markup).toContain('data-coach-archetype-glyph="offensive_mind"');
expect(markup).toContain('data-glyph-routes="true"');
});

it('renders all 6 glyph variants without error', () => {
for (const archetype of COACH_ARCHETYPE_GLYPHS) {
const markup = renderToStaticMarkup(<CoachArchetypeGlyphSvg archetype={archetype} label={archetype} />);

expect(markup).toContain(`data-coach-archetype-glyph="${archetype}"`);
}
});

it('renders a shield for defensive_mind', () => {
const markup = renderToStaticMarkup(<CoachArchetypeGlyphSvg archetype="defensive_mind" label="Defense" />);

expect(markup).toContain('data-glyph-shield="true"');
});

it('renders a handshake for players_coach', () => {
const markup = renderToStaticMarkup(<CoachArchetypeGlyphSvg archetype="players_coach" label="Players" />);

expect(markup).toContain('data-glyph-handshake="true"');
});

it('renders a flame for fire_starter', () => {
const markup = renderToStaticMarkup(<CoachArchetypeGlyphSvg archetype="fire_starter" label="Fire" />);

expect(markup).toContain('data-glyph-flame="true"');
});

it('renders distinct SVG path data across every glyph', () => {
const signatures = COACH_ARCHETYPE_GLYPHS.map((archetype) =>
renderToStaticMarkup(<CoachArchetypeGlyphSvg archetype={archetype} label={archetype} />).match(/d="[^"]+"/g)?.join('|') ?? '',
);

expect(new Set(signatures)).toHaveLength(COACH_ARCHETYPE_GLYPHS.length);
});
});
Loading
Loading