fix(downloads): restore actionable package states and extraction controls
Resolve child archive selections to complete multipart sets, support bulk package extraction, refresh extraction passwords at execution time, and keep archive failures scoped by their full paths. Restore sortable download columns, preserve verified availability, classify package retry and extraction states, secure link copying through the preload bridge, and release cancelled provider work without stale cooldowns.
This commit is contained in:
@@ -777,6 +777,36 @@ function findButton(node: ReactNode, label: string): ReactElement {
|
||||
return findElement(node, (element) => element.type === "button" && element.props.children === label);
|
||||
}
|
||||
|
||||
function dispatchColumnSortPointerGesture(header: ReactElement, label: string, clientXs: readonly number[], deliverPointerClick: boolean): void {
|
||||
const startX = clientXs[0];
|
||||
const endX = clientXs[clientXs.length - 1];
|
||||
if (startX === undefined || endX === undefined) throw new Error("Pointer gesture requires coordinates");
|
||||
const columnHeader = findElement(header, (element) => element.props["data-download-column"] === "name");
|
||||
const sortButton = findElement(columnHeader, (element) => element.type === "button" && String(element.props.children).startsWith(label));
|
||||
const capturedPointers = new Set<number>();
|
||||
const currentTarget = {
|
||||
closest: () => null,
|
||||
hasPointerCapture: (pointerId: number) => capturedPointers.has(pointerId),
|
||||
releasePointerCapture: (pointerId: number) => capturedPointers.delete(pointerId),
|
||||
setPointerCapture: (pointerId: number) => capturedPointers.add(pointerId)
|
||||
};
|
||||
const target = { closest: (selector: string) => selector === ".downloads-column-sort" ? {} : null };
|
||||
const event = (clientX: number) => ({
|
||||
button: 0,
|
||||
clientX,
|
||||
currentTarget,
|
||||
isPrimary: true,
|
||||
pointerId: 7,
|
||||
preventDefault: () => {},
|
||||
target
|
||||
});
|
||||
|
||||
columnHeader.props.onPointerDown(event(startX));
|
||||
clientXs.slice(1, -1).forEach((clientX) => columnHeader.props.onPointerMove(event(clientX)));
|
||||
columnHeader.props.onPointerUp(event(endX));
|
||||
if (deliverPointerClick) sortButton.props.onClick({ detail: 1 });
|
||||
}
|
||||
|
||||
function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsViewModel> = {}): DownloadsViewModel {
|
||||
return {
|
||||
...buildDownloadsViewModel(input),
|
||||
@@ -1523,6 +1553,9 @@ describe("download table row contracts", () => {
|
||||
expect(getAvailabilitySummary([
|
||||
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
|
||||
])).toEqual({ online: 0, total: 1, state: "checking" });
|
||||
expect(getAvailabilitySummary([
|
||||
item("active-a", "package-a", "downloading", { onlineStatus: undefined })
|
||||
])).toEqual({ online: 1, total: 1, state: "online" });
|
||||
});
|
||||
|
||||
it("shows reset package availability as one compact unchecked label", () => {
|
||||
@@ -1549,6 +1582,20 @@ describe("download table row contracts", () => {
|
||||
expect(html).not.toContain(">online</span>");
|
||||
});
|
||||
|
||||
it("shows an actively downloading item as online even without a stored availability result", () => {
|
||||
const html = renderToStaticMarkup(ItemRowContent({
|
||||
actions: createActions(),
|
||||
columnOrder: ["name", "availability"],
|
||||
gridTemplate: "200px 150px",
|
||||
item: item("active-availability", "package-a", "downloading", { onlineStatus: undefined }),
|
||||
selected: false
|
||||
}));
|
||||
|
||||
expect(html).toContain(">Online</span>");
|
||||
expect(html).not.toContain(">Ungeprüft</span>");
|
||||
expect(html).toContain('class="downloads-link-state online"');
|
||||
});
|
||||
|
||||
it("renders availability for package and file rows", () => {
|
||||
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
|
||||
const packageHtml = renderToStaticMarkup(PackageCardContent({
|
||||
@@ -1613,7 +1660,7 @@ describe("download table row contracts", () => {
|
||||
selectedVersion: 0
|
||||
}));
|
||||
|
||||
expect(html).toContain(">70%</b>");
|
||||
expect(html).toContain(">94%</b>");
|
||||
});
|
||||
|
||||
it("never exposes archive filenames as the visible package status", () => {
|
||||
@@ -1779,7 +1826,7 @@ describe("download table row contracts", () => {
|
||||
|
||||
expect(html).toMatch(/aria-sort="descending"[^>]*data-download-column="name"/);
|
||||
expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="size"/);
|
||||
expect(html).not.toMatch(/aria-sort="[^"]+"[^>]*data-download-column="account"/);
|
||||
expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="account"/);
|
||||
expect(moveLeft.props.type).toBe("button");
|
||||
expect(calls).toEqual([
|
||||
["down", "size", 250],
|
||||
@@ -1788,6 +1835,81 @@ describe("download table row contracts", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ clientXs: [100, 100], deliverPointerClick: false },
|
||||
{ clientXs: [100, 104, 104], deliverPointerClick: true }
|
||||
])("sorts exactly once when a captured pointer gesture stays below the drag threshold", ({ clientXs, deliverPointerClick }) => {
|
||||
const sorted: string[] = [];
|
||||
const header = DownloadsTableHeader({
|
||||
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
|
||||
columnOrder: ["name", "size"],
|
||||
gridTemplate: "200px 100px",
|
||||
selectedCount: 0,
|
||||
sortColumn: "name",
|
||||
sortDirection: "asc",
|
||||
visibleIds: []
|
||||
});
|
||||
|
||||
dispatchColumnSortPointerGesture(header, "Name", clientXs, deliverPointerClick);
|
||||
|
||||
expect(sorted).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("never sorts when a pointer gesture reaches the drag threshold", () => {
|
||||
const sorted: string[] = [];
|
||||
const header = DownloadsTableHeader({
|
||||
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
|
||||
columnOrder: ["name", "size"],
|
||||
gridTemplate: "200px 100px",
|
||||
selectedCount: 0,
|
||||
sortColumn: "name",
|
||||
sortDirection: "asc",
|
||||
visibleIds: []
|
||||
});
|
||||
|
||||
dispatchColumnSortPointerGesture(header, "Name", [100, 105, 101], true);
|
||||
|
||||
expect(sorted).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps sortable headers keyboard operable", () => {
|
||||
const sorted: string[] = [];
|
||||
const header = DownloadsTableHeader({
|
||||
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
|
||||
columnOrder: ["name", "size"],
|
||||
gridTemplate: "200px 100px",
|
||||
selectedCount: 0,
|
||||
sortColumn: "name",
|
||||
sortDirection: "asc",
|
||||
visibleIds: []
|
||||
});
|
||||
const sortButton = findElement(header, (element) => element.type === "button" && String(element.props.children).startsWith("Name"));
|
||||
|
||||
sortButton.props.onClick({ detail: 0 });
|
||||
|
||||
expect(sorted).toEqual(["name"]);
|
||||
});
|
||||
|
||||
it("exposes Service as a sortable column header", () => {
|
||||
const sorted: string[] = [];
|
||||
const header = DownloadsTableHeader({
|
||||
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
|
||||
columnOrder: ["account"],
|
||||
gridTemplate: "100px",
|
||||
selectedCount: 0,
|
||||
sortColumn: "service",
|
||||
sortDirection: "desc",
|
||||
visibleIds: []
|
||||
});
|
||||
const serviceHeader = findElement(header, (element) => element.props["data-download-column"] === "account");
|
||||
const sortButton = findElement(serviceHeader, (element) => element.type === "button");
|
||||
|
||||
sortButton.props.onClick({ detail: 0 });
|
||||
|
||||
expect(serviceHeader.props["aria-sort"]).toBe("descending");
|
||||
expect(sorted).toEqual(["service"]);
|
||||
});
|
||||
|
||||
it("opens the column menu without letting the same context event close it again", () => {
|
||||
const calls: Array<[string, number, number]> = [];
|
||||
const header = DownloadsTableHeader({
|
||||
@@ -2088,7 +2210,7 @@ describe("download table row contracts", () => {
|
||||
selectedVersion: 0
|
||||
}));
|
||||
|
||||
expect(html).toMatch(/title="0\/1 · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
|
||||
expect(html).toMatch(/title="0\/1 fertig · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
|
||||
});
|
||||
|
||||
it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => {
|
||||
@@ -2163,7 +2285,7 @@ describe("download table row contracts", () => {
|
||||
selectedIds: new Set<string>(),
|
||||
selectedVersion: 0
|
||||
}));
|
||||
expect(errorHtml).toMatch(/>Entpack-Fehler<\/span>/);
|
||||
expect(errorHtml).toMatch(/>Download fertig · 1 Entpackfehler<\/span>/);
|
||||
expect(errorHtml).not.toMatch(/>2\/2<\/span>/);
|
||||
});
|
||||
|
||||
@@ -2182,7 +2304,7 @@ describe("download table row contracts", () => {
|
||||
}));
|
||||
|
||||
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
|
||||
expect(html).toContain('title="0/1"');
|
||||
expect(html).toContain('title="0/1 fertig"');
|
||||
});
|
||||
|
||||
it("commits Enter and the resulting Blur rename sequence exactly once", () => {
|
||||
|
||||
Reference in New Issue
Block a user