feat: add filter and sort controller
This commit is contained in:
parent
98dc078a15
commit
26bdff8daa
95
src/content/market/filter-sort-controller.ts
Normal file
95
src/content/market/filter-sort-controller.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -18,6 +18,16 @@ export interface MarketRecord extends MarketRowSnapshot {
|
||||
rates?: Required<AfterSearchRates>;
|
||||
}
|
||||
|
||||
export interface MarketFilterState {
|
||||
personalVideoAfterSearchRateMin?: number;
|
||||
singleVideoAfterSearchRateMin?: number;
|
||||
}
|
||||
|
||||
export interface MarketSortState {
|
||||
direction: "asc" | "desc";
|
||||
field: keyof Required<AfterSearchRates>;
|
||||
}
|
||||
|
||||
export type MarketApiFailureReason =
|
||||
| "bad-response"
|
||||
| "missing-rate"
|
||||
|
||||
96
tests/filter-sort-controller.test.ts
Normal file
96
tests/filter-sort-controller.test.ts
Normal file
@ -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"]);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user