Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions reactapp/__tests__/components/inputs/NormalInput.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { useState } from "react";
import NormalInput from "components/inputs/NormalInput";

/* eslint-disable no-template-curly-in-string */
Expand Down Expand Up @@ -427,4 +428,49 @@ describe("NormalInput Component", () => {
);
expect(input).toHaveValue("20");
});

describe("editing a decimal under a normalizing parent", () => {
// Mirrors the real chain: the parent parses whatever the input publishes and
// feeds the number back as `value`. "0." parses to 0, so a naive resync
// echoes "0" and eats the decimal point mid-edit.
// Both cases start from 0.3, so no props are needed -- which also keeps the
// helper clear of prop-types lint.
const ControlledNumber = () => {
const [value, setValue] = useState(0.3);
return (
<NormalInput
label="Num"
type="number"
value={value}
onChange={(e) => {
const parsed = parseFloat(e.target.value);
setValue(Number.isNaN(parsed) ? "" : parsed);
}}
/>
);
};

test("backspacing 0.3 to 0. keeps the decimal point", () => {
render(<ControlledNumber />);
const input = screen.getByLabelText("Num Input");
expect(input).toHaveValue("0.3");

fireEvent.change(input, { target: { value: "0." } });
expect(input).toHaveValue("0.");

// Typing the next digit therefore yields 0.5, not 5.
fireEvent.change(input, { target: { value: "0.5" } });
expect(input).toHaveValue("0.5");
});

test("a trailing zero survives while it is being typed", () => {
// Starts at 0.3 so the parent's value genuinely changes to 0.5 and the
// resync fires. String(0.5) is "0.5", which would drop the typed zero.
render(<ControlledNumber />);
const input = screen.getByLabelText("Num Input");

fireEvent.change(input, { target: { value: "0.50" } });
expect(input).toHaveValue("0.50");
});
});
});
108 changes: 105 additions & 3 deletions reactapp/__tests__/components/map/colorRamps.test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
COLOR_RAMPS,
RAMP_GROUPS,
RAMP_NAMES,
RAMP_STOPS,
resolveRamp,
_internal,
} from "components/map/colorRamps";

const HEX_RE = /^#[0-9a-fA-F]{6}$/;
const RAMP_KEYS = ["viridis", "turbo", "RdYlBu", "grayscale"];
const RAMP_KEYS = RAMP_NAMES;

describe("COLOR_RAMPS", () => {
test.each(RAMP_KEYS)(
Expand Down Expand Up @@ -36,8 +38,108 @@ describe("COLOR_RAMPS", () => {
},
);

test("RAMP_NAMES exposes the four canonical names in order", () => {
expect(RAMP_NAMES).toEqual(["viridis", "turbo", "RdYlBu", "grayscale"]);
test("RAMP_NAMES is the groups flattened, in display order", () => {
expect(RAMP_NAMES).toEqual([
"viridis",
"magma",
"inferno",
"plasma",
"cividis",
"turbo",
"Blues",
"YlGnBu",
"YlOrRd",
"grayscale",
"RdYlBu",
"RdBu",
"Spectral",
"BrBG",
]);
});

test("groups and COLOR_RAMPS cover exactly the same names", () => {
// A ramp defined but never grouped is unreachable in the picker; a grouped
// name with no ramp renders an empty gradient.
expect([...RAMP_NAMES].sort()).toEqual(Object.keys(COLOR_RAMPS).sort());
});

test("no ramp is listed in two groups", () => {
expect(new Set(RAMP_NAMES).size).toBe(RAMP_NAMES.length);
});

test("every group has a label and at least one ramp", () => {
for (const group of RAMP_GROUPS) {
expect(typeof group.label).toBe("string");
expect(group.label.length).toBeGreaterThan(0);
expect(group.names.length).toBeGreaterThan(0);
}
});

// Relative luminance (Rec. 709). Used below to assert the shape of each
// family, which is what a mis-sampled or mis-pasted colormap table breaks.
const luminance = (hex) => {
const n = parseInt(hex.slice(1), 16);
const r = ((n >> 16) & 0xff) / 255;
const g = ((n >> 8) & 0xff) / 255;
const b = (n & 0xff) / 255;
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};

test.each(["viridis", "magma", "inferno", "plasma", "cividis"])(
"%s increases in luminance from end to end",
(rampName) => {
// The defining property of this family: lightness rises monotonically, so
// the ramp reads as an ordered scale even in greyscale print.
const lums = COLOR_RAMPS[rampName].map(luminance);
for (let i = 1; i < lums.length; i++) {
expect(lums[i]).toBeGreaterThan(lums[i - 1]);
}
},
);

test.each(["RdYlBu", "RdBu", "Spectral", "BrBG"])(
"%s is lightest at its midpoint",
(rampName) => {
// Diverging maps pivot through a pale neutral; both ends must be darker
// than the centre or the midpoint stops reading as the neutral value.
const ramp = COLOR_RAMPS[rampName];
const mid = luminance(ramp[Math.floor(ramp.length / 2)]);
expect(mid).toBeGreaterThan(luminance(ramp[0]));
expect(mid).toBeGreaterThan(luminance(ramp[ramp.length - 1]));
},
);

describe("resolveRamp", () => {
test.each(RAMP_NAMES)("%s unreversed is the registered array", (name) => {
expect(resolveRamp(name, false)).toBe(COLOR_RAMPS[name]);
expect(resolveRamp(name)).toBe(COLOR_RAMPS[name]);
});

test.each(RAMP_NAMES)("%s reversed is end-to-end flipped", (name) => {
const forward = COLOR_RAMPS[name];
const reversed = resolveRamp(name, true);
expect(reversed).toHaveLength(forward.length);
expect(reversed[0]).toBe(forward[forward.length - 1]);
expect(reversed[reversed.length - 1]).toBe(forward[0]);
});

test("reversing does not mutate the registered ramp", () => {
// resolveRamp returns the shared array when unreversed, so an in-place
// reverse would corrupt every other consumer of that ramp.
const before = [...COLOR_RAMPS.viridis];
resolveRamp("viridis", true);
expect(COLOR_RAMPS.viridis).toEqual(before);
});

test("reversing twice returns to the original order", () => {
const once = resolveRamp("magma", true);
expect([...once].reverse()).toEqual(COLOR_RAMPS.magma);
});

test("an unknown ramp resolves to undefined either way", () => {
expect(resolveRamp("nope")).toBeUndefined();
expect(resolveRamp("nope", true)).toBeUndefined();
});
});

test("grayscale starts black and ends white", () => {
Expand Down
65 changes: 65 additions & 0 deletions reactapp/__tests__/components/map/geoTIFFStyle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,71 @@ describe("buildGeoTIFFStyleColor", () => {
expect(expr).toHaveLength(3 + RAMP_STOPS * 2);
});

describe("rampReverse", () => {
const stopsOf = (expr) => {
// Strip the 3-element operator header, then take every other entry.
const body = expr.slice(3);
return body.filter((_, i) => i % 2 === 1);
};

test("flips the colors while leaving the value stops in place", () => {
const forward = buildGeoTIFFStyleColor({
rampName: "viridis",
rampMin: 0,
rampMax: 100,
});
const reversed = buildGeoTIFFStyleColor({
rampName: "viridis",
rampMin: 0,
rampMax: 100,
rampReverse: true,
});

// Same length and same numeric breakpoints -- only the palette turns around.
expect(reversed).toHaveLength(forward.length);
const values = (expr) => expr.slice(3).filter((_, i) => i % 2 === 0);
expect(values(reversed)).toEqual(values(forward));
expect(stopsOf(reversed)).toEqual([...stopsOf(forward)].reverse());
});

test("the low end of the range takes the ramp's last color", () => {
const reversed = buildGeoTIFFStyleColor({
rampName: "viridis",
rampMin: 0,
rampMax: 100,
rampReverse: true,
});
expect(reversed[3]).toBe(0);
expect(reversed[4]).toBe(
COLOR_RAMPS.viridis[COLOR_RAMPS.viridis.length - 1],
);
expect(reversed[reversed.length - 1]).toBe(COLOR_RAMPS.viridis[0]);
});

test("omitting rampReverse matches passing false", () => {
const args = { rampName: "turbo", rampMin: -5, rampMax: 5 };
expect(buildGeoTIFFStyleColor(args)).toEqual(
buildGeoTIFFStyleColor({ ...args, rampReverse: false }),
);
});

test("reversing survives the transparency guards being prepended", () => {
const reversed = buildGeoTIFFStyleColor({
rampName: "Blues",
rampMin: 0,
rampMax: 1,
rampReverse: true,
hasNodata: true,
});
expect(reversed[0]).toBe("case");
const interpolateExpr = reversed[reversed.length - 1];
expect(interpolateExpr[0]).toBe("interpolate");
expect(interpolateExpr[4]).toBe(
COLOR_RAMPS.Blues[COLOR_RAMPS.Blues.length - 1],
);
});
});

test("starts with the first ramp color and ends with the last", () => {
const expr = buildGeoTIFFStyleColor({
rampName: "viridis",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ test("Settings Pane with visualizationRef Element", async () => {
);
expect(refreshRateInput).toBeInTheDocument();
fireEvent.change(refreshRateInput, { target: { value: -2 } });
expect(refreshRateInput.value).toBe("0");
// onRefreshRateChange rejects negatives rather than clamping them, so the
// setting is left alone while the box keeps what was typed. This previously
// asserted "0", which only held because the mount effect happened to flush
// after this change and overwrite the entry -- a timing artifact of the test,
// not the behaviour a user sees once the component has mounted.
expect(refreshRateInput.value).toBe("-2");

await expectSettings(JSON.stringify({}));

Expand Down
30 changes: 28 additions & 2 deletions reactapp/__tests__/components/modals/MapLayer/RampPicker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import RampPicker from "components/modals/MapLayer/RampPicker";

const RAMP_NAMES = ["viridis", "turbo", "RdYlBu", "grayscale"];
import { RAMP_GROUPS, RAMP_NAMES } from "components/map/colorRamps";

describe("RampPicker", () => {
test("renders all four ramp options by name", () => {
test("renders every registered ramp option by name", () => {
render(<RampPicker selectedRamp={null} onChange={() => {}} />);

for (const name of RAMP_NAMES) {
Expand All @@ -15,6 +15,32 @@ describe("RampPicker", () => {
}
});

test("shows each group's heading", () => {
// Fourteen swatches need family headings to stay navigable. The rows
// themselves carry no visible text -- the swatch is the label, with the
// ramp name exposed only to assistive tech.
render(<RampPicker selectedRamp={null} onChange={() => {}} />);

for (const group of RAMP_GROUPS) {
expect(screen.getByText(group.label)).toBeInTheDocument();
for (const name of group.names) {
expect(screen.getByTestId(`ramp-option-${name}`)).toHaveTextContent("");
}
}
});

test("every ramp is reachable and selectable", async () => {
const onChange = jest.fn();
render(<RampPicker selectedRamp={null} onChange={onChange} />);

expect(screen.getAllByRole("radio")).toHaveLength(RAMP_NAMES.length);
for (const name of RAMP_NAMES) {
onChange.mockClear();
await userEvent.click(screen.getByTestId(`ramp-option-${name}`));
expect(onChange).toHaveBeenCalledWith(name);
}
});

test("each option has a gradient swatch element", () => {
render(<RampPicker selectedRamp={null} onChange={() => {}} />);

Expand Down
39 changes: 39 additions & 0 deletions reactapp/__tests__/components/modals/MapLayer/StylePane.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ const GeoTIFFTestHarness = ({ initialSourceProps, sourcePropsSpy }) => {
<p data-testid="rampName">{sourceProps.rampName ?? ""}</p>
<p data-testid="rampMin">{sourceProps.rampMin ?? ""}</p>
<p data-testid="rampMax">{sourceProps.rampMax ?? ""}</p>
<p data-testid="rampReverse">
{String(sourceProps.rampReverse ?? false)}
</p>
</LayoutContext.Provider>
</AppContext.Provider>
);
Expand Down Expand Up @@ -157,6 +160,42 @@ test("StylePane GeoTIFF ramp/min/max handlers no-op when setSourceProps is missi
expect(() =>
fireEvent.change(maxInput, { target: { value: "100" } }),
).not.toThrow();

// Reverse checkbox → handleReverseToggle short-circuits too.
const reverse = screen.getByLabelText("Reverse Color Ramp");
expect(() => fireEvent.click(reverse)).not.toThrow();
});

test("StylePane reverse checkbox toggles sourceProps.rampReverse", async () => {
render(<GeoTIFFTestHarness initialSourceProps={{ type: "GeoTIFF" }} />);

const reverse = await screen.findByLabelText("Reverse Color Ramp");
expect(reverse).not.toBeChecked();
expect(screen.getByTestId("rampReverse")).toHaveTextContent("false");

await userEvent.click(reverse);
expect(screen.getByTestId("rampReverse")).toHaveTextContent("true");
expect(await screen.findByLabelText("Reverse Color Ramp")).toBeChecked();

await userEvent.click(screen.getByLabelText("Reverse Color Ramp"));
expect(screen.getByTestId("rampReverse")).toHaveTextContent("false");
});

test("StylePane hides the reverse checkbox in categorical mode", async () => {
// A discrete class list has no ramp direction to flip.
render(
<GeoTIFFTestHarness
initialSourceProps={{
type: "GeoTIFF",
rampName: "turbo",
styleMode: "categorical",
classes: [{ value: "1", color: "#123456", label: "One" }],
}}
/>,
);

expect(await screen.findByText("Classes")).toBeInTheDocument();
expect(screen.queryByLabelText("Reverse Color Ramp")).not.toBeInTheDocument();
});

test("StylePane json Input", async () => {
Expand Down
Loading
Loading