js-dojo

2 router, walker, leaf

GOAL

finish a recursive skill-tree renderer's three jobs - route on kind, walk the unlocks one tier deeper, stop at a talent.

CONCEPT

a recursive component never calls itself blindly. A router picks the component for each node, a walker maps that node's unlocks back through the router at tier + 1, and the leaf draws itself and recurses into nothing.

HINT

exactly one of the three functions ever writes tier + 1.

MIRRORS

the pause menu's skill tree. One entry component asks what a node is, a discipline redraws everything it unlocks one tier in, and a talent ends the line. Forget the base case and the HUD redraws until the stack gives out.

Run

This koan renders React, so it needs a one-time setup.

pnpm koan 07-recursive-components/02-router-walker-leaf.tsx

Source

// DOJO · Module 7 / Exercise 2 — router, walker, leaf
// GOAL: finish a recursive skill-tree renderer's three jobs - route on
//          kind, walk the unlocks one tier deeper, stop at a talent.
// CONCEPT: a recursive component never calls itself blindly. A router
//          picks the component for each node, a walker maps that node's
//          unlocks back through the router at tier + 1, and the leaf
//          draws itself and recurses into nothing.
// HINT: exactly one of the three functions ever writes tier + 1.
// MIRRORS: the pause menu's skill tree. One entry component asks what a
//          node is, a discipline redraws everything it unlocks one tier
//          in, and a talent ends the line. Forget the base case and the
//          HUD redraws until the stack gives out.
// Run: pnpm koan 07-recursive-components/02-router-walker-leaf.tsx

import type { ReactNode } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
  true;
afterEach(cleanup);

interface SkillNode {
  kind: "discipline" | "talent";
  name: string;
  unlocks: SkillNode[];
}

const SKILL_TREE: SkillNode = {
  kind: "discipline",
  name: "combat",
  unlocks: [
    {
      kind: "discipline",
      name: "blades",
      unlocks: [{ kind: "talent", name: "riposte", unlocks: [] }],
    },
    { kind: "talent", name: "shield-bash", unlocks: [] },
  ],
};

interface EntryProps {
  node: SkillNode;
  tier: number;
}

function SkillEntry({ node, tier }: EntryProps) {
  // -- TODO 1 --------------------------------------------------------
  // The router's one job: read node.kind and hand the node to the
  // component that knows how to draw it. As shipped it sends EVERY node
  // to TalentEntry, so a discipline never gets a chance to draw what it
  // unlocks.
  return <TalentEntry node={node} tier={tier} />;
}

function DisciplineEntry({ node, tier }: EntryProps) {
  // -- TODO 2 --------------------------------------------------------
  // The walker's one job: draw every entry of node.unlocks back through
  // SkillEntry, each one tier deeper than this entry. As shipped
  // unlockedEntries is null, so the recursion never starts.
  const unlockedEntries: ReactNode = null;
  return (
    <div>
      <div data-skill={node.name} data-kind="discipline" data-tier={tier}>
        {node.name}
      </div>
      {unlockedEntries}
    </div>
  );
}

function TalentEntry({ node, tier }: EntryProps) {
  // -- TODO 3 --------------------------------------------------------
  // The base case: draw the name with data-skill, data-kind="talent" and
  // data-tier attributes (match DisciplineEntry's entry div), and
  // recurse into nothing. As shipped a talent draws nothing at all.
  return null;
}

describe("Module 7 / Exercise 2 - router, walker, leaf", () => {
  it("TODO 1 - the router sends a discipline to DisciplineEntry", () => {
    render(<SkillEntry node={SKILL_TREE} tier={0} />);
    const root = screen.queryByText("combat");
    expect(
      root?.getAttribute("data-kind"),
      "TODO 1: the router must send a discipline node to DisciplineEntry, " +
        "not to TalentEntry",
    ).toBe("discipline");
    expect(
      root?.getAttribute("data-tier"),
      "TODO 1: the node the router was handed draws at the tier it was " +
        "given, here 0",
    ).toBe("0");
  });

  it("TODO 2 - the walker draws the unlocks one tier deeper", () => {
    render(<SkillEntry node={SKILL_TREE} tier={0} />);
    const blades = screen.queryByText("blades");
    expect(
      blades?.getAttribute("data-kind"),
      "TODO 2: a nested discipline only appears once the walker maps " +
        "node.unlocks back through SkillEntry",
    ).toBe("discipline");
    expect(
      blades?.getAttribute("data-tier"),
      "TODO 2: the walker is the only place tier + 1 is written",
    ).toBe("1");
  });

  it("TODO 3 - the talent base case draws, and the tree reads in order", () => {
    const { container } = render(<SkillEntry node={SKILL_TREE} tier={0} />);
    expect(
      screen.queryByText("riposte")?.getAttribute("data-kind"),
      "TODO 3: a talent draws itself and stops",
    ).toBe("talent");
    expect(
      screen.queryByText("riposte")?.getAttribute("data-tier"),
      "TODO 3: riposte sits two tiers under the root",
    ).toBe("2");
    expect(
      screen.queryByText("shield-bash")?.getAttribute("data-tier"),
      "TODO 3: shield-bash is unlocked straight off the root, so tier 1",
    ).toBe("1");
    const names = Array.from(container.querySelectorAll("[data-skill]")).map(
      (el) => el.getAttribute("data-skill"),
    );
    expect(
      names,
      "TODO 3: the three jobs together walk the tree depth-first, so the " +
        "entries read in this order",
    ).toEqual(["combat", "blades", "riposte", "shield-bash"]);
  });
});

Solution