| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview Tests for text_intersection_observer.ts. |
| */ |
| |
| import type {HTMLElementWithSymbolIndex, NodeWithSymbolIndex} from '//ios/web/annotations/resources/text_dom_utils.js'; |
| import type {InternalIntersectionObserver, TextNodeVisitor} from '//ios/web/annotations/resources/text_intersection_observer.js'; |
| import {observedNode, observedTextNodeCount, TextIntersectionObserver, visibleDescendantCount, visibleElement} from '//ios/web/annotations/resources/text_intersection_observer.js'; |
| import {IdleTaskTracker} from '//ios/web/annotations/resources/text_tasks.js'; |
| import {expectEq, expectNeq, FakeTaskTimer, load, TestSuite} from '//ios/web/annotations/resources/text_test_utils.js'; |
| |
| let currentObserver: FakeIntersectionObserver|null = null; |
| |
| class FakeIntersectionObserver implements InternalIntersectionObserver { |
| connected = false; |
| observed = new Set<Element>(); |
| |
| constructor( |
| public callback: IntersectionObserverCallback, |
| _options?: IntersectionObserverInit) { |
| currentObserver = this; |
| this.connected = true; |
| } |
| |
| disconnect(): void { |
| this.connected = false; |
| } |
| observe(target: Element): void { |
| this.observed.add(target); |
| } |
| unobserve(target: Element): void { |
| this.observed.delete(target); |
| } |
| |
| // Simulates viewport intersection hits on given `items`. |
| hits(items: Array<{target: Element, isIntersecting: boolean}>): void { |
| const entries: IntersectionObserverEntry[] = []; |
| for (const item of items) { |
| entries.push({ |
| boundingClientRect: {}, |
| intersectionRatio: item.isIntersecting ? 1 : 0, |
| intersectionRect: {}, |
| isIntersecting: item.isIntersecting, |
| rootBounds: null, |
| target: item.target, |
| time: 0, |
| } as unknown as IntersectionObserverEntry); |
| } |
| this.callback(entries, null as unknown as IntersectionObserver); |
| } |
| } |
| |
| export class TestTextIntersectionObserver extends TestSuite implements |
| TextNodeVisitor { |
| // Mark: TextNodeVisitor |
| |
| visibleText = ''; |
| invisibleNodes: Node[] = []; |
| invisibleNodeNames = ''; |
| flowState = 'idle'; |
| |
| begin() { |
| this.flowState += '-begin'; |
| } |
| visibleTextNode(textNode: Text): void { |
| this.visibleText += '+' + textNode.textContent; |
| } |
| invisibleNode(node: Node): void { |
| this.invisibleNodes.push(node); |
| this.invisibleNodeNames += ':' + node.nodeName; |
| } |
| enterVisibleNode(node: Node): void { |
| this.visibleText += '+<' + node.nodeName + '>'; |
| } |
| leaveVisibleNode(node: Node): void { |
| this.visibleText += '+</' + node.nodeName + '>'; |
| } |
| end(): void { |
| this.flowState += '-end'; |
| } |
| |
| // Mark: Tests |
| |
| timer = new FakeTaskTimer(); |
| tracker = new IdleTaskTracker(this.timer, 100, 50); |
| observer = new TextIntersectionObserver( |
| document.documentElement, this, this.tracker, FakeIntersectionObserver, |
| 50); |
| |
| override setUp(): void { |
| currentObserver = null; |
| this.timer.restart(); |
| this.visibleText = ''; |
| this.invisibleNodes.length = 0; |
| this.invisibleNodeNames = ''; |
| this.flowState = 'idle'; |
| this.observer.start(); |
| expectNeq(currentObserver, null); |
| } |
| |
| override tearDown(): void { |
| this.observer.stop(); |
| currentObserver = null; |
| } |
| |
| // TODO(crbug.com/40936184): add test for shadowRoot. |
| |
| // Tests the proper tagging of nodes depending on events from |
| // IntersectionObserver (the fake one above). also tests that the visiting of |
| // nodes after the given delay happened. |
| testTextIntersectionObserverFlow() { |
| load( |
| '<div id="d1">Hello</div>' + |
| '<div id="d2">Small</div>' + |
| '<div id="d3">World</div>'); |
| const html = document.documentElement as HTMLElementWithSymbolIndex; |
| const body = document.body as HTMLElementWithSymbolIndex; |
| const d1 = document.querySelector<HTMLElementWithSymbolIndex>('#d1')!; |
| const d2 = document.querySelector<HTMLElementWithSymbolIndex>('#d2')!; |
| const d3 = document.querySelector<HTMLElementWithSymbolIndex>('#d3')!; |
| |
| this.observer.observe(d1.childNodes[0]!); |
| this.observer.observe(d2.childNodes[0]!); |
| this.observer.observe(d3.childNodes[0]!); |
| expectEq(currentObserver?.observed.size, 3); |
| |
| expectEq(undefined, html[visibleDescendantCount]); |
| expectEq(undefined, body[visibleDescendantCount]); |
| expectEq(false, !!d1[visibleElement]); |
| expectEq(false, !!d2[visibleElement]); |
| expectEq(false, !!d3[visibleElement]); |
| expectEq(1, d1[observedTextNodeCount]); |
| expectEq(1, d2[observedTextNodeCount]); |
| expectEq(1, d3[observedTextNodeCount]); |
| expectEq(true, !!(d1.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| expectEq(true, !!(d2.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| expectEq(true, !!(d3.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| |
| // Make d2 visible. |
| currentObserver?.hits([{target: d2, isIntersecting: true}]); |
| expectEq(1, html[visibleDescendantCount]); |
| expectEq(1, body[visibleDescendantCount]); |
| expectEq(false, !!d1[visibleElement]); |
| expectEq(true, !!d2[visibleElement]); |
| expectEq(false, !!d3[visibleElement]); |
| expectEq(1, d1[observedTextNodeCount]); |
| expectEq(1, d2[observedTextNodeCount]); |
| expectEq(1, d3[observedTextNodeCount]); |
| expectEq(true, !!(d1.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| expectEq(true, !!(d2.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| expectEq(true, !!(d3.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| |
| this.timer.moveAhead(/* ms= */ 10, /* times= */ 6); // -> 60ms total |
| |
| // Check that the visit happened. |
| expectEq(this.visibleText, '+<BODY>+<DIV>+Small+</DIV>+</BODY>'); |
| expectEq(this.invisibleNodeNames, ':HEAD:DIV:DIV'); |
| expectEq(this.invisibleNodes[1], d1); |
| expectEq(this.invisibleNodes[2], d3); |
| expectEq(this.flowState, 'idle-begin-end'); |
| // d2 should not be observed anymore. |
| expectEq(currentObserver?.observed.size, 2); |
| expectEq(undefined, d2[observedTextNodeCount]); |
| expectEq(false, !!(d2.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| // And not visible. |
| expectEq(false, !!d2[visibleElement]); |
| expectEq(undefined, html[visibleDescendantCount]); |
| expectEq(undefined, body[visibleDescendantCount]); |
| } |
| |
| // Tests the proper tagging/untagging of nodes depending on events from |
| // IntersectionObserver when simulating a viewport scrolling down. |
| testTextIntersectionObserverScroll() { |
| load( |
| '<div id="d1">Hello</div>' + |
| '<div id="d2">Small</div>' + |
| '<div id="d3">World</div>'); |
| const html = document.documentElement as HTMLElementWithSymbolIndex; |
| const body = document.body as HTMLElementWithSymbolIndex; |
| const d1 = document.querySelector<HTMLElementWithSymbolIndex>('#d1')!; |
| const d2 = document.querySelector<HTMLElementWithSymbolIndex>('#d2')!; |
| const d3 = document.querySelector<HTMLElementWithSymbolIndex>('#d3')!; |
| this.observer.observe(d1.childNodes[0]!); |
| this.observer.observe(d2.childNodes[0]!); |
| this.observer.observe(d3.childNodes[0]!); |
| expectEq(currentObserver?.observed.size, 3); |
| |
| // Make d1 visible. |
| currentObserver?.hits([{target: d1, isIntersecting: true}]); |
| this.timer.moveAhead(/* ms= */ 10, /* times= */ 6); // -> 60ms total |
| // Check that the visit happened. |
| expectEq(this.visibleText, '+<BODY>+<DIV>+Hello+</DIV>+</BODY>'); |
| expectEq(this.invisibleNodeNames, ':HEAD:DIV:DIV'); |
| expectEq(this.invisibleNodes[1], d2); |
| expectEq(this.invisibleNodes[2], d3); |
| expectEq(this.flowState, 'idle-begin-end'); |
| |
| // Make d2 visible. |
| currentObserver?.hits([{target: d2, isIntersecting: true}]); |
| this.timer.moveAhead(/* ms= */ 10, /* times= */ 2); // -> 80ms total |
| // But before text extraction, make it invisible. |
| currentObserver?.hits([{target: d2, isIntersecting: false}]); |
| this.timer.moveAhead(/* ms= */ 10, /* times= */ 6); // -> 140ms total |
| expectEq(this.visibleText, '+<BODY>+<DIV>+Hello+</DIV>+</BODY>'); |
| expectEq(this.flowState, 'idle-begin-end-begin-end'); |
| |
| // Make d3 visible. |
| currentObserver?.hits([{target: d3, isIntersecting: true}]); |
| this.timer.moveAhead(/* ms= */ 10, /* times= */ 6); // -> 200ms total |
| expectEq( |
| this.visibleText, |
| '+<BODY>+<DIV>+Hello+</DIV>+</BODY>+<BODY>+<DIV>+World+</DIV>+</BODY>'); |
| expectEq(this.flowState, 'idle-begin-end-begin-end-begin-end'); |
| |
| // d1 and d3 should not be observed anymore, d2 should. |
| expectEq(undefined, html[visibleDescendantCount]); |
| expectEq(undefined, body[visibleDescendantCount]); |
| expectEq(false, !!d1[visibleElement]); |
| expectEq(false, !!d2[visibleElement]); |
| expectEq(false, !!d3[visibleElement]); |
| expectEq(undefined, d1[observedTextNodeCount]); |
| expectEq(1, d2[observedTextNodeCount]); |
| expectEq(undefined, d3[observedTextNodeCount]); |
| expectEq(false, !!(d1.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| expectEq(true, !!(d2.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| expectEq(false, !!(d3.childNodes[0] as NodeWithSymbolIndex)[observedNode]); |
| } |
| } |