import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import {
  DEFAULT_BASEMAP,
  DEFAULT_DASHBOARD_COLUMNS,
  createEmptyProject,
  normalizeDashboardColumns,
  normalizeWidgets,
  parseProject,
  projectFromStore,
  serializeProject,
  useAppStore,
  type DashboardWidget,
} from "@geolibre/core";
import {
  computeChart,
  chartResultHasData,
} from "../apps/geolibre-desktop/src/components/panels/charts/chart-spec";
import {
  CHART_PALETTE,
  categoryColors,
  isHexColor,
  shadeRamp,
} from "../apps/geolibre-desktop/src/components/panels/charts/chart-colors";
import {
  distinctCategoryValues,
  filterRowsBySelections,
  type ChartRow,
} from "../apps/geolibre-desktop/src/lib/attribute-charts";

function widget(patch: Partial<DashboardWidget> = {}): DashboardWidget {
  return {
    id: "w1",
    layerId: "layer-a",
    type: "histogram",
    field: "pop",
    bins: 10,
    ...patch,
  };
}

describe("normalizeWidgets", () => {
  it("keeps a valid widget with all of its options", () => {
    const widgets: DashboardWidget[] = [
      {
        id: "bar-1",
        layerId: "layer-a",
        type: "bar",
        category: "kind",
        aggregation: "sum",
        valueField: "pop",
        title: "By kind",
      },
    ];
    assert.deepEqual(normalizeWidgets(widgets), widgets);
  });

  it("drops widgets without an id, layer id, or known type", () => {
    const result = normalizeWidgets([
      { id: "", layerId: "a", type: "bar" },
      { id: "x", layerId: "", type: "bar" },
      { id: "y", layerId: "a", type: "donut" },
      widget({ id: "good", layerId: "a", type: "box", field: "pop" }),
    ] as never);
    assert.deepEqual(
      result?.map((w) => w.id),
      ["good"],
    );
  });

  it("de-duplicates widgets by id, keeping the first", () => {
    const result = normalizeWidgets([
      widget({ id: "dup", title: "first" }),
      widget({ id: "dup", title: "second" }),
    ]);
    assert.equal(result?.length, 1);
    assert.equal(result?.[0].title, "first");
  });

  it("keeps a valid hex color and drops an invalid one", () => {
    const result = normalizeWidgets([
      widget({ id: "a", color: "#ff0000" }),
      widget({ id: "b", color: "red" }),
      widget({ id: "c", color: "#abc" }),
    ]);
    assert.equal(result?.find((w) => w.id === "a")?.color, "#ff0000");
    assert.equal("color" in (result?.find((w) => w.id === "b") ?? {}), false);
    assert.equal(result?.find((w) => w.id === "c")?.color, "#abc");
  });

  it("coerces bins to an integer and drops a bad aggregation", () => {
    const result = normalizeWidgets([
      { id: "a", layerId: "l", type: "histogram", field: "pop", bins: 7.9 },
      {
        id: "b",
        layerId: "l",
        type: "bar",
        category: "kind",
        aggregation: "median",
      },
    ] as never);
    assert.equal(result?.[0].bins, 7);
    assert.equal("aggregation" in (result?.[1] ?? {}), false);
  });

  it("drops a non-positive bin count and caps a huge one", () => {
    const result = normalizeWidgets([
      { id: "a", layerId: "l", type: "histogram", field: "pop", bins: 0 },
      { id: "b", layerId: "l", type: "histogram", field: "pop", bins: 999 },
    ] as never);
    assert.equal("bins" in (result?.find((w) => w.id === "a") ?? {}), false);
    assert.equal(result?.find((w) => w.id === "b")?.bins, 50);
  });

  it("drops a mean aggregation on a pie widget", () => {
    const result = normalizeWidgets([
      { id: "p", layerId: "l", type: "pie", category: "kind", aggregation: "mean" },
      { id: "b", layerId: "l", type: "bar", category: "kind", aggregation: "mean" },
    ] as never);
    assert.equal("aggregation" in (result?.find((w) => w.id === "p") ?? {}), false);
    assert.equal(result?.find((w) => w.id === "b")?.aggregation, "mean");
  });

  it("keeps an indicator widget with aggregation, prefix, and suffix", () => {
    const widgets: DashboardWidget[] = [
      {
        id: "ind-1",
        layerId: "layer-a",
        type: "indicator",
        indicatorAggregation: "sum",
        field: "area_ha",
        prefix: "€",
        suffix: " ha",
      },
    ];
    assert.deepEqual(normalizeWidgets(widgets), widgets);
  });

  it("keeps a count indicator that needs no field", () => {
    const result = normalizeWidgets([
      { id: "ind-c", layerId: "l", type: "indicator", indicatorAggregation: "count" },
    ] as never);
    assert.equal(result?.[0].type, "indicator");
    assert.equal(result?.[0].indicatorAggregation, "count");
  });

  it("drops an invalid indicator aggregation", () => {
    const result = normalizeWidgets([
      { id: "ind-x", layerId: "l", type: "indicator", indicatorAggregation: "mode" },
    ] as never);
    assert.equal("indicatorAggregation" in (result?.find((w) => w.id === "ind-x") ?? {}), false);
  });

  it("drops indicator-only fields from a non-indicator widget", () => {
    const result = normalizeWidgets([
      {
        id: "h",
        layerId: "l",
        type: "histogram",
        field: "pop",
        indicatorAggregation: "median",
        prefix: "€",
        suffix: " ha",
      },
    ] as never);
    const widget = result?.[0] ?? {};
    assert.equal("indicatorAggregation" in widget, false);
    assert.equal("prefix" in widget, false);
    assert.equal("suffix" in widget, false);
  });

  // "selector" was missing from the type allow-list, so normalizeWidgets threw
  // every selector widget away: they disappeared from a saved project and never
  // came back on reload.
  it("keeps a selector widget and its multi-select flag", () => {
    const result = normalizeWidgets([
      { id: "s", layerId: "l", type: "selector", category: "CONTINENT", multiple: true },
    ] as never);
    assert.equal(result?.length, 1);
    assert.equal(result?.[0].type, "selector");
    assert.equal(result?.[0].category, "CONTINENT");
    assert.equal(result?.[0].multiple, true);
  });

  it("omits the multi-select flag when it is off or not a boolean", () => {
    const result = normalizeWidgets([
      { id: "a", layerId: "l", type: "selector", category: "c", multiple: false },
      { id: "b", layerId: "l", type: "selector", category: "c", multiple: "yes" },
      { id: "c", layerId: "l", type: "selector", category: "c" },
    ] as never);
    assert.equal(result?.length, 3);
    for (const id of ["a", "b", "c"]) {
      const normalized = result?.find((w) => w.id === id);
      assert.ok(normalized, `selector ${id} should survive normalization`);
      assert.equal("multiple" in normalized, false);
    }
  });

  it("drops the multi-select flag from a non-selector widget", () => {
    const result = normalizeWidgets([
      { id: "p", layerId: "l", type: "pie", category: "kind", multiple: true },
    ] as never);
    const normalized = result?.[0];
    assert.ok(normalized, "the pie widget should survive normalization");
    assert.equal("multiple" in normalized, false);
  });

  // normalizeWidgets also runs on the save path, so a list widget whose
  // columns/sort/limit were dropped here went blank the moment its project was
  // saved — the renderer falls back to "no data" without listFields.
  it("keeps a list widget's columns, sort, and row limit", () => {
    const result = normalizeWidgets([
      {
        id: "l1",
        layerId: "l",
        type: "list",
        listFields: ["NAME", "POP"],
        sortBy: "POP",
        sortDir: "asc",
        limit: 50,
      },
    ] as never);
    const normalized = result?.[0];
    assert.ok(normalized, "the list widget should survive normalization");
    assert.deepEqual(normalized.listFields, ["NAME", "POP"]);
    assert.equal(normalized.sortBy, "POP");
    assert.equal(normalized.sortDir, "asc");
    assert.equal(normalized.limit, 50);
  });

  it("drops unusable list columns, sort directions, and row limits", () => {
    const result = normalizeWidgets([
      {
        id: "l1",
        layerId: "l",
        type: "list",
        listFields: ["NAME", "", 7, "  "],
        sortBy: "   ",
        sortDir: "sideways",
        limit: 0,
      },
      { id: "l2", layerId: "l", type: "list", listFields: [], limit: 10_000 },
    ] as never);
    const first = result?.find((w) => w.id === "l1");
    assert.ok(first, "l1 should survive normalization");
    assert.deepEqual(first.listFields, ["NAME"]);
    assert.equal("sortBy" in first, false);
    assert.equal("sortDir" in first, false);
    assert.equal("limit" in first, false);
    const second = result?.find((w) => w.id === "l2");
    assert.ok(second, "l2 should survive normalization");
    assert.equal("listFields" in second, false);
    // Clamped to the editor's maximum rather than round-tripped.
    assert.equal(second.limit, 500);
  });

  it("drops list fields from a non-list widget", () => {
    const result = normalizeWidgets([
      { id: "p", layerId: "l", type: "pie", category: "kind", listFields: ["NAME"], limit: 5 },
    ] as never);
    const normalized = result?.[0];
    assert.ok(normalized, "the pie widget should survive normalization");
    assert.equal("listFields" in normalized, false);
    assert.equal("limit" in normalized, false);
  });

  it("returns null for a non-array or an all-invalid list", () => {
    assert.equal(normalizeWidgets(undefined), null);
    assert.equal(normalizeWidgets("nope"), null);
    assert.equal(normalizeWidgets([{ id: "", layerId: "" }] as never), null);
  });
});

describe("widgets in the project file", () => {
  it("round-trips through projectFromStore and parseProject", () => {
    const widgets: DashboardWidget[] = [
      { id: "w1", layerId: "layer-a", type: "histogram", field: "pop", bins: 12 },
      { id: "w2", layerId: "layer-a", type: "scatter", xField: "pop", yField: "area" },
    ];
    const project = projectFromStore({
      projectName: "Widgets",
      mapView: { center: [0, 0], zoom: 2, bearing: 0, pitch: 0 },
      basemapStyleUrl: DEFAULT_BASEMAP,
      basemapVisible: true,
      basemapOpacity: 1,
      layers: [],
      preferences: createEmptyProject().preferences,
      widgets,
      metadata: {},
    });
    assert.deepEqual(project.widgets, widgets);
    const reparsed = parseProject(serializeProject(project));
    assert.deepEqual(reparsed.widgets, widgets);
  });

  it("round-trips an indicator widget through serialize/parse", () => {
    const widgets: DashboardWidget[] = [
      {
        id: "ind-1",
        layerId: "layer-a",
        type: "indicator",
        indicatorAggregation: "median",
        field: "area_ha",
        prefix: "€",
        suffix: " ha",
        title: "Mediana",
        color: "#004D43",
      },
    ];
    const project = projectFromStore({
      projectName: "Indicator",
      mapView: { center: [0, 0], zoom: 2, bearing: 0, pitch: 0 },
      basemapStyleUrl: DEFAULT_BASEMAP,
      basemapVisible: true,
      basemapOpacity: 1,
      layers: [],
      preferences: createEmptyProject().preferences,
      widgets,
      metadata: {},
    });
    assert.deepEqual(project.widgets, widgets);
    const reparsed = parseProject(serializeProject(project));
    assert.deepEqual(reparsed.widgets, widgets);
  });

  it("persists a non-default column count and omits the default", () => {
    const base = {
      projectName: "Widgets",
      mapView: { center: [0, 0] as [number, number], zoom: 2, bearing: 0, pitch: 0 },
      basemapStyleUrl: DEFAULT_BASEMAP,
      basemapVisible: true,
      basemapOpacity: 1,
      layers: [],
      preferences: createEmptyProject().preferences,
      metadata: {},
    };
    const custom = projectFromStore({ ...base, dashboardColumns: 3 });
    assert.equal(custom.dashboardColumns, 3);
    assert.equal(parseProject(serializeProject(custom)).dashboardColumns, 3);

    const defaulted = projectFromStore({
      ...base,
      dashboardColumns: DEFAULT_DASHBOARD_COLUMNS,
    });
    assert.equal("dashboardColumns" in defaulted, false);
  });

  it("omits the widgets key when none are valid", () => {
    const project = projectFromStore({
      projectName: "Widgets",
      mapView: { center: [0, 0], zoom: 2, bearing: 0, pitch: 0 },
      basemapStyleUrl: DEFAULT_BASEMAP,
      basemapVisible: true,
      basemapOpacity: 1,
      layers: [],
      preferences: createEmptyProject().preferences,
      widgets: [{ id: "", layerId: "", type: "bar" }] as never,
      metadata: {},
    });
    assert.equal("widgets" in project, false);
  });
});

describe("cross-filtering by selector values", () => {
  const rows: ChartRow[] = [
    { properties: { CONTINENT: "Africa", INCOME_GRP: "5. Low income", NAME: "Chad" } },
    { properties: { CONTINENT: "Africa", INCOME_GRP: "3. Upper middle", NAME: "Gabon" } },
    { properties: { CONTINENT: "Asia", INCOME_GRP: "5. Low income", NAME: "Nepal" } },
    { properties: { CONTINENT: "Europe", INCOME_GRP: "1. High income", NAME: "France" } },
  ];
  const names = (result: ChartRow[]) => result.map((row) => row.properties.NAME);

  it("returns every row when nothing is selected", () => {
    assert.equal(filterRowsBySelections(rows, []), rows);
    assert.equal(filterRowsBySelections(rows, [{ field: "CONTINENT", values: [] }]), rows);
  });

  it("keeps only the rows matching a single selected value", () => {
    const result = filterRowsBySelections(rows, [{ field: "CONTINENT", values: ["Africa"] }]);
    assert.deepEqual(names(result), ["Chad", "Gabon"]);
  });

  it("ORs the values within one selection", () => {
    const result = filterRowsBySelections(rows, [
      { field: "CONTINENT", values: ["Africa", "Asia"] },
    ]);
    assert.deepEqual(names(result), ["Chad", "Gabon", "Nepal"]);
  });

  it("ANDs separate selections", () => {
    const result = filterRowsBySelections(rows, [
      { field: "CONTINENT", values: ["Africa", "Asia"] },
      { field: "INCOME_GRP", values: ["5. Low income"] },
    ]);
    assert.deepEqual(names(result), ["Chad", "Nepal"]);
  });

  it("yields nothing when the selections cannot overlap", () => {
    const result = filterRowsBySelections(rows, [
      { field: "CONTINENT", values: ["Europe"] },
      { field: "INCOME_GRP", values: ["5. Low income"] },
    ]);
    assert.deepEqual(result, []);
  });

  // The chips are built with String(...), so a numeric or boolean category has
  // to match the same way or clicking a chip would filter everything away.
  it("matches non-string values against their chip label", () => {
    const mixed: ChartRow[] = [
      { properties: { zone: 1, NAME: "one" } },
      { properties: { zone: 2, NAME: "two" } },
      { properties: { zone: true, NAME: "yes" } },
    ];
    assert.deepEqual(names(filterRowsBySelections(mixed, [{ field: "zone", values: ["1"] }])), [
      "one",
    ]);
    assert.deepEqual(names(filterRowsBySelections(mixed, [{ field: "zone", values: ["true"] }])), [
      "yes",
    ]);
  });
});

describe("selector and list widgets in the project file", () => {
  it("round-trips a selector widget through serialize/parse", () => {
    const widgets: DashboardWidget[] = [
      {
        id: "sel-1",
        layerId: "layer-a",
        type: "selector",
        category: "CONTINENT",
        multiple: true,
        title: "Continent",
      },
    ];
    const project = projectFromStore({
      projectName: "Selector",
      mapView: { center: [0, 0], zoom: 2, bearing: 0, pitch: 0 },
      basemapStyleUrl: DEFAULT_BASEMAP,
      basemapVisible: true,
      basemapOpacity: 1,
      layers: [],
      preferences: createEmptyProject().preferences,
      widgets,
      metadata: {},
    });
    assert.deepEqual(project.widgets, widgets);
    const reparsed = parseProject(serializeProject(project));
    assert.deepEqual(reparsed.widgets, widgets);
  });

  it("round-trips a list widget through serialize/parse", () => {
    const widgets: DashboardWidget[] = [
      {
        id: "list-1",
        layerId: "layer-a",
        type: "list",
        listFields: ["NAME", "POP_EST"],
        sortBy: "POP_EST",
        sortDir: "asc",
        limit: 25,
        title: "Top countries",
      },
    ];
    const project = projectFromStore({
      projectName: "List",
      mapView: { center: [0, 0], zoom: 2, bearing: 0, pitch: 0 },
      basemapStyleUrl: DEFAULT_BASEMAP,
      basemapVisible: true,
      basemapOpacity: 1,
      layers: [],
      preferences: createEmptyProject().preferences,
      widgets,
      metadata: {},
    });
    assert.deepEqual(project.widgets, widgets);
    const reparsed = parseProject(serializeProject(project));
    assert.deepEqual(reparsed.widgets, widgets);
  });
});

describe("chart colors", () => {
  it("recognizes hex colors and rejects other strings", () => {
    assert.equal(isHexColor("#fff"), true);
    assert.equal(isHexColor("#3fb1ce"), true);
    assert.equal(isHexColor("blue"), false);
    assert.equal(isHexColor("#12"), false);
    assert.equal(isHexColor(undefined), false);
  });

  it("builds a ramp that starts at the base and lightens", () => {
    const ramp = shadeRamp("#000000", 3);
    assert.equal(ramp.length, 3);
    assert.equal(ramp[0], "#000000");
    // Later shades are lighter than the base.
    assert.notEqual(ramp[2], "#000000");
  });

  it("falls back to the palette when no valid color is given", () => {
    const palette = categoryColors(undefined, 3);
    assert.deepEqual(palette, CHART_PALETTE.slice(0, 3));
    const ramp = categoryColors("#3fb1ce", 4);
    assert.equal(ramp.length, 4);
    assert.equal(ramp[0], "#3fb1ce");
  });
});

describe("normalizeDashboardColumns", () => {
  it("clamps into range and falls back to the default", () => {
    assert.equal(normalizeDashboardColumns(3), 3);
    assert.equal(normalizeDashboardColumns(0), 1);
    assert.equal(normalizeDashboardColumns(99), 6);
    assert.equal(normalizeDashboardColumns(2.9), 2);
    assert.equal(normalizeDashboardColumns(undefined), DEFAULT_DASHBOARD_COLUMNS);
    assert.equal(normalizeDashboardColumns("x"), DEFAULT_DASHBOARD_COLUMNS);
  });
});

describe("app store widget actions", () => {
  beforeEach(() => {
    useAppStore.getState().newProject({ name: "Test Project" });
  });

  it("adds, updates, moves, and removes widgets", () => {
    const store = useAppStore.getState();
    store.addWidget(widget({ id: "a" }));
    store.addWidget(widget({ id: "b", type: "box", field: "area" }));
    assert.deepEqual(
      useAppStore.getState().widgets.map((w) => w.id),
      ["a", "b"],
    );
    assert.equal(useAppStore.getState().isDirty, true);

    useAppStore.getState().updateWidget("a", { title: "Renamed", bins: 20 });
    const a = useAppStore.getState().widgets.find((w) => w.id === "a");
    assert.equal(a?.title, "Renamed");
    assert.equal(a?.bins, 20);
    assert.equal(a?.id, "a");

    // Move "b" to the front; clamps and reorders.
    useAppStore.getState().moveWidget("b", 0);
    assert.deepEqual(
      useAppStore.getState().widgets.map((w) => w.id),
      ["b", "a"],
    );

    useAppStore.getState().removeWidget("a");
    assert.deepEqual(
      useAppStore.getState().widgets.map((w) => w.id),
      ["b"],
    );
  });

  it("ignores a duplicate widget id in addWidget", () => {
    useAppStore.getState().addWidget(widget({ id: "a", title: "first" }));
    useAppStore.getState().addWidget(widget({ id: "a", title: "second" }));
    const widgets = useAppStore.getState().widgets;
    assert.equal(widgets.length, 1);
    assert.equal(widgets[0].title, "first");
  });

  it("ignores updates and moves for an unknown widget id", () => {
    useAppStore.getState().addWidget(widget({ id: "a" }));
    useAppStore.getState().updateWidget("missing", { title: "x" });
    useAppStore.getState().moveWidget("missing", 3);
    assert.deepEqual(
      useAppStore.getState().widgets.map((w) => w.id),
      ["a"],
    );
  });

  it("clamps the dashboard column count", () => {
    useAppStore.getState().setDashboardColumns(3);
    assert.equal(useAppStore.getState().dashboardColumns, 3);
    useAppStore.getState().setDashboardColumns(99);
    assert.equal(useAppStore.getState().dashboardColumns, 6);
    useAppStore.getState().setDashboardColumns(0);
    assert.equal(useAppStore.getState().dashboardColumns, 1);
    // Non-finite input is ignored, leaving the last valid value intact.
    useAppStore.getState().setDashboardColumns(Number.NaN);
    assert.equal(useAppStore.getState().dashboardColumns, 1);
  });
});

describe("computeChart", () => {
  const rows: ChartRow[] = [
    { properties: { pop: 10, kind: "a" } },
    { properties: { pop: 20, kind: "a" } },
    { properties: { pop: 30, kind: "b" } },
  ];

  it("dispatches a histogram and reports it has data", () => {
    const result = computeChart(rows, { type: "histogram", field: "pop", bins: 4 });
    assert.equal(result.type, "histogram");
    assert.ok(chartResultHasData(result));
    if (result.type === "histogram") assert.equal(result.result?.total, 3);
  });

  it("dispatches a bar count grouped by a category", () => {
    const result = computeChart(rows, {
      type: "bar",
      category: "kind",
      aggregation: "count",
    });
    assert.equal(result.type, "bar");
    if (result.type === "bar") {
      assert.equal(result.result?.bars.length, 2);
    }
  });

  it("dispatches a pie of category shares that sum to the whole", () => {
    const result = computeChart(rows, {
      type: "pie",
      category: "kind",
      aggregation: "count",
    });
    assert.equal(result.type, "pie");
    if (result.type === "pie") {
      assert.equal(result.result?.total, 3);
      assert.equal(result.result?.slices.length, 2);
    }
  });

  it("returns an empty result when the required field is missing", () => {
    const result = computeChart(rows, { type: "histogram" });
    assert.equal(chartResultHasData(result), false);
  });
});

describe("selector widget values", () => {
  // Rows carry their attributes in a `properties` bag, not on the row itself.
  // Reading the row directly yields undefined for every feature, which left the
  // selector widget rendering its "no data" fallback instead of any chips.
  const rows: ChartRow[] = [
    { properties: { CONTINENT: "Africa", NAME: "Kenya" } },
    { properties: { CONTINENT: "Asia", NAME: "Nepal" } },
    { properties: { CONTINENT: "Africa", NAME: "Chad" } },
  ];

  it("reads distinct values out of each row's property bag", () => {
    assert.deepEqual(distinctCategoryValues(rows, "CONTINENT"), ["Africa", "Asia"]);
  });

  it("sorts values and drops blank, whitespace-only, and nullish ones", () => {
    const sparse: ChartRow[] = [
      { properties: { region: "Oceania" } },
      { properties: { region: "" } },
      { properties: { region: "   " } },
      { properties: { region: "\t\n" } },
      { properties: { region: null } },
      { properties: {} },
      { properties: { region: "Americas" } },
    ];
    assert.deepEqual(distinctCategoryValues(sparse, "region"), ["Americas", "Oceania"]);
  });

  it("keeps a value's original spacing as its label", () => {
    const padded: ChartRow[] = [{ properties: { region: " Asia " } }];
    assert.deepEqual(distinctCategoryValues(padded, "region"), [" Asia "]);
  });

  it("returns nothing for a field no row carries", () => {
    assert.deepEqual(distinctCategoryValues(rows, "missing"), []);
  });
});

describe("saving an edited widget", () => {
  beforeEach(() => {
    useAppStore.getState().newProject({ name: "Test Project" });
  });

  // The widget editor omits optional fields the user left empty, so saving has
  // to replace the record. Merging kept the old values and an emptied title,
  // color, prefix, or suffix silently reverted the moment the dialog closed.
  it("clears the optional fields the new record omits", () => {
    useAppStore.getState().addWidget(
      widget({
        id: "w",
        type: "indicator",
        title: "Old title",
        color: "#004D43",
        prefix: "€",
        suffix: " ha",
      }),
    );
    useAppStore.getState().replaceWidget("w", {
      layerId: "layer-a",
      type: "indicator",
      indicatorAggregation: "count",
    });
    const saved = useAppStore.getState().widgets.find((w) => w.id === "w");
    assert.ok(saved, "the widget should survive being replaced");
    assert.equal("title" in saved, false);
    assert.equal("color" in saved, false);
    assert.equal("prefix" in saved, false);
    assert.equal("suffix" in saved, false);
  });

  it("clears multi-select when the new record omits the flag", () => {
    useAppStore
      .getState()
      .addWidget(widget({ id: "s", type: "selector", category: "CONTINENT", multiple: true }));
    useAppStore.getState().replaceWidget("s", {
      layerId: "layer-a",
      type: "selector",
      category: "CONTINENT",
    });
    const saved = useAppStore.getState().widgets.find((w) => w.id === "s");
    assert.ok(saved, "the widget should survive being replaced");
    assert.equal("multiple" in saved, false);
  });

  it("drops fields belonging to the previous widget type", () => {
    useAppStore
      .getState()
      .addWidget(widget({ id: "w", type: "histogram", field: "pop", bins: 12 }));
    useAppStore
      .getState()
      .replaceWidget("w", { layerId: "layer-a", type: "pie", category: "kind" });
    const saved = useAppStore.getState().widgets.find((w) => w.id === "w");
    assert.ok(saved, "the widget should survive being replaced");
    assert.equal("bins" in saved, false);
    assert.equal("field" in saved, false);
  });

  it("keeps the widget's id and position, and ignores an unknown id", () => {
    useAppStore.getState().addWidget(widget({ id: "a" }));
    useAppStore.getState().addWidget(widget({ id: "b" }));
    useAppStore.getState().replaceWidget("a", { layerId: "layer-z", type: "box", field: "area" });
    assert.deepEqual(
      useAppStore.getState().widgets.map((w) => w.id),
      ["a", "b"],
    );
    assert.equal(useAppStore.getState().widgets[0].layerId, "layer-z");

    useAppStore.getState().replaceWidget("missing", { layerId: "l", type: "box", field: "x" });
    assert.equal(useAppStore.getState().widgets.length, 2);
  });

  // Kept distinct from replaceWidget: updateWidget is still the partial-patch
  // API, and this merge behavior is why the editor cannot use it.
  it("updateWidget still merges, retaining a field the patch omits", () => {
    useAppStore
      .getState()
      .addWidget(widget({ id: "s", type: "selector", category: "CONTINENT", multiple: true }));
    useAppStore.getState().updateWidget("s", { category: "REGION_UN" });
    const saved = useAppStore.getState().widgets.find((w) => w.id === "s");
    assert.equal(saved?.multiple, true);
    assert.equal(saved?.category, "REGION_UN");
  });
});
