Ctrl K
ComunidadGuías…

test/shared/email-renderer/variable-reference.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { expect } from "@std/expect";
import { describe, it as test } from "@std/testing/bdd";
import type { TemplateData } from "#shared/email-renderer.ts";
import { createBaseLiquidEngine } from "#shared/liquid-engine.ts";
import {
  LOOP_EXAMPLE,
  TEMPLATE_VARIABLES,
} from "#templates/components/email-template-reference.tsx";
import { expectIncludesAll } from "#test-utils/assertions.ts";
import { makeTestEntry } from "#test-utils/factories.ts";
import { buildTestData, describeEmailRenderer } from "./test-helpers.ts";

/** An entry whose every template field carries a visible value, so a path
 * that resolves to empty can never pass the render check. */
const fullEntry = (name: string, slug: string, quantity: number) =>
  makeTestEntry(
    { name, slug, unit_price: 1000 },
    {
      address: "1 High Street",
      date: "2026-09-01",
      end_date: "2026-09-03",
      phone: "555-1234",
      price_paid: "2500",
      quantity,
      remaining_balance: 1250,
      special_instructions: "Use gate B",
    },
  );

const ANSWER_LINES = new Map([
  [
    42,
    new Map([
      [
        1,
        [
          { question: "Any dietary needs?", text: "None" },
          { question: "How did you hear about us?", text: "A friend" },
        ],
      ],
    ]),
  ],
]);

const buildReferenceData = (): Promise<TemplateData> =>
  buildTestData(
    [
      fullEntry("Summer Fete", "summer-fete", 3),
      fullEntry("Bake Off", "bake-off", 1),
    ],
    { answerLines: ANSWER_LINES },
  );

const joinPath = (prefix: string, key: string): string =>
  prefix === "" ? key : `${prefix}.${key}`;

/** The loop alias the reference gives an array's rows: a row of `entries`
 * is `entry`, a row of `attendee.answers` is `answer`. */
const loopAliasFor = (prefix: string): string =>
  prefix.endsWith("answers") ? "answer" : "entry";

/** Queue an array's first row under the loop alias the reference names it
 * by, so its paths carry that loop's own prefix. */
const queueLoopRows = (
  queue: [prefix: string, value: unknown][],
  prefix: string,
  array: unknown[],
): void => {
  const row = array[0];
  if (row !== undefined) queue.push([loopAliasFor(prefix), row]);
};

/** Every dotted path the built data holds. */
const runtimePaths = (data: TemplateData): Set<string> => {
  const paths = new Set<string>();
  const queue: [prefix: string, value: unknown][] = [["", data]];
  for (const [prefix, value] of queue) {
    if (value === null || typeof value !== "object") {
      paths.add(prefix);
    } else if (Array.isArray(value)) {
      paths.add(prefix);
      queueLoopRows(queue, prefix, value);
    } else {
      for (const [key, sub] of Object.entries(value)) {
        queue.push([joinPath(prefix, key), sub]);
      }
    }
  }
  return paths;
};

/** The variable paths the reference table shows. A code that filters a
 * literal (the pluralize demo) names no path. */
const displayedPaths = (): Set<string> => {
  const paths = new Set<string>();
  for (const [code] of TEMPLATE_VARIABLES) {
    const variable = code.slice(2, -2).split("|")[0]!.trim();
    if (!/^\d+$/.test(variable)) paths.add(variable);
  }
  return paths;
};

describeEmailRenderer(() => {
  describe("the displayed variable reference", () => {
    test("shows a path for every field the runtime data holds", async () => {
      const data = await buildReferenceData();
      const displayed = displayedPaths();
      const missing = [...runtimePaths(data)].filter(
        (path) => !displayed.has(path),
      );
      expect(missing).toEqual([]);
    });

    test("shows no path the runtime data does not hold", async () => {
      const data = await buildReferenceData();
      const runtime = runtimePaths(data);
      const phantom = [...displayedPaths()].filter(
        (path) => !runtime.has(path),
      );
      expect(phantom).toEqual([]);
    });

    test("renders every snippet the reference shows", async () => {
      const data = await buildReferenceData();
      const engine = createBaseLiquidEngine();
      for (const [code] of TEMPLATE_VARIABLES) {
        // An `entry.*` snippet only resolves inside the loop it belongs to,
        // and an `answer.*` snippet only inside an entry's answers loop.
        const snippet = code.includes("answer.")
          ? `{% for entry in entries %}{% for answer in entry.attendee.answers %}${code}{% endfor %}{% endfor %}`
          : code.includes("entry.")
            ? `{% for entry in entries %}${code}{% endfor %}`
            : code;
        const rendered = await engine.parseAndRender(snippet, data);
        expect(rendered.trim(), code).not.toBe("");
      }
    });

    test("renders the reference's money and plural examples exactly", async () => {
      const data = await buildReferenceData();
      const engine = createBaseLiquidEngine();
      expect(
        await engine.parseAndRender("{{ amount_owed | currency }}", data),
      ).toBe("£12.50");
      expect(
        await engine.parseAndRender(
          '{{ 2 | pluralize: "ticket", "tickets" }}',
          data,
        ),
      ).toBe("tickets");
      expect(
        await engine.parseAndRender(
          '{{ 1 | pluralize: "ticket", "tickets" }}',
          data,
        ),
      ).toBe("ticket");
      expect(await engine.parseAndRender("{{ currency }}", data)).toBe("GBP");
    });

    test("the worked loop prints each booked listing's line", async () => {
      const data = await buildReferenceData();
      const engine = createBaseLiquidEngine();
      const loop = await engine.parseAndRender(LOOP_EXAMPLE, data);
      const dates = data.entries[0]!.attendee.date_range_label;
      expectIncludesAll(loop, [
        "Summer Fete: 3 tickets",
        "Bake Off: 1 ticket",
        dates,
        "£25",
      ]);
    });
  });
});