fix: stabilize market metrics hydration and sorting
This commit is contained in:
@@ -121,4 +121,67 @@ describe("filter-sort-controller", () => {
|
||||
|
||||
expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]);
|
||||
});
|
||||
|
||||
test("keeps equal rate buckets in a deterministic order across repeated sorts", () => {
|
||||
const records: MarketRecord[] = [
|
||||
{
|
||||
authorId: "b",
|
||||
authorName: "Beta",
|
||||
status: "success",
|
||||
rates: {
|
||||
singleVideoAfterSearchRate: "0.5% - 1%"
|
||||
}
|
||||
},
|
||||
{
|
||||
authorId: "a",
|
||||
authorName: "Alpha",
|
||||
status: "success",
|
||||
rates: {
|
||||
singleVideoAfterSearchRate: "0.5% - 1%"
|
||||
}
|
||||
},
|
||||
{
|
||||
authorId: "d",
|
||||
authorName: "Delta",
|
||||
status: "success",
|
||||
rates: {
|
||||
singleVideoAfterSearchRate: "0.25% - 0.5%"
|
||||
}
|
||||
},
|
||||
{
|
||||
authorId: "c",
|
||||
authorName: "Gamma",
|
||||
status: "success",
|
||||
rates: {
|
||||
singleVideoAfterSearchRate: "0.25% - 0.5%"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const firstResult = applyFilterAndSort(records, {
|
||||
sort: {
|
||||
direction: "desc",
|
||||
field: "singleVideoAfterSearchRate"
|
||||
}
|
||||
});
|
||||
const secondResult = applyFilterAndSort([...records].reverse(), {
|
||||
sort: {
|
||||
direction: "desc",
|
||||
field: "singleVideoAfterSearchRate"
|
||||
}
|
||||
});
|
||||
|
||||
expect(firstResult.map((record) => record.authorId)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d"
|
||||
]);
|
||||
expect(secondResult.map((record) => record.authorId)).toEqual([
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"d"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -511,6 +511,86 @@ describe("market-content-entry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("rehydrates real rows after serialized market rows arrive later", async () => {
|
||||
document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([
|
||||
{
|
||||
authorName: "达人 A",
|
||||
price21To60s: "¥450,000"
|
||||
},
|
||||
{
|
||||
authorName: "达人 B",
|
||||
price21To60s: "¥20,000"
|
||||
}
|
||||
]);
|
||||
|
||||
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
|
||||
success: true as const,
|
||||
rates:
|
||||
authorId === "111"
|
||||
? {
|
||||
singleVideoAfterSearchRate: "0.02%",
|
||||
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||||
}
|
||||
: {
|
||||
singleVideoAfterSearchRate: "0.5%-1%",
|
||||
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||||
}
|
||||
}));
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = trackController(createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics,
|
||||
window
|
||||
}));
|
||||
|
||||
await controller.ready;
|
||||
|
||||
expect(loadAuthorMetrics).not.toHaveBeenCalled();
|
||||
expect(readDivPluginRowTexts(0)).toEqual(["", "", "", "", "", "", "", ""]);
|
||||
expect(readDivPluginRowTexts(1)).toEqual(["", "", "", "", "", "", "", ""]);
|
||||
|
||||
document.documentElement.setAttribute(
|
||||
"data-sces-market-rows",
|
||||
JSON.stringify([
|
||||
{
|
||||
authorId: "111",
|
||||
authorName: "达人 A",
|
||||
singleVideoAfterSearchRate: "0.02%"
|
||||
},
|
||||
{
|
||||
authorId: "222",
|
||||
authorName: "达人 B"
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
await flushWithTimers();
|
||||
await flushWithTimers();
|
||||
|
||||
expect(loadAuthorMetrics).toHaveBeenCalledTimes(2);
|
||||
expect(readDivPluginRowTexts(0)).toEqual([
|
||||
"0.02%",
|
||||
"0.03% - 0.2%",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]);
|
||||
expect(readDivPluginRowTexts(1)).toEqual([
|
||||
"0.5% - 1%",
|
||||
"0.01% - 0.1%",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
""
|
||||
]);
|
||||
});
|
||||
|
||||
test("applying plugin filters hides non-matching current-page rows without a full scan", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const resultStore = createMarketResultStore();
|
||||
@@ -3113,28 +3193,47 @@ function readRowOrder() {
|
||||
}
|
||||
|
||||
function readDivAuthorOrder() {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid^="author-cell-"] a'),
|
||||
(link) => link.textContent?.trim() ?? ""
|
||||
const authorColumn = document.querySelector(
|
||||
'[data-testid="author-section"] .content-column'
|
||||
);
|
||||
return readVisualCells(authorColumn).map(
|
||||
(cell) => cell.querySelector("a")?.textContent?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function readDivRightRowTexts(rowIndex: number) {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
|
||||
(column) =>
|
||||
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
|
||||
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function readDivPluginRowTexts(rowIndex: number) {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid="plugin-section"] > .content-column'),
|
||||
(column) =>
|
||||
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
|
||||
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function readVisualCells(root: Element | null): HTMLElement[] {
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(root.querySelectorAll(":scope > .content-cell"))
|
||||
.filter((cell): cell is HTMLElement => cell instanceof HTMLElement)
|
||||
.sort((left, right) => {
|
||||
const leftOrder = Number(left.style.order || "0");
|
||||
const rightOrder = Number(right.style.order || "0");
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
|
||||
const cells = Array.from(root.querySelectorAll(":scope > .content-cell"));
|
||||
return cells.indexOf(left) - cells.indexOf(right);
|
||||
});
|
||||
}
|
||||
|
||||
function trackController<T extends { dispose?: () => void }>(controller: T): T {
|
||||
if (controller.dispose) {
|
||||
disposers.push(() => controller.dispose?.());
|
||||
|
||||
@@ -553,6 +553,7 @@ describe("market-dom-sync", () => {
|
||||
expect(table.rows[0].rates).toEqual({
|
||||
singleVideoAfterSearchRate: "0.02%"
|
||||
});
|
||||
expect(readMarketPageSignature(document)).toContain("::111|222");
|
||||
});
|
||||
|
||||
test("finds the real next-page button in Xingtu pagination", () => {
|
||||
@@ -581,6 +582,16 @@ describe("market-dom-sync", () => {
|
||||
expect(isPageControlDisabled(nextControl)).toBe(false);
|
||||
expect(readMarketPageSignature(document)).toContain("1::111|222");
|
||||
});
|
||||
|
||||
test("reads market page signature without mutating the page", () => {
|
||||
document.body.innerHTML = buildRealMarketGridFixture();
|
||||
|
||||
const signature = readMarketPageSignature(document);
|
||||
|
||||
expect(signature).toContain("::111|222");
|
||||
expect(document.querySelector('[data-testid="plugin-header"]')).toBeNull();
|
||||
expect(document.querySelector('[data-testid="plugin-section"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function buildRealMarketGridFixture() {
|
||||
@@ -898,16 +909,14 @@ function readPluginHeaderTexts() {
|
||||
function readRightRowTexts(rowIndex: number) {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
|
||||
(column) =>
|
||||
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
|
||||
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function readPluginRowTexts(rowIndex: number) {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid="plugin-section"] > .content-column'),
|
||||
(column) =>
|
||||
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
|
||||
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -918,9 +927,11 @@ function readScrollHintText() {
|
||||
}
|
||||
|
||||
function readAuthorNames() {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid="author-section"] .content-cell a'),
|
||||
(link) => link.textContent?.trim() ?? ""
|
||||
const authorColumn = document.querySelector(
|
||||
'[data-testid="author-section"] .content-column'
|
||||
);
|
||||
return readVisualCells(authorColumn).map(
|
||||
(cell) => cell.querySelector("a")?.textContent?.trim() ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -937,3 +948,22 @@ function readRightActionHiddenStates() {
|
||||
(cell) => (cell as HTMLElement).hidden
|
||||
);
|
||||
}
|
||||
|
||||
function readVisualCells(root: Element | null): HTMLElement[] {
|
||||
if (!root) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(root.querySelectorAll(":scope > .content-cell"))
|
||||
.filter((cell): cell is HTMLElement => cell instanceof HTMLElement)
|
||||
.sort((left, right) => {
|
||||
const leftOrder = Number(left.style.order || "0");
|
||||
const rightOrder = Number(right.style.order || "0");
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
|
||||
const cells = Array.from(root.querySelectorAll(":scope > .content-cell"));
|
||||
return cells.indexOf(left) - cells.indexOf(right);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user