← back to Open Seo

src/client/features/rank-tracking/RankTrackingFilters.test.ts

283 lines

import { describe, expect, it } from "vitest";
import type { RankTrackingRow } from "@/types/schemas/rank-tracking";
import {
  applyDomainListFilters,
  applyFilters,
  countActiveDomainListFilters,
  EMPTY_DOMAIN_LIST_FILTERS,
  EMPTY_FILTERS,
  getDomainListFilterOptions,
  matchesMetricRangeFilter,
  matchesPositionFilter,
  type DomainListFilters,
  type Filters,
} from "./RankTrackingFilters";

type DomainSummary = {
  id: string;
  domain: string;
  devices: "both" | "desktop" | "mobile";
  locationCode: number;
};

function makeRow(
  keyword: string,
  desktopPosition: number | null,
  mobilePosition: number | null,
  metrics: {
    volume?: number | null;
    kd?: number | null;
    cpc?: number | null;
  } = {},
): RankTrackingRow {
  return {
    trackingKeywordId: keyword,
    keyword,
    searchVolume: metrics.volume ?? null,
    keywordDifficulty: metrics.kd ?? null,
    cpc: metrics.cpc ?? null,
    desktop: {
      position: desktopPosition,
      previousPosition: null,
      rankingUrl: null,
      serpFeatures: [],
    },
    mobile: {
      position: mobilePosition,
      previousPosition: null,
      rankingUrl: null,
      serpFeatures: [],
    },
  };
}

function withFilters(overrides: Partial<Filters>): Filters {
  return { ...EMPTY_FILTERS, ...overrides };
}

function makeSummary(
  id: string,
  domain: string,
  devices: DomainSummary["devices"],
  locationCode: number,
): DomainSummary {
  return { id, domain, devices, locationCode };
}

function withDomainFilters(
  overrides: Partial<DomainListFilters>,
): DomainListFilters {
  return { ...EMPTY_DOMAIN_LIST_FILTERS, ...overrides };
}

describe("matchesPositionFilter", () => {
  it("matches only unranked positions when max is zero", () => {
    expect(matchesPositionFilter(null, "", "0")).toBe(true);
    expect(matchesPositionFilter(1, "", "0")).toBe(false);
    expect(matchesPositionFilter(20, "10", "0")).toBe(false);
  });

  it("keeps regular rank ranges unchanged", () => {
    expect(matchesPositionFilter(4, "1", "10")).toBe(true);
    expect(matchesPositionFilter(11, "1", "10")).toBe(false);
    expect(matchesPositionFilter(null, "1", "10")).toBe(false);
  });
});

describe("applyFilters", () => {
  const rows = [
    makeRow("ranked both", 3, 6),
    makeRow("desktop unranked", null, 5),
    makeRow("mobile unranked", 7, null),
    makeRow("unranked both", null, null),
  ];

  it("filters desktop unranked rows with desktop max zero", () => {
    expect(
      applyFilters(rows, withFilters({ maxDesktopPos: "0" })).map(
        (row) => row.keyword,
      ),
    ).toEqual(["desktop unranked", "unranked both"]);
  });

  it("filters mobile unranked rows with mobile max zero", () => {
    expect(
      applyFilters(rows, withFilters({ maxMobilePos: "0" })).map(
        (row) => row.keyword,
      ),
    ).toEqual(["mobile unranked", "unranked both"]);
  });

  it("requires both devices to be unranked when both max values are zero", () => {
    expect(
      applyFilters(
        rows,
        withFilters({ maxDesktopPos: "0", maxMobilePos: "0" }),
      ).map((row) => row.keyword),
    ).toEqual(["unranked both"]);
  });
});

describe("applyDomainListFilters", () => {
  const summaries = [
    makeSummary("alpha-us-mobile", "alpha.example.com", "mobile", 2840),
    makeSummary("alpha-fr-desktop", "alpha.example.com", "desktop", 2250),
    makeSummary("alpha-fr-mobile", "alpha.example.com", "mobile", 2250),
    makeSummary("bravo-fr-both", "bravo.example.com", "both", 2250),
    makeSummary("charlie-uk-desktop", "charlie.example.com", "desktop", 2826),
  ];

  it("narrows by text query and restores all when cleared", () => {
    expect(
      applyDomainListFilters(
        summaries,
        withDomainFilters({ query: "ALPHA" }),
      ).map((summary) => summary.id),
    ).toEqual(["alpha-us-mobile", "alpha-fr-desktop", "alpha-fr-mobile"]);

    expect(
      applyDomainListFilters(summaries, EMPTY_DOMAIN_LIST_FILTERS).map(
        (summary) => summary.id,
      ),
    ).toEqual(summaries.map((summary) => summary.id));
  });

  it("filters by device", () => {
    expect(
      applyDomainListFilters(
        summaries,
        withDomainFilters({ device: "mobile" }),
      ).map((summary) => summary.id),
    ).toEqual(["alpha-us-mobile", "alpha-fr-mobile"]);
  });

  it("filters by country", () => {
    expect(
      applyDomainListFilters(
        summaries,
        withDomainFilters({ locationCode: "2250" }),
      ).map((summary) => summary.id),
    ).toEqual(["alpha-fr-desktop", "alpha-fr-mobile", "bravo-fr-both"]);
  });

  it("combines domain, device, and country filters with AND semantics", () => {
    expect(
      applyDomainListFilters(
        summaries,
        withDomainFilters({
          query: "alpha",
          device: "mobile",
          locationCode: "2250",
        }),
      ).map((summary) => summary.id),
    ).toEqual(["alpha-fr-mobile"]);
  });

  it("returns an empty list when filters match nothing", () => {
    expect(
      applyDomainListFilters(
        summaries,
        withDomainFilters({ query: "missing", device: "mobile" }),
      ),
    ).toEqual([]);
  });
});

describe("getDomainListFilterOptions", () => {
  it("derives distinct device and country options from available summaries", () => {
    const options = getDomainListFilterOptions([
      makeSummary("a", "a.com", "mobile", 2250),
      makeSummary("b", "b.com", "desktop", 2250),
      makeSummary("c", "c.com", "mobile", 2826),
    ]);

    expect(options.devices).toEqual([
      { value: "desktop", label: "Desktop" },
      { value: "mobile", label: "Mobile" },
    ]);
    expect(options.locations).toEqual([
      { value: "2250", label: "FR" },
      { value: "2826", label: "UK" },
    ]);
  });
});

describe("countActiveDomainListFilters", () => {
  it("counts non-empty domain list filters", () => {
    expect(countActiveDomainListFilters(EMPTY_DOMAIN_LIST_FILTERS)).toBe(0);
    expect(
      countActiveDomainListFilters(
        withDomainFilters({
          query: "alpha",
          device: "desktop",
          locationCode: "2840",
        }),
      ),
    ).toBe(3);
  });
});

describe("matchesMetricRangeFilter", () => {
  it("matches everything when no bounds are set", () => {
    expect(matchesMetricRangeFilter(null, "", "")).toBe(true);
    expect(matchesMetricRangeFilter(500, "", "")).toBe(true);
  });

  it("excludes null values once a bound is set", () => {
    expect(matchesMetricRangeFilter(null, "10", "")).toBe(false);
    expect(matchesMetricRangeFilter(null, "", "100")).toBe(false);
  });

  it("treats zero as a real value, not 'no data'", () => {
    expect(matchesMetricRangeFilter(0, "0", "10")).toBe(true);
    expect(matchesMetricRangeFilter(0, "1", "10")).toBe(false);
  });

  it("respects min-only and max-only bounds", () => {
    expect(matchesMetricRangeFilter(50, "20", "")).toBe(true);
    expect(matchesMetricRangeFilter(10, "20", "")).toBe(false);
    expect(matchesMetricRangeFilter(50, "", "40")).toBe(false);
    expect(matchesMetricRangeFilter(50, "", "60")).toBe(true);
  });
});

describe("applyFilters metric ranges", () => {
  const rows = [
    makeRow("high volume", 3, 6, { volume: 5000, kd: 40, cpc: 2.5 }),
    makeRow("low volume", 3, 6, { volume: 10, kd: 80, cpc: 0.1 }),
    makeRow("no data", 3, 6, {}),
  ];

  it("filters by volume range", () => {
    expect(
      applyFilters(rows, withFilters({ minVolume: "100" })).map(
        (row) => row.keyword,
      ),
    ).toEqual(["high volume"]);
  });

  it("filters by KD range", () => {
    expect(
      applyFilters(rows, withFilters({ maxKd: "50" })).map(
        (row) => row.keyword,
      ),
    ).toEqual(["high volume"]);
  });

  it("filters by CPC range", () => {
    expect(
      applyFilters(rows, withFilters({ minCpc: "1" })).map(
        (row) => row.keyword,
      ),
    ).toEqual(["high volume"]);
  });

  it("excludes rows without metric data once a metric filter is active", () => {
    expect(
      applyFilters(rows, withFilters({ minVolume: "0" })).map(
        (row) => row.keyword,
      ),
    ).toEqual(["high volume", "low volume"]);
  });
});