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>;
|
rates?: Required<AfterSearchRates>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MarketFilterState {
|
||||||
|
personalVideoAfterSearchRateMin?: number;
|
||||||
|
singleVideoAfterSearchRateMin?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketSortState {
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
field: keyof Required<AfterSearchRates>;
|
||||||
|
}
|
||||||
|
|
||||||
export type MarketApiFailureReason =
|
export type MarketApiFailureReason =
|
||||||
| "bad-response"
|
| "bad-response"
|
||||||
| "missing-rate"
|
| "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