> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kodelabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Run your first FTT test

> Run an on-demand FCU heating test on one device and read the pass or fail score in KODE OS.

export const TutorialStep = ({tutorialId, id, number, title, stepIds = "", subStepIds = "", mediaLabel, children}) => {
  const progressEvent = "kode-tutorial-progress";
  const brandBlues = ["#015BFA", "#3A9AFF", "#7BB3FF", "#C5DBFF", "#E8F1FF"];
  const [complete, setComplete] = useState(false);
  const parseIds = value => {
    if (Array.isArray(value)) return value;
    if (typeof value === "string" && value.length > 0) {
      return value.split(",").map(stepId => stepId.trim()).filter(Boolean);
    }
    return [];
  };
  const hasSubsteps = parseIds(subStepIds).length > 0;
  const readProgress = () => {
    if (typeof window === "undefined") return {};
    try {
      return JSON.parse(localStorage.getItem(`kode-tutorial:${tutorialId}`) || "{}");
    } catch {
      return {};
    }
  };
  const writeProgress = next => {
    if (typeof window === "undefined") return;
    localStorage.setItem(`kode-tutorial:${tutorialId}`, JSON.stringify(next));
    window.dispatchEvent(new CustomEvent(progressEvent, {
      detail: {
        tutorialId,
        progress: next
      }
    }));
  };
  const computeComplete = current => {
    const subs = parseIds(subStepIds);
    if (subs.length > 0) {
      return subs.every(stepId => current[stepId] === true);
    }
    return current[id] === true;
  };
  const playConfetti = () => {
    if (typeof window === "undefined" || typeof document === "undefined") return;
    const canvas = document.createElement("canvas");
    canvas.className = "tutorial-confetti-canvas";
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    document.body.appendChild(canvas);
    const ctx = canvas.getContext("2d");
    const pieces = Array.from({
      length: 80
    }, () => ({
      x: Math.random() * canvas.width,
      y: -20 - Math.random() * canvas.height * 0.35,
      w: 4 + Math.random() * 5,
      h: 6 + Math.random() * 8,
      color: brandBlues[Math.floor(Math.random() * brandBlues.length)],
      vy: 2.2 + Math.random() * 3.2,
      vx: -1.5 + Math.random() * 3,
      rot: Math.random() * Math.PI,
      vr: -0.12 + Math.random() * 0.24,
      alpha: 0.55 + Math.random() * 0.35
    }));
    let frame = 0;
    const maxFrames = 110;
    const draw = () => {
      frame += 1;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      pieces.forEach(piece => {
        piece.x += piece.vx;
        piece.y += piece.vy;
        piece.rot += piece.vr;
        piece.vy += 0.045;
        const fade = Math.max(0, 1 - frame / maxFrames);
        ctx.save();
        ctx.translate(piece.x, piece.y);
        ctx.rotate(piece.rot);
        ctx.globalAlpha = piece.alpha * fade;
        ctx.fillStyle = piece.color;
        ctx.fillRect(-piece.w / 2, -piece.h / 2, piece.w, piece.h);
        ctx.restore();
      });
      if (frame < maxFrames) {
        requestAnimationFrame(draw);
      } else {
        canvas.remove();
      }
    };
    requestAnimationFrame(draw);
  };
  useEffect(() => {
    const current = readProgress();
    setComplete(computeComplete(current));
    const onProgress = event => {
      if (event.detail?.tutorialId === tutorialId) {
        setComplete(computeComplete(event.detail.progress || ({})));
      }
    };
    window.addEventListener(progressEvent, onProgress);
    return () => window.removeEventListener(progressEvent, onProgress);
  }, [tutorialId, id, subStepIds]);
  const toggleComplete = () => {
    if (hasSubsteps) return;
    const current = readProgress();
    const nextValue = !current[id];
    const next = {
      ...current,
      [id]: nextValue
    };
    writeProgress(next);
    setComplete(nextValue);
    const topLevel = parseIds(stepIds);
    if (nextValue && topLevel.length > 0 && topLevel.every(stepId => next[stepId] === true)) {
      playConfetti();
    }
  };
  return <section id={`tutorial-step-${id}`} className={`tutorial-step-card${complete ? " is-complete" : ""}`}>
      <div className="tutorial-step-header">
        <div>
          <p className="tutorial-step-number">Step {number}</p>
          <h3 className="tutorial-step-title">{title}</h3>
        </div>
        <button type="button" className={`tutorial-complete-btn${complete ? " is-complete" : ""}${hasSubsteps ? " is-locked" : ""}`} onClick={toggleComplete} aria-pressed={complete} aria-label={hasSubsteps ? complete ? "All substeps complete" : "Complete all substeps first" : complete ? "Mark step incomplete" : "Mark step complete"} title={hasSubsteps ? complete ? "All substeps complete" : "Complete all substeps first" : complete ? "Mark incomplete" : "Mark complete"} disabled={hasSubsteps}>
          {complete ? "✓" : ""}
        </button>
      </div>

      {mediaLabel ? <div className="tutorial-media-slot" aria-label="Media placeholder">
          <div className="tutorial-media-placeholder">
            <span>{mediaLabel}</span>
            <span className="tutorial-media-hint">
              Replace this block with a Frame, image, or video when ready
            </span>
          </div>
        </div> : null}

      <div className="tutorial-step-body">{children}</div>

      <div className="tutorial-step-footer">
        {hasSubsteps ? <p className="tutorial-step-gate">
            {complete ? "All substeps complete" : "Mark each substep complete to finish this step"}
          </p> : <button type="button" className={`tutorial-mark-complete${complete ? " is-complete" : ""}`} onClick={toggleComplete}>
            {complete ? "Completed" : "Mark step complete"}
          </button>}
      </div>
    </section>;
};

export const TutorialProgress = ({tutorialId, steps = []}) => {
  const progressEvent = "kode-tutorial-progress";
  const [progress, setProgress] = useState({});
  const [activeId, setActiveId] = useState(steps[0]?.id || "");
  const parseSubsteps = step => {
    if (!step?.substeps) return [];
    if (Array.isArray(step.substeps)) return step.substeps;
    if (typeof step.substeps !== "string") return [];
    return step.substeps.split(";").map(entry => entry.trim()).filter(Boolean).map(entry => {
      const sep = entry.indexOf(":");
      if (sep === -1) return {
        id: entry,
        title: entry
      };
      return {
        id: entry.slice(0, sep).trim(),
        title: entry.slice(sep + 1).trim()
      };
    });
  };
  const isStepComplete = (step, current) => {
    const subs = parseSubsteps(step);
    if (subs.length > 0) {
      return subs.every(sub => current[sub.id] === true);
    }
    return current[step.id] === true;
  };
  const readProgress = () => {
    if (typeof window === "undefined") return {};
    try {
      return JSON.parse(localStorage.getItem(`kode-tutorial:${tutorialId}`) || "{}");
    } catch {
      return {};
    }
  };
  const resetProgress = () => {
    if (typeof window === "undefined") return;
    localStorage.removeItem(`kode-tutorial:${tutorialId}`);
    window.dispatchEvent(new CustomEvent(progressEvent, {
      detail: {
        tutorialId,
        progress: {}
      }
    }));
  };
  useEffect(() => {
    setProgress(readProgress());
    const onProgress = event => {
      if (event.detail?.tutorialId === tutorialId) {
        setProgress(event.detail.progress || ({}));
      }
    };
    window.addEventListener(progressEvent, onProgress);
    return () => window.removeEventListener(progressEvent, onProgress);
  }, [tutorialId]);
  useEffect(() => {
    const ids = [];
    steps.forEach(step => {
      ids.push(step.id);
      parseSubsteps(step).forEach(sub => ids.push(sub.id));
    });
    const nodes = ids.map(stepId => {
      return document.getElementById(`tutorial-step-${stepId}`) || document.getElementById(`tutorial-substep-${stepId}`);
    }).filter(Boolean);
    if (nodes.length === 0) return undefined;
    const observer = new IntersectionObserver(entries => {
      const visible = entries.filter(entry => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio);
      if (visible[0]?.target?.id) {
        const raw = visible[0].target.id.replace("tutorial-substep-", "").replace("tutorial-step-", "");
        setActiveId(raw);
      }
    }, {
      rootMargin: "-20% 0px -55% 0px",
      threshold: [0.15, 0.4, 0.7]
    });
    nodes.forEach(node => observer.observe(node));
    return () => observer.disconnect();
  }, [tutorialId, steps.length]);
  const completedCount = steps.filter(step => isStepComplete(step, progress)).length;
  const done = steps.length > 0 && steps.every(step => isStepComplete(step, progress));
  const pct = steps.length ? Math.round(completedCount / steps.length * 100) : 0;
  return <aside className="tutorial-progress" aria-label="Tutorial progress">
      <div className="tutorial-progress-header">
        <p className="tutorial-progress-label">Progress</p>
        <p className="tutorial-progress-count">
          {completedCount} / {steps.length}
        </p>
      </div>

      <div className="tutorial-progress-bar" role="progressbar" aria-valuemin={0} aria-valuemax={steps.length} aria-valuenow={completedCount}>
        <div className="tutorial-progress-bar-fill" style={{
    width: `${pct}%`
  }} />
      </div>

      <ol className="tutorial-progress-list">
        {steps.map((step, index) => {
    const subs = parseSubsteps(step);
    const isComplete = isStepComplete(step, progress);
    const isActive = activeId === step.id || subs.some(sub => sub.id === activeId);
    return <li key={step.id} className="tutorial-progress-group">
              <a href={`#tutorial-step-${step.id}`} className={["tutorial-progress-item", isActive ? "is-active" : "", isComplete ? "is-complete" : ""].filter(Boolean).join(" ")}>
                <span className="tutorial-progress-marker" aria-hidden="true">
                  {isComplete ? "✓" : index}
                </span>
                <span className="tutorial-progress-title">{step.title}</span>
              </a>

              {subs.length > 0 ? <ol className="tutorial-progress-sublist">
                  {subs.map((sub, subIndex) => {
      const subComplete = progress[sub.id] === true;
      const subActive = activeId === sub.id;
      return <li key={sub.id}>
                        <a href={`#tutorial-substep-${sub.id}`} className={["tutorial-progress-item tutorial-progress-item--sub", subActive ? "is-active" : "", subComplete ? "is-complete" : ""].filter(Boolean).join(" ")}>
                              <span className="tutorial-progress-marker tutorial-progress-marker--sub" aria-hidden="true">
                            {subComplete ? "✓" : ""}
                          </span>
                          <span className="tutorial-progress-title">
                            {sub.title}
                          </span>
                        </a>
                      </li>;
    })}
                </ol> : null}
            </li>;
  })}
      </ol>

      <div className="tutorial-progress-footer">
        {done ? <p className="tutorial-progress-done">Tutorial complete</p> : null}

        {completedCount > 0 || Object.keys(progress).length > 0 ? <button type="button" className="tutorial-reset" onClick={resetProgress}>
            Reset progress
          </button> : null}
      </div>
    </aside>;
};

export const TutorialHero = ({eyebrow = "Tutorial", title, summary, time, level, imageSrc, imageAlt = "Tutorial preview"}) => {
  useEffect(() => {
    if (typeof document === "undefined") return undefined;
    document.documentElement.setAttribute("data-tutorial-layout", "true");
    const page = document.querySelector(".tutorial-page");
    if (page) {
      let sibling = page.previousElementSibling;
      while (sibling) {
        sibling.setAttribute("data-tutorial-hidden-header", "true");
        sibling = sibling.previousElementSibling;
      }
    }
    document.querySelectorAll("h1").forEach(heading => {
      if (heading.textContent.trim() !== title) return;
      heading.setAttribute("data-tutorial-hidden-header", "true");
      const prev = heading.previousElementSibling;
      if (prev) prev.setAttribute("data-tutorial-hidden-header", "true");
      const next = heading.nextElementSibling;
      if (next && next.tagName === "P") {
        next.setAttribute("data-tutorial-hidden-header", "true");
      }
    });
    return () => {
      document.documentElement.removeAttribute("data-tutorial-layout");
      document.querySelectorAll("[data-tutorial-hidden-header]").forEach(node => node.removeAttribute("data-tutorial-hidden-header"));
    };
  }, [title]);
  return <header className="tutorial-hero">
      <div className="tutorial-hero-glow" aria-hidden="true" />
      <div className="tutorial-hero-copy">
        <p className="tutorial-hero-eyebrow">{eyebrow}</p>
        <h2 className="tutorial-hero-title">{title}</h2>
        {summary ? <p className="tutorial-hero-summary">{summary}</p> : null}
        <div className="tutorial-hero-meta">
          {level ? <span className="tutorial-pill">{level}</span> : null}
          {time ? <span className="tutorial-pill">{time}</span> : null}
        </div>
      </div>
      <div className="tutorial-hero-media">
        {imageSrc ? <img src={imageSrc} alt={imageAlt} className="tutorial-hero-image" /> : <div className="tutorial-media-placeholder tutorial-media-placeholder--hero">
            <span>Add hero image</span>
            <span className="tutorial-media-hint">
              Drop a screenshot or still here
            </span>
          </div>}
      </div>
    </header>;
};

<div className="tutorial-page">
  <TutorialHero eyebrow="FTT" title="Run your first FTT test" summary="Run one FCU heating test on a single device, watch each sequence finish, and read the score." level="Beginner" time="About 15 min" />

  <div className="tutorial-body">
    <TutorialProgress
      tutorialId="ftt-first-test"
      steps={[
    { id: "how-scoring-works", title: "How scoring works" },
    { id: "before-you-begin", title: "Before you begin" },
    { id: "open-device", title: "Open a device" },
    { id: "start-test", title: "Start the test" },
    { id: "watch-progress", title: "Watch progress" },
    { id: "read-score", title: "Read the score" },
  ]}
    />

    <div className="tutorial-steps">
      <TutorialStep tutorialId="ftt-first-test" id="how-scoring-works" number="0" title="How FTT decides pass or fail" stepIds="how-scoring-works,before-you-begin,open-device,start-test,watch-progress,read-score">
        This walkthrough uses an FCU heating workflow. FTT commands valves, reads discharge air temperature, and scores each step against thresholds.

        The heating workflow typically runs in this order:

        1. **Check readiness** — occupied mode and fan command must meet the preconditions. If not, the test archives. This gate is not part of the scored sequence total.
        2. **Stop cooling** — close the chilled water valve. Pass when discharge air temperature leaves the cooled range.
        3. **Stop heating** — close the hot water valve. Pass when discharge air temperature leaves the heated range.
        4. **Start heating** — open the hot water valve. Pass when discharge air temperature rises past the threshold within the time limit.
        5. **Score the run** — passed sequences divided by total scored sequences. Two of three passed is **66.67%**.

        You will run this on one device. You do not need a project or a custom workflow yet. For the full concept map, see [What is Functional Testing?](/products/ftt/overview).
      </TutorialStep>

      <TutorialStep tutorialId="ftt-first-test" id="before-you-begin" number="1" title="Before you begin" stepIds="how-scoring-works,before-you-begin,open-device,start-test,watch-progress,read-score">
        Select one FCU that is online and occupied. Avoid units on floors under construction.

        Confirm the following:

        * Access to `FTT` for the building
        * Required workflow points present on the device (valve commands, discharge air temperature, occupancy, fan command)
        * Writable points so FTT can command and read feedback
        * Parent equipment in a suitable operating state when the workflow requires it

        <Info>
          Missing points or non-writable points prevent a valid score. Resolve templating or write access before you continue. See [Prerequisites](/products/ftt/overview#prerequisites).
        </Info>
      </TutorialStep>

      <TutorialStep tutorialId="ftt-first-test" id="open-device" number="2" title="Open the device FTT tab" stepIds="how-scoring-works,before-you-begin,open-device,start-test,watch-progress,read-score" mediaLabel="Screenshot: device details page with the FTT tab selected">
        Run a single-device test from the device page. A project is not required for this walkthrough.

        <Steps>
          <Step title="Open Devices">
            In KODE OS, open `Devices` for your building.
          </Step>

          <Step title="Select an FCU">
            Search for or select the FCU you want to test.
          </Step>

          <Step title="Open the FTT tab">
            On the device details page, open the `FTT` tab.
          </Step>
        </Steps>
      </TutorialStep>

      <TutorialStep tutorialId="ftt-first-test" id="start-test" number="3" title="Start an FCU heating test" stepIds="how-scoring-works,before-you-begin,open-device,start-test,watch-progress,read-score" mediaLabel="Screenshot: Run New Test dialog with V2 FCU Heating selected">
        Choose the heating workflow, set a reason, then confirm. Keep default parameters on the first run unless your site already has tuned values.

        <Steps>
          <Step title="Open Run New Test">
            Select `Run New Test`.
          </Step>

          <Step title="Select the workflow">
            In the `Workflow` dropdown, choose `V2 FCU Heating` (or the FCU heating workflow available on your site).
          </Step>

          <Step title="Set a reason">
            Select a `Reason` such as `Routine Maintenance` or `Commissioning Agent`. Use `Specify` if your team records extra context.
          </Step>

          <Step title="Review parameters">
            If your account has **View FTT Parameters for Manual Test**, review the workflow parameters before you continue. Keep defaults unless you know this equipment needs tuned values.

            When the permission is off, parameters stay hidden. You can still run the preconfigured test. Admins control this Write permission in Launchpad (enabled by default for FTT users). See [Run a test on a single device](/products/ftt/test-results#run-a-test-on-a-single-device).
          </Step>

          <Step title="Confirm the run">
            Select `Continue`. Review the workflow name, device, and maximum duration. Select `Confirm`.

            The device locks for the duration of the test.
          </Step>
        </Steps>
      </TutorialStep>

      <TutorialStep tutorialId="ftt-first-test" id="watch-progress" number="4" title="Watch the test run" stepIds="how-scoring-works,before-you-begin,open-device,start-test,watch-progress,read-score" mediaLabel="Screenshot or GIF: live test result with sequences in progress">
        The result page opens when the run starts. Status shows `In Progress`. Log lines appear as each sequence executes.

        Match the live report to the sequence list from step 0:

        1. Starting conditions
        2. Pre-condition check
        3. Stop cooling
        4. Stop heating
        5. Start heating
        6. Finished with final score

        <Warning>
          If a pre-condition check fails, FTT archives the test. Correct the readiness issue (for example, occupied mode), then run again.
        </Warning>

        Wait until status is `Completed` or `Failed` before you evaluate the score.
      </TutorialStep>

      <TutorialStep tutorialId="ftt-first-test" id="read-score" number="5" title="Read the score" stepIds="how-scoring-works,before-you-begin,open-device,start-test,watch-progress,read-score" mediaLabel="Screenshot: completed FCU heating report with sequence pass/fail and final score">
        The score is the share of scored sequences that passed. Two of three passed is **66.67%**.

        Read the report in this order:

        1. **Final score** — overall pass rate for the run
        2. **Failed sequence** — which step missed its threshold
        3. **Sensor reading** — measured value versus required value
        4. **History** — prior scores for the same device

        A low score is not always a mechanical failure. Slow valves, tight thresholds, or plant conditions can fail Start Heating on healthy equipment. Use the failed sequence text to decide whether to retune parameters or investigate the unit.

        <Check>
          You ran an on-demand FCU heating test and can explain how each sequence contributed to the score.
        </Check>

        ### Related reading

        * [Test results](/products/ftt/test-results) — dashboards, filters, and report detail
        * [FCU workflows](/products/ftt/fcu-workflows) — heating and cooling library parameters
        * [FTT projects](/products/ftt/projects) — schedule the same workflow across many devices
      </TutorialStep>
    </div>
  </div>
</div>
