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
174 changes: 174 additions & 0 deletions reactapp/__tests__/components/landingPage/dashboardSearch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {
STOPWORDS,
filterDashboards,
matchesDashboardSearch,
normalizeForSearch,
significantTokens,
} from "components/landingPage/dashboardSearch";

const dashboard = (name, description) => ({ name, description });

describe("normalizeForSearch", () => {
test("casefolds", () => {
expect(normalizeForSearch("Guatemala")).toBe("guatemala");
});

test("strips accents so an unaccented query still matches", () => {
expect(normalizeForSearch("Clasificación")).toBe("clasificacion");
expect(normalizeForSearch("Peligro Sevéro")).toBe("peligro severo");
});

test("non-strings normalize to an empty string", () => {
// description is nullable on a dashboard record.
expect(normalizeForSearch(undefined)).toBe("");
expect(normalizeForSearch(null)).toBe("");
expect(normalizeForSearch(42)).toBe("");
});
});

describe("significantTokens", () => {
test("drops stopwords and keeps the rest", () => {
expect(significantTokens("the flood and the depth")).toEqual([
"flood",
"depth",
]);
});

test("a query of only stopwords yields no tokens", () => {
expect(significantTokens("the and a an of")).toEqual([]);
});

test("splits on punctuation, not just spaces", () => {
expect(significantTokens("Exercise #2 / flood-depth")).toEqual([
"exercise",
"2",
"flood",
"depth",
]);
});

test("digits survive as tokens", () => {
expect(significantTokens("hands on 1")).toEqual(["hands", "1"]);
});

test("empty and whitespace queries yield no tokens", () => {
expect(significantTokens("")).toEqual([]);
expect(significantTokens(" ")).toEqual([]);
});

test("the list covers the words called out as insignificant", () => {
for (const word of ["the", "and", "a", "an"]) {
expect(STOPWORDS.has(word)).toBe(true);
}
});
});

describe("matchesDashboardSearch", () => {
const guatemala = dashboard(
"Guatemala Hands On 1",
"Solution for WMO Guatemala Hands On Exercise #1",
);

test("an empty or whitespace query matches everything", () => {
expect(matchesDashboardSearch(guatemala, "")).toBe(true);
expect(matchesDashboardSearch(guatemala, " ")).toBe(true);
});

test("matches a partial name, case-insensitively", () => {
expect(matchesDashboardSearch(guatemala, "guat")).toBe(true);
expect(matchesDashboardSearch(guatemala, "GUATEMALA hands")).toBe(true);
});

test("matches on description words the name does not contain", () => {
expect(matchesDashboardSearch(guatemala, "WMO")).toBe(true);
expect(matchesDashboardSearch(guatemala, "exercise")).toBe(true);
});

test("ignores stopwords in the query when matching a description", () => {
// "for" appears in the description, but the match must not depend on it.
expect(matchesDashboardSearch(guatemala, "the solution")).toBe(true);
expect(matchesDashboardSearch(guatemala, "a wmo exercise")).toBe(true);
});

test("requires every significant token, not just one", () => {
expect(matchesDashboardSearch(guatemala, "solution exercise")).toBe(true);
expect(matchesDashboardSearch(guatemala, "solution volcano")).toBe(false);
});

test("tokens match inside longer words", () => {
const flood = dashboard("Basin", "Shows flooding across the basin");
expect(matchesDashboardSearch(flood, "flood")).toBe(true);
});

test("a stopword-only query does not match via the description", () => {
// The whole point: "the" appears in this description, but matching on it
// would surface every dashboard in the app.
const withThe = dashboard("Basin", "Shows the depth of the basin");
expect(matchesDashboardSearch(withThe, "the")).toBe(false);
expect(matchesDashboardSearch(withThe, "and the")).toBe(false);
});

test("a stopword-only query still matches a name containing it", () => {
// Stopwords are not stripped from the name path, so a dashboard actually
// called "The Basin" stays findable by typing "the".
const theBasin = dashboard("The Basin", "Depth across a basin");
expect(matchesDashboardSearch(theBasin, "the")).toBe(true);
});

test("accents in the record do not need accents in the query", () => {
const hazard = dashboard(
"Peligro",
"Clasificación de peligro por inundación",
);
expect(matchesDashboardSearch(hazard, "clasificacion")).toBe(true);
expect(matchesDashboardSearch(hazard, "inundacion")).toBe(true);
});

test("a missing description does not throw and does not match", () => {
const bare = dashboard("Basin", undefined);
expect(matchesDashboardSearch(bare, "basin")).toBe(true);
expect(matchesDashboardSearch(bare, "depth")).toBe(false);
});

test("a name is matched as a whole substring, spaces included", () => {
// "hands on" is contiguous in the name; "on hands" is not, and has no
// description support either.
expect(matchesDashboardSearch(guatemala, "hands on 1")).toBe(true);
const reordered = dashboard("Hands On", "unrelated text");
expect(matchesDashboardSearch(reordered, "on hands")).toBe(false);
});
});

describe("filterDashboards", () => {
const dashboards = [
dashboard("Guatemala Hands On 1", "Solution for WMO Exercise #1"),
dashboard("Guatemala Hands On 2", "Solution for WMO Exercise #2"),
dashboard("Willamette", "Reservoir forecasts for the Willamette basin"),
];

test("preserves the original order", () => {
const result = filterDashboards(dashboards, "guatemala");
expect(result.map((d) => d.name)).toEqual([
"Guatemala Hands On 1",
"Guatemala Hands On 2",
]);
});

test("an empty query returns every dashboard", () => {
expect(filterDashboards(dashboards, "")).toHaveLength(3);
});

test("narrows to one on a description word", () => {
expect(filterDashboards(dashboards, "reservoir")).toHaveLength(1);
});

test("a stopword-only query matches nothing here", () => {
// "the" appears in the Willamette description but in no name.
expect(filterDashboards(dashboards, "the")).toHaveLength(0);
});

test("returns an empty array for a missing list", () => {
expect(filterDashboards(undefined, "x")).toEqual([]);
expect(filterDashboards(null, "x")).toEqual([]);
});
});
134 changes: 134 additions & 0 deletions reactapp/__tests__/components/views/LandingPage.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
publicDashboard,
mockedDashboards,
Expand Down Expand Up @@ -121,6 +122,139 @@ describe("LandingPage", () => {
expect(screen.getAllByTitle("Public dashboard")).toHaveLength(1);
});

describe("search", () => {
// Purpose-built records: one description repeats "the" so a stopword-only
// query has something it could wrongly match, and neither name contains it.
const searchDashboards = [
{
...JSON.parse(JSON.stringify(mockedDashboards.dashboards[0])),
id: 1,
name: "Flood Depth",
description: "Shows the flood depth across the basin",
},
{
...JSON.parse(JSON.stringify(mockedDashboards.dashboards[1])),
id: 2,
name: "Reservoir Storage",
description: "Willamette reservoir storage and forecasts",
},
];

const renderLandingPage = (availableDashboards = searchDashboards) =>
render(
<MemoryRouter initialEntries={["/"]}>
<ModalPriorityProvider>
<AppContext.Provider
value={{
user: { username: "admin" },
tethysApp: { exitUrl: "/home" },
}}
>
<PermissionGroupContext.Provider value={{ permissionGroups: [] }}>
<AvailableDashboardsContext.Provider
value={{
availableDashboards,
deleteDashboard: jest.fn(),
copyDashboard: jest.fn(),
updateDashboard: jest.fn(),
}}
>
<AppTourContextProvider>
<LandingPage />
</AppTourContextProvider>
</AvailableDashboardsContext.Provider>
</PermissionGroupContext.Provider>
</AppContext.Provider>
</ModalPriorityProvider>
</MemoryRouter>,
);

const searchInput = () => screen.getByLabelText("Dashboard Search Input");

it("shows every dashboard before anything is typed", () => {
renderLandingPage();

expect(screen.getByText("Flood Depth")).toBeInTheDocument();
expect(screen.getByText("Reservoir Storage")).toBeInTheDocument();
expect(screen.queryByText(/dashboards$/)).not.toBeInTheDocument();
});

it("filters on a partial name", async () => {
renderLandingPage();

await userEvent.type(searchInput(), "flood");

expect(screen.getByText("Flood Depth")).toBeInTheDocument();
expect(screen.queryByText("Reservoir Storage")).not.toBeInTheDocument();
expect(screen.getByText("1 of 2 dashboards")).toBeInTheDocument();
});

it("filters on a word that only appears in the description", async () => {
renderLandingPage();

await userEvent.type(searchInput(), "willamette");

expect(screen.getByText("Reservoir Storage")).toBeInTheDocument();
expect(screen.queryByText("Flood Depth")).not.toBeInTheDocument();
});

it("ignores an insignificant word rather than matching descriptions on it", async () => {
// "the" appears twice in the Flood Depth description and in neither name.
renderLandingPage();

await userEvent.type(searchInput(), "the");

expect(screen.queryByText("Flood Depth")).not.toBeInTheDocument();
expect(screen.queryByText("Reservoir Storage")).not.toBeInTheDocument();
expect(
screen.getByText(/No dashboards match/, { exact: false }),
).toBeInTheDocument();
expect(screen.getByText("0 of 2 dashboards")).toBeInTheDocument();
});

it("still matches significant words when stopwords are typed alongside", async () => {
renderLandingPage();

await userEvent.type(searchInput(), "the basin");

expect(screen.getByText("Flood Depth")).toBeInTheDocument();
expect(screen.getByText("1 of 2 dashboards")).toBeInTheDocument();
});

it("hides the New Dashboard tile while filtering and restores it after", async () => {
renderLandingPage();
expect(screen.getByText("Create a New Dashboard")).toBeInTheDocument();

await userEvent.type(searchInput(), "flood");
expect(
screen.queryByText("Create a New Dashboard"),
).not.toBeInTheDocument();

await userEvent.click(screen.getByLabelText("Clear Dashboard Search"));
expect(screen.getByText("Create a New Dashboard")).toBeInTheDocument();
});

it("clearing the box restores every dashboard", async () => {
renderLandingPage();

await userEvent.type(searchInput(), "flood");
expect(screen.queryByText("Reservoir Storage")).not.toBeInTheDocument();

await userEvent.click(screen.getByLabelText("Clear Dashboard Search"));
expect(searchInput()).toHaveValue("");
expect(screen.getByText("Flood Depth")).toBeInTheDocument();
expect(screen.getByText("Reservoir Storage")).toBeInTheDocument();
});

it("omits the search box entirely when there is nothing to search", () => {
renderLandingPage([]);

expect(
screen.queryByLabelText("Dashboard Search Input"),
).not.toBeInTheDocument();
});
});

it("Doesn't show Create new Dashboard when signed in as public with no dashboards", () => {
render(
<MemoryRouter initialEntries={["/"]}>
Expand Down
Loading
Loading