feat: add filter and sort controller

This commit is contained in:
admin123 2026-04-20 20:07:53 +08:00
parent 98dc078a15
commit 26bdff8daa
3 changed files with 201 additions and 0 deletions

View 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;
}

View File

@ -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"

View 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"]);
});
});