Download
160 lines 5.5 KiBJavaScriptModified 2026-08-01 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
// renderSettingsRows turns a settings schema into labeled rows whose
// controls report coerced values through onChange(key, value). Both the
// settings window and the bar's right-click popup trust this contract.

import { describe, it, expect, vi } from "vitest";
import { renderSettingsRows, hasFocusedControl } from "../src/lib/settings-form.js";

function makeRows(schemas, options = {}, { invoke } = {}) {
  const onChange = vi.fn();
  const container = document.createElement("div");
  renderSettingsRows(container, {
    schemas,
    options,
    invoke: invoke ?? (() => Promise.resolve(null)),
    onChange,
  });
  document.body.appendChild(container);
  return { container, onChange };
}

// Renders a single-schema form and returns its control element.
function make(schema, current, opts = {}) {
  const options = current === undefined ? {} : { [schema.key]: current };
  const { container, onChange } = makeRows([schema], options, opts);
  const el = container.querySelector(".dform-row > :not(label)");
  return { el, container, onChange };
}

function fireChange(input) {
  input.dispatchEvent(new Event("change"));
}

describe("renderSettingsRows", () => {
  it("renders one labeled row per schema entry, falling back to the key", () => {
    const { container } = makeRows([
      { key: "alpha", label: "Alpha setting" },
      { key: "beta" },
    ]);
    const rows = [...container.querySelectorAll(".dform-row")];
    expect(rows).toHaveLength(2);
    expect(rows.map((r) => r.querySelector("label").textContent)).toEqual([
      "Alpha setting",
      "beta",
    ]);
  });

  it("keys onChange by the schema entry that changed", () => {
    const { container, onChange } = makeRows([
      { key: "first" },
      { key: "second" },
    ]);
    const inputs = container.querySelectorAll("input");
    inputs[1].value = "hello";
    fireChange(inputs[1]);
    expect(onChange).toHaveBeenCalledWith("second", "hello");
  });

  it("resolves values as options[key] ?? schema.default", () => {
    const { container } = makeRows(
      [
        { key: "set", default: "fallback" },
        { key: "unset", default: "fallback" },
      ],
      { set: "saved" },
    );
    const inputs = container.querySelectorAll("input");
    expect(inputs[0].value).toBe("saved");
    expect(inputs[1].value).toBe("fallback");
  });

  it("boolean renders a checkbox and reports toggles", () => {
    const { el, onChange } = make({ type: "boolean", key: "x" }, true);
    expect(el.type).toBe("checkbox");
    expect(el.checked).toBe(true);
    el.checked = false;
    fireChange(el);
    expect(onChange).toHaveBeenCalledWith("x", false);
  });

  it("select renders the choices and reports the picked one", () => {
    const { el, onChange } = make(
      { type: "select", key: "x", choices: ["a", "b"] },
      "b",
    );
    expect([...el.options].map((o) => o.value)).toEqual(["a", "b"]);
    expect(el.value).toBe("b");
    el.value = "a";
    fireChange(el);
    expect(onChange).toHaveBeenCalledWith("x", "a");
  });

  it("number clamps to min/max (an emptied field counts as 0)", () => {
    const { el, onChange } = make(
      { type: "number", key: "x", min: 10, max: 20, default: 15 },
      12,
    );
    el.value = "99";
    fireChange(el);
    expect(onChange).toHaveBeenLastCalledWith("x", 20);
    el.value = "";
    fireChange(el);
    expect(onChange).toHaveBeenLastCalledWith("x", 10);
  });

  it("unknown types fall back to a text input", () => {
    const { el, onChange } = make({ key: "x" }, "hello");
    expect(el.type).toBe("text");
    el.value = "world";
    fireChange(el);
    expect(onChange).toHaveBeenCalledWith("x", "world");
  });

  it("location shows what the value resolves to", async () => {
    const invoke = vi.fn(() =>
      Promise.resolve({ area: "Greenwich Village", region: "New York", country: "USA" }),
    );
    const { el } = make({ type: "location", key: "x" }, "10001", { invoke });
    await Promise.resolve();
    await Promise.resolve();
    expect(invoke).toHaveBeenCalledWith("resolve_location", { location: "10001" });
    expect(el.querySelector(".loc-status").textContent).toBe(
      "→ Greenwich Village, New York, USA",
    );
  });

  it("location surfaces resolve failures", async () => {
    const invoke = vi.fn(() => Promise.reject("couldn't resolve this location"));
    const { el } = make({ type: "location", key: "x" }, "Brooklyn", { invoke });
    await Promise.resolve();
    await Promise.resolve();
    const status = el.querySelector(".loc-status");
    expect(status.textContent).toBe("couldn't resolve this location");
    expect(status.classList.contains("error")).toBe(true);
  });
});

describe("hasFocusedControl", () => {
  it("holds only while a sustained-focus control inside the container is active", () => {
    const { container } = makeRows([
      { key: "text" },
      { type: "boolean", key: "check" },
    ]);
    expect(hasFocusedControl(container)).toBe(false);

    container.querySelector('input[type="text"]').focus();
    expect(hasFocusedControl(container)).toBe(true);

    // Checkboxes don't need sustained focus — clicks resolve instantly.
    container.querySelector('input[type="checkbox"]').focus();
    expect(hasFocusedControl(container)).toBe(false);

    // A focused control outside the container doesn't count.
    const outside = document.createElement("input");
    outside.type = "text";
    document.body.appendChild(outside);
    outside.focus();
    expect(hasFocusedControl(container)).toBe(false);
    expect(hasFocusedControl(null)).toBe(false);
  });
});