diff --git a/src/domain/webCaptureDiff.resources.test.ts b/src/domain/webCaptureDiff.resources.test.ts index f710f07279..f840b6308f 100644 --- a/src/domain/webCaptureDiff.resources.test.ts +++ b/src/domain/webCaptureDiff.resources.test.ts @@ -110,3 +110,47 @@ describe("web resource comparison identity and optional size", () => { }); }); }); + +it.each([ + [[128, 256], [128], [false, true], "unknown"], + [[128], [256], [true, true], "unknown"], + [[128], [128], [true, true], "unknown"], + [[128], [128, 256], [false, true], "changed"], + [[], [128], [false, true], "changed"], + [[128], [], [true, true], "unknown"], + [[128], [null], [false, true], "unknown"], +] as const)( + "uses directional resource coverage for %j vs %j (limited=%j): %s", + (leftSizes, rightSizes, [leftLimited, rightLimited], status) => { + const before = capture(leftSizes.map((size) => resource(size))); + const after = capture(rightSizes.map((size) => resource(size))); + if (leftLimited) before.completeness.truncated_sections.push("resources"); + if (rightLimited) after.completeness.truncated_sections.push("resources"); + for (const [left, right] of [ + [before, after], + [after, before], + ] as const) + expect(compareResources(left, right)).toMatchObject({ + status, + total_changes: status === "changed" ? 1 : 0, + changes: + status === "changed" + ? [ + { + identity: expect.stringMatching(/^resg_/u), + change: + left.resources.length === 0 + ? "added" + : right.resources.length === 0 + ? "removed" + : "modified", + }, + ] + : [], + reason: + status === "changed" + ? null + : expect.stringContaining("Resource inventory"), + }); + }, +); diff --git a/src/domain/webCaptureDiff.ts b/src/domain/webCaptureDiff.ts index b1e799267e..781fb30345 100644 --- a/src/domain/webCaptureDiff.ts +++ b/src/domain/webCaptureDiff.ts @@ -213,9 +213,8 @@ const compareWebCaptureDimensions = ( resources: compareResources( before.resources, after.resources, - sectionsComplete(before, ["resources"]) && - sectionsComplete(after, ["resources"]), - "Resource inventory or content-size evidence was incomplete in at least one observation.", + sectionsComplete(before, ["resources"]), + sectionsComplete(after, ["resources"]), ), network: networkDimension(before, after), metadata: compareAggregate( @@ -235,9 +234,12 @@ const compareWebCaptureDimensions = ( const compareResources = ( before: WebPageInspection["resources"], after: WebPageInspection["resources"], - complete: boolean, - reason: string, + beforeComplete: boolean, + afterComplete: boolean, ): Dimension => { + const reason = + "Resource inventory or content-size evidence was incomplete in at least one observation."; + const complete = beforeComplete && afterComplete; const left = resourceGroups(before); const right = resourceGroups(after); const changes: Change[] = []; @@ -246,12 +248,14 @@ const compareResources = ( const leftSizes = left.get(identity); const rightSizes = right.get(identity); if (leftSizes === undefined || rightSizes === undefined) { - if (!complete) unresolved = true; - else + const absenceProven = + leftSizes === undefined ? beforeComplete : afterComplete; + if (absenceProven) changes.push({ identity, change: leftSizes === undefined ? "added" : "removed", }); + else unresolved = true; continue; } // Stable inventories deduplicate identical observations. A null size also @@ -261,7 +265,12 @@ const compareResources = ( unresolved = true; continue; } - if (!sameResourceSizes(leftSizes, rightSizes)) + // A partial set can omit any size variant. Only a complete peer can + // contradict a retained variant, including when the other side is partial. + if ( + (beforeComplete && !resourceSizesContained(rightSizes, leftSizes)) || + (afterComplete && !resourceSizesContained(leftSizes, rightSizes)) + ) changes.push({ identity, change: "modified" }); } if (changes.length > 0) @@ -292,11 +301,10 @@ const resourceGroups = ( return groups; }; -const sameResourceSizes = ( - left: ReadonlySet, - right: ReadonlySet, -): boolean => - left.size === right.size && [...left].every((size) => right.has(size)); +const resourceSizesContained = ( + observed: ReadonlySet, + complete: ReadonlySet, +): boolean => [...observed].every((size) => complete.has(size)); type BodyShapeSources = { readonly request: boolean;