diff --git a/src/content/market/filter-sort-controller.ts b/src/content/market/filter-sort-controller.ts new file mode 100644 index 0000000..3399350 --- /dev/null +++ b/src/content/market/filter-sort-controller.ts @@ -0,0 +1,95 @@ +import { + compareRateValues, + parseRateLowerBound +} from "../../shared/rate-normalizer"; +import type { + MarketFilterState, + MarketRecord, + MarketSortState +} from "./types"; + +interface ApplyFilterAndSortOptions { + filters?: MarketFilterState; + sort?: MarketSortState; +} + +export function applyFilterAndSort( + records: MarketRecord[], + options: ApplyFilterAndSortOptions = {} +): MarketRecord[] { + const filteredRecords = records.filter((record) => + matchesFilters(record, options.filters) + ); + + if (!options.sort) { + return filteredRecords; + } + + return [...filteredRecords].sort((leftRecord, rightRecord) => + compareRecords(leftRecord, rightRecord, options.sort as MarketSortState) + ); +} + +function matchesFilters( + record: MarketRecord, + filters: MarketFilterState | undefined +): boolean { + if (!filters) { + return true; + } + + return ( + meetsThreshold( + record.rates?.singleVideoAfterSearchRate, + filters.singleVideoAfterSearchRateMin + ) && + meetsThreshold( + record.rates?.personalVideoAfterSearchRate, + filters.personalVideoAfterSearchRateMin + ) + ); +} + +function meetsThreshold( + rateValue: string | undefined, + minValue: number | undefined +): boolean { + if (minValue == null) { + return true; + } + + const lowerBound = parseRateLowerBound(rateValue ?? null); + return lowerBound != null && lowerBound >= minValue; +} + +function compareRecords( + leftRecord: MarketRecord, + rightRecord: MarketRecord, + sort: MarketSortState +): number { + const leftValue = leftRecord.rates?.[sort.field]; + const rightValue = rightRecord.rates?.[sort.field]; + const leftLowerBound = parseRateLowerBound(leftValue ?? null); + const rightLowerBound = parseRateLowerBound(rightValue ?? null); + + if (leftLowerBound == null && rightLowerBound == null) { + return 0; + } + + if (leftLowerBound == null) { + return 1; + } + + if (rightLowerBound == null) { + return -1; + } + + if (leftLowerBound !== rightLowerBound) { + return sort.direction === "asc" + ? leftLowerBound - rightLowerBound + : rightLowerBound - leftLowerBound; + } + + const tieBreak = compareRateValues(leftValue, rightValue); + return sort.direction === "asc" ? tieBreak : -tieBreak; +} diff --git a/src/content/market/types.ts b/src/content/market/types.ts index 21e6529..ed6a533 100644 --- a/src/content/market/types.ts +++ b/src/content/market/types.ts @@ -18,6 +18,16 @@ export interface MarketRecord extends MarketRowSnapshot { rates?: Required; } +export interface MarketFilterState { + personalVideoAfterSearchRateMin?: number; + singleVideoAfterSearchRateMin?: number; +} + +export interface MarketSortState { + direction: "asc" | "desc"; + field: keyof Required; +} + export type MarketApiFailureReason = | "bad-response" | "missing-rate" diff --git a/tests/filter-sort-controller.test.ts b/tests/filter-sort-controller.test.ts new file mode 100644 index 0000000..3880b69 --- /dev/null +++ b/tests/filter-sort-controller.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "vitest"; + +import { applyFilterAndSort } from "../src/content/market/filter-sort-controller"; +import type { MarketRecord } from "../src/content/market/types"; + +const baseRecords: MarketRecord[] = [ + { + authorId: "a", + authorName: "Alpha", + status: "success", + rates: { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + } + }, + { + authorId: "b", + authorName: "Beta", + status: "success", + rates: { + singleVideoAfterSearchRate: "0.5% - 1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + } + }, + { + authorId: "c", + authorName: "Gamma", + status: "failed", + failureReason: "request-failed" + } +]; + +describe("filter-sort-controller", () => { + test("passes only when the lower bound meets the single-rate threshold", () => { + const result = applyFilterAndSort(baseRecords, { + filters: { + singleVideoAfterSearchRateMin: 0.1 + } + }); + + expect(result.map((record) => record.authorId)).toEqual(["b"]); + }); + + test("passes only when the lower bound meets the personal-rate threshold", () => { + const result = applyFilterAndSort(baseRecords, { + filters: { + personalVideoAfterSearchRateMin: 0.02 + } + }); + + expect(result.map((record) => record.authorId)).toEqual(["a"]); + }); + + test("sorts by single-rate descending using the lower bound", () => { + const result = applyFilterAndSort(baseRecords, { + sort: { + direction: "desc", + field: "singleVideoAfterSearchRate" + } + }); + + expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]); + }); + + test("sorts by personal-rate ascending using the lower bound", () => { + const result = applyFilterAndSort(baseRecords, { + sort: { + direction: "asc", + field: "personalVideoAfterSearchRate" + } + }); + + expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]); + }); + + test("keeps failed and missing rows at the end", () => { + const result = applyFilterAndSort( + [ + ...baseRecords, + { + authorId: "d", + authorName: "Delta", + status: "missing" + } + ], + { + sort: { + direction: "desc", + field: "singleVideoAfterSearchRate" + } + } + ); + + expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]); + }); +});