388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { beforeEach, describe, expect, test } from "vitest";
|
|
|
|
import {
|
|
applyRowOrder,
|
|
applyRowVisibility,
|
|
findNextPageControl,
|
|
isPageControlDisabled,
|
|
readMarketPageSignature,
|
|
renderMarketRowState,
|
|
syncMarketTable
|
|
} from "../src/content/market/dom-sync";
|
|
import type { MarketRecord } from "../src/content/market/types";
|
|
|
|
describe("market-dom-sync", () => {
|
|
beforeEach(() => {
|
|
document.body.innerHTML = `
|
|
<div data-market-table>
|
|
<div data-market-header>
|
|
<div data-market-header-cell="authorName">达人</div>
|
|
<div data-market-header-cell="price21To60s">21-60s报价</div>
|
|
</div>
|
|
<div data-market-body>
|
|
<div data-market-row data-author-id="a">
|
|
<span data-market-field="authorName">Alpha</span>
|
|
<span data-market-field="price21To60s">450000</span>
|
|
</div>
|
|
<div data-market-row data-author-id="b">
|
|
<span data-market-field="authorName">Beta</span>
|
|
<span data-market-field="price21To60s">70000</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
test("injects the two header cells and per-row cells", () => {
|
|
const table = syncMarketTable(document);
|
|
|
|
expect(table).not.toBeNull();
|
|
expect(
|
|
document.querySelector('[data-market-header-cell="singleVideoAfterSearchRate"]')
|
|
).not.toBeNull();
|
|
expect(
|
|
document.querySelector(
|
|
'[data-market-header-cell="personalVideoAfterSearchRate"]'
|
|
)
|
|
).not.toBeNull();
|
|
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(4);
|
|
});
|
|
|
|
test("renders loading, success, and failed states", () => {
|
|
const table = syncMarketTable(document);
|
|
if (!table) {
|
|
throw new Error("Expected market table");
|
|
}
|
|
|
|
const alphaRow = table.rows[0];
|
|
const betaRow = table.rows[1];
|
|
|
|
renderMarketRowState(alphaRow, {
|
|
authorId: "a",
|
|
authorName: "Alpha",
|
|
status: "loading"
|
|
});
|
|
renderMarketRowState(betaRow, {
|
|
authorId: "b",
|
|
authorName: "Beta",
|
|
status: "success",
|
|
rates: {
|
|
singleVideoAfterSearchRate: "0.5%-1%",
|
|
personalVideoAfterSearchRate: "0.02 - 0.1%"
|
|
}
|
|
});
|
|
|
|
expect(alphaRow.singleCell.textContent).toBe("加载中...");
|
|
expect(betaRow.singleCell.textContent).toBe("0.5% - 1%");
|
|
expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%");
|
|
|
|
renderMarketRowState(betaRow, {
|
|
authorId: "b",
|
|
authorName: "Beta",
|
|
status: "failed"
|
|
});
|
|
expect(betaRow.singleCell.textContent).toBe("加载失败");
|
|
expect(betaRow.personalCell.textContent).toBe("加载失败");
|
|
});
|
|
|
|
test("hides rows outside the visible author ids", () => {
|
|
const table = syncMarketTable(document);
|
|
if (!table) {
|
|
throw new Error("Expected market table");
|
|
}
|
|
|
|
applyRowVisibility(table, new Set(["b"]));
|
|
|
|
expect(table.rows[0].row.hidden).toBe(true);
|
|
expect(table.rows[1].row.hidden).toBe(false);
|
|
});
|
|
|
|
test("reorders rows based on ordered author ids", () => {
|
|
const table = syncMarketTable(document);
|
|
if (!table) {
|
|
throw new Error("Expected market table");
|
|
}
|
|
|
|
applyRowOrder(table, ["b", "a"]);
|
|
|
|
expect(
|
|
Array.from(
|
|
document.querySelectorAll("[data-market-row]")
|
|
).map((row) => row.getAttribute("data-author-id"))
|
|
).toEqual(["b", "a"]);
|
|
});
|
|
|
|
test("supports the real div-grid market layout and keeps rows aligned", () => {
|
|
document.body.innerHTML = buildRealMarketGridFixture();
|
|
|
|
const table = syncMarketTable(document);
|
|
if (!table) {
|
|
throw new Error("Expected market table");
|
|
}
|
|
|
|
expect(readRightHeaderTexts()).toEqual([
|
|
"21-60s报价",
|
|
"单视频看后搜率",
|
|
"个人视频看后搜率",
|
|
"操作"
|
|
]);
|
|
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
|
|
|
renderMarketRowState(table.rows[0], {
|
|
authorId: "111",
|
|
authorName: "达人 A",
|
|
status: "success",
|
|
rates: {
|
|
singleVideoAfterSearchRate: "0.5%-1%",
|
|
personalVideoAfterSearchRate: "0.02 - 0.1%"
|
|
}
|
|
});
|
|
|
|
expect(readRightRowTexts(0)).toEqual([
|
|
"¥450,000",
|
|
"0.5% - 1%",
|
|
"0.02% - 0.1%",
|
|
"下单"
|
|
]);
|
|
|
|
applyRowVisibility(table, new Set(["222"]));
|
|
|
|
expect(readAuthorRowHiddenStates()).toEqual([true, false]);
|
|
expect(readRightActionHiddenStates()).toEqual([true, false]);
|
|
|
|
applyRowVisibility(table, new Set(["111", "222"]));
|
|
applyRowOrder(table, ["222", "111"]);
|
|
|
|
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
|
|
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "下单"]);
|
|
expect(table.rows[0].exportFields).toMatchObject({
|
|
"21-60s报价": "¥450,000",
|
|
"代表视频": "代表视频A",
|
|
"达人信息": "达人 A"
|
|
});
|
|
});
|
|
|
|
test("falls back to the market vue state when the DOM has no author id", () => {
|
|
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
|
|
attachMarketVueState([
|
|
{
|
|
attribute_datas: {
|
|
nickname: "达人 A"
|
|
},
|
|
star_id: "111"
|
|
},
|
|
{
|
|
attribute_datas: {
|
|
nickname: "达人 B"
|
|
},
|
|
star_id: "222"
|
|
}
|
|
]);
|
|
|
|
const table = syncMarketTable(document);
|
|
if (!table) {
|
|
throw new Error("Expected market table");
|
|
}
|
|
|
|
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
|
});
|
|
|
|
test("falls back to serialized market rows when vue state is unavailable", () => {
|
|
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
|
|
document.documentElement.setAttribute(
|
|
"data-sces-market-rows",
|
|
JSON.stringify([
|
|
{
|
|
authorId: "111",
|
|
authorName: "达人 A",
|
|
singleVideoAfterSearchRate: "0.02%"
|
|
},
|
|
{
|
|
authorId: "222",
|
|
authorName: "达人 B"
|
|
}
|
|
])
|
|
);
|
|
|
|
const table = syncMarketTable(document);
|
|
if (!table) {
|
|
throw new Error("Expected market table");
|
|
}
|
|
|
|
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
|
|
expect(table.rows[0].rates).toEqual({
|
|
singleVideoAfterSearchRate: "0.02%"
|
|
});
|
|
});
|
|
|
|
test("finds the real next-page button in Xingtu pagination", () => {
|
|
document.body.innerHTML = `
|
|
${buildRealMarketGridFixture()}
|
|
<div class="pagination xt-space xt-space--medium">
|
|
<div class="el-pagination is-background xt-pagination xt-pagination--normal">
|
|
<button type="button" disabled="disabled" class="btn-prev">
|
|
<i class="el-icon el-icon-arrow-left"></i>
|
|
</button>
|
|
<ul class="el-pager">
|
|
<li class="number active">1</li>
|
|
<li class="number">2</li>
|
|
</ul>
|
|
<button type="button" class="btn-next">
|
|
<i class="el-icon el-icon-arrow-right"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const nextControl = findNextPageControl(document);
|
|
|
|
expect(nextControl).not.toBeNull();
|
|
expect(nextControl?.className).toContain("btn-next");
|
|
expect(isPageControlDisabled(nextControl)).toBe(false);
|
|
expect(readMarketPageSignature(document)).toContain("1::111|222");
|
|
});
|
|
});
|
|
|
|
function buildRealMarketGridFixture() {
|
|
return `
|
|
<div class="base-author-list">
|
|
<div class="section-wrapper sticky-header hide-scrollbar">
|
|
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
|
<div class="header-cell" style="min-width: 310px;">达人信息</div>
|
|
</div>
|
|
<div class="middle-columns" style="width: 190px; display: flex;">
|
|
<div class="header-cell" style="min-width: 190px;">代表视频</div>
|
|
</div>
|
|
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
|
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
|
|
<div class="header-cell" style="min-width: 200px;">操作</div>
|
|
</div>
|
|
</div>
|
|
<div class="section-wrapper hide-scrollbar">
|
|
<div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
|
<div class="content-column" style="min-width: 310px;">
|
|
<div class="content-cell" data-testid="author-cell-111" style="height: 120px;">
|
|
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a>
|
|
</div>
|
|
<div class="content-cell" data-testid="author-cell-222" style="height: 120px;">
|
|
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="middle-columns" style="width: 190px; display: flex;">
|
|
<div class="content-column" style="min-width: 190px;">
|
|
<div class="content-cell" style="height: 120px;">代表视频A</div>
|
|
<div class="content-cell" style="height: 120px;">代表视频B</div>
|
|
</div>
|
|
</div>
|
|
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
|
<div class="content-column" style="min-width: 150px;">
|
|
<div class="content-cell" style="height: 120px;">¥450,000</div>
|
|
<div class="content-cell" style="height: 120px;">¥20,000</div>
|
|
</div>
|
|
<div class="content-column" style="min-width: 200px;">
|
|
<div class="content-cell" data-testid="action-cell-111" style="height: 120px;">下单</div>
|
|
<div class="content-cell" data-testid="action-cell-222" style="height: 120px;">下单</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function buildRealMarketGridFixtureWithoutAuthorIds() {
|
|
return `
|
|
<div class="base-author-list">
|
|
<div class="section-wrapper sticky-header hide-scrollbar">
|
|
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
|
<div class="header-cell" style="min-width: 310px;">达人信息</div>
|
|
</div>
|
|
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
|
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
|
|
<div class="header-cell" style="min-width: 200px;">操作</div>
|
|
</div>
|
|
</div>
|
|
<div class="section-wrapper hide-scrollbar">
|
|
<div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
|
|
<div class="content-column" style="min-width: 310px;">
|
|
<div class="content-cell" data-testid="author-cell-1" style="height: 120px;">
|
|
<span class="author-nickname">达人 A</span>
|
|
</div>
|
|
<div class="content-cell" data-testid="author-cell-2" style="height: 120px;">
|
|
<span class="author-nickname">达人 B</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
|
|
<div class="content-column" style="min-width: 150px;">
|
|
<div class="content-cell" style="height: 120px;">¥450,000</div>
|
|
<div class="content-cell" style="height: 120px;">¥20,000</div>
|
|
</div>
|
|
<div class="content-column" style="min-width: 200px;">
|
|
<div class="content-cell" data-testid="action-cell-1" style="height: 120px;">下单</div>
|
|
<div class="content-cell" data-testid="action-cell-2" style="height: 120px;">下单</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function attachMarketVueState(
|
|
marketList: Array<{ attribute_datas?: { nickname?: string }; star_id?: string }>
|
|
) {
|
|
const marketRoot = document.querySelector(".base-author-list");
|
|
if (!(marketRoot instanceof HTMLElement)) {
|
|
throw new Error("Expected market root");
|
|
}
|
|
|
|
Object.defineProperty(marketRoot, "__vue__", {
|
|
configurable: true,
|
|
value: {
|
|
_setupState: {
|
|
__$temp_1: {
|
|
marketList
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function readRightHeaderTexts() {
|
|
return Array.from(
|
|
document.querySelectorAll('[data-testid="right-header"] > *'),
|
|
(cell) => cell.textContent?.trim() ?? ""
|
|
);
|
|
}
|
|
|
|
function readRightRowTexts(rowIndex: number) {
|
|
return Array.from(
|
|
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
|
|
(column) =>
|
|
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
|
|
);
|
|
}
|
|
|
|
function readAuthorNames() {
|
|
return Array.from(
|
|
document.querySelectorAll('[data-testid="author-section"] .content-cell a'),
|
|
(link) => link.textContent?.trim() ?? ""
|
|
);
|
|
}
|
|
|
|
function readAuthorRowHiddenStates() {
|
|
return Array.from(
|
|
document.querySelectorAll('[data-testid^="author-cell-"]'),
|
|
(cell) => (cell as HTMLElement).hidden
|
|
);
|
|
}
|
|
|
|
function readRightActionHiddenStates() {
|
|
return Array.from(
|
|
document.querySelectorAll('[data-testid^="action-cell-"]'),
|
|
(cell) => (cell as HTMLElement).hidden
|
|
);
|
|
}
|