ui_utils_spec.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. /* Copyright 2017 Mozilla Foundation
  2. *
  3. * Licensed under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License.
  5. * You may obtain a copy of the License at
  6. *
  7. * http://www.apache.org/licenses/LICENSE-2.0
  8. *
  9. * Unless required by applicable law or agreed to in writing, software
  10. * distributed under the License is distributed on an "AS IS" BASIS,
  11. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. * See the License for the specific language governing permissions and
  13. * limitations under the License.
  14. */
  15. import {
  16. backtrackBeforeAllVisibleElements,
  17. binarySearchFirstItem,
  18. getPageSizeInches,
  19. getVisibleElements,
  20. isPortraitOrientation,
  21. isValidRotation,
  22. parseQueryString,
  23. removeNullCharacters,
  24. } from "../../web/ui_utils.js";
  25. describe("ui_utils", function () {
  26. describe("binary search", function () {
  27. function isTrue(boolean) {
  28. return boolean;
  29. }
  30. function isGreater3(number) {
  31. return number > 3;
  32. }
  33. it("empty array", function () {
  34. expect(binarySearchFirstItem([], isTrue)).toEqual(0);
  35. });
  36. it("single boolean entry", function () {
  37. expect(binarySearchFirstItem([false], isTrue)).toEqual(1);
  38. expect(binarySearchFirstItem([true], isTrue)).toEqual(0);
  39. });
  40. it("three boolean entries", function () {
  41. expect(binarySearchFirstItem([true, true, true], isTrue)).toEqual(0);
  42. expect(binarySearchFirstItem([false, true, true], isTrue)).toEqual(1);
  43. expect(binarySearchFirstItem([false, false, true], isTrue)).toEqual(2);
  44. expect(binarySearchFirstItem([false, false, false], isTrue)).toEqual(3);
  45. });
  46. it("three numeric entries", function () {
  47. expect(binarySearchFirstItem([0, 1, 2], isGreater3)).toEqual(3);
  48. expect(binarySearchFirstItem([2, 3, 4], isGreater3)).toEqual(2);
  49. expect(binarySearchFirstItem([4, 5, 6], isGreater3)).toEqual(0);
  50. });
  51. it("three numeric entries and a start index", function () {
  52. expect(binarySearchFirstItem([0, 1, 2, 3, 4], isGreater3, 2)).toEqual(4);
  53. expect(binarySearchFirstItem([2, 3, 4], isGreater3, 2)).toEqual(2);
  54. expect(binarySearchFirstItem([4, 5, 6], isGreater3, 1)).toEqual(1);
  55. });
  56. });
  57. describe("isValidRotation", function () {
  58. it("should reject non-integer angles", function () {
  59. expect(isValidRotation()).toEqual(false);
  60. expect(isValidRotation(null)).toEqual(false);
  61. expect(isValidRotation(NaN)).toEqual(false);
  62. expect(isValidRotation([90])).toEqual(false);
  63. expect(isValidRotation("90")).toEqual(false);
  64. expect(isValidRotation(90.5)).toEqual(false);
  65. });
  66. it("should reject non-multiple of 90 degree angles", function () {
  67. expect(isValidRotation(45)).toEqual(false);
  68. expect(isValidRotation(-123)).toEqual(false);
  69. });
  70. it("should accept valid angles", function () {
  71. expect(isValidRotation(0)).toEqual(true);
  72. expect(isValidRotation(90)).toEqual(true);
  73. expect(isValidRotation(-270)).toEqual(true);
  74. expect(isValidRotation(540)).toEqual(true);
  75. });
  76. });
  77. describe("isPortraitOrientation", function () {
  78. it("should be portrait orientation", function () {
  79. expect(
  80. isPortraitOrientation({
  81. width: 200,
  82. height: 400,
  83. })
  84. ).toEqual(true);
  85. expect(
  86. isPortraitOrientation({
  87. width: 500,
  88. height: 500,
  89. })
  90. ).toEqual(true);
  91. });
  92. it("should be landscape orientation", function () {
  93. expect(
  94. isPortraitOrientation({
  95. width: 600,
  96. height: 300,
  97. })
  98. ).toEqual(false);
  99. });
  100. });
  101. describe("parseQueryString", function () {
  102. it("should parse one key/value pair", function () {
  103. const parameters = parseQueryString("key1=value1");
  104. expect(parameters.size).toEqual(1);
  105. expect(parameters.get("key1")).toEqual("value1");
  106. });
  107. it("should parse multiple key/value pairs", function () {
  108. const parameters = parseQueryString(
  109. "key1=value1&key2=value2&key3=value3"
  110. );
  111. expect(parameters.size).toEqual(3);
  112. expect(parameters.get("key1")).toEqual("value1");
  113. expect(parameters.get("key2")).toEqual("value2");
  114. expect(parameters.get("key3")).toEqual("value3");
  115. });
  116. it("should parse keys without values", function () {
  117. const parameters = parseQueryString("key1");
  118. expect(parameters.size).toEqual(1);
  119. expect(parameters.get("key1")).toEqual("");
  120. });
  121. it("should decode encoded key/value pairs", function () {
  122. const parameters = parseQueryString("k%C3%ABy1=valu%C3%AB1");
  123. expect(parameters.size).toEqual(1);
  124. expect(parameters.get("këy1")).toEqual("valuë1");
  125. });
  126. it("should convert keys to lowercase", function () {
  127. const parameters = parseQueryString("Key1=Value1&KEY2=Value2");
  128. expect(parameters.size).toEqual(2);
  129. expect(parameters.get("key1")).toEqual("Value1");
  130. expect(parameters.get("key2")).toEqual("Value2");
  131. });
  132. });
  133. describe("removeNullCharacters", function () {
  134. it("should not modify string without null characters", function () {
  135. const str = "string without null chars";
  136. expect(removeNullCharacters(str)).toEqual("string without null chars");
  137. });
  138. it("should modify string with null characters", function () {
  139. const str = "string\x00With\x00Null\x00Chars";
  140. expect(removeNullCharacters(str)).toEqual("stringWithNullChars");
  141. });
  142. it("should modify string with non-displayable characters", function () {
  143. const str = Array.from(Array(32).keys())
  144. .map(x => String.fromCharCode(x) + "a")
  145. .join("");
  146. // \x00 is replaced by an empty string.
  147. const expected =
  148. "a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a a";
  149. expect(removeNullCharacters(str, /* replaceInvisible */ true)).toEqual(
  150. expected
  151. );
  152. });
  153. });
  154. describe("getPageSizeInches", function () {
  155. it("gets page size (in inches)", function () {
  156. const page = {
  157. view: [0, 0, 595.28, 841.89],
  158. userUnit: 1.0,
  159. rotate: 0,
  160. };
  161. const { width, height } = getPageSizeInches(page);
  162. expect(+width.toPrecision(3)).toEqual(8.27);
  163. expect(+height.toPrecision(4)).toEqual(11.69);
  164. });
  165. it("gets page size (in inches), for non-default /Rotate entry", function () {
  166. const pdfPage1 = { view: [0, 0, 612, 792], userUnit: 1, rotate: 0 };
  167. const { width: width1, height: height1 } = getPageSizeInches(pdfPage1);
  168. expect(width1).toEqual(8.5);
  169. expect(height1).toEqual(11);
  170. const pdfPage2 = { view: [0, 0, 612, 792], userUnit: 1, rotate: 90 };
  171. const { width: width2, height: height2 } = getPageSizeInches(pdfPage2);
  172. expect(width2).toEqual(11);
  173. expect(height2).toEqual(8.5);
  174. });
  175. });
  176. describe("getVisibleElements", function () {
  177. // These values are based on margin/border values in the CSS, but there
  178. // isn't any real need for them to be; they just need to take *some* value.
  179. const BORDER_WIDTH = 9;
  180. const SPACING = 2 * BORDER_WIDTH - 7;
  181. // This is a helper function for assembling an array of view stubs from an
  182. // array of arrays of [width, height] pairs, which represents wrapped lines
  183. // of pages. It uses the above constants to add realistic spacing between
  184. // the pages and the lines.
  185. //
  186. // If you're reading a test that calls makePages, you should think of the
  187. // inputs to makePages as boxes with no borders, being laid out in a
  188. // container that has no margins, so that the top of the tallest page in
  189. // the first row will be at y = 0, and the left of the first page in
  190. // the first row will be at x = 0. The spacing between pages in a row, and
  191. // the spacing between rows, is SPACING. If you wanted to construct an
  192. // actual HTML document with the same layout, you should give each page
  193. // element a margin-right and margin-bottom of SPACING, and add no other
  194. // margins, borders, or padding.
  195. //
  196. // If you're reading makePages itself, you'll see a somewhat more
  197. // complicated picture because this suite of tests is exercising
  198. // getVisibleElements' ability to account for the borders that real page
  199. // elements have. makePages tests this by subtracting a BORDER_WIDTH from
  200. // offsetLeft/Top and adding it to clientLeft/Top. So the element stubs that
  201. // getVisibleElements sees may, for example, actually have an offsetTop of
  202. // -9. If everything is working correctly, this detail won't leak out into
  203. // the tests themselves, and so the tests shouldn't use the value of
  204. // BORDER_WIDTH at all.
  205. function makePages(lines) {
  206. const result = [];
  207. let lineTop = 0,
  208. id = 0;
  209. for (const line of lines) {
  210. const lineHeight = line.reduce(function (maxHeight, pair) {
  211. return Math.max(maxHeight, pair[1]);
  212. }, 0);
  213. let offsetLeft = -BORDER_WIDTH;
  214. for (const [clientWidth, clientHeight] of line) {
  215. const offsetTop =
  216. lineTop + (lineHeight - clientHeight) / 2 - BORDER_WIDTH;
  217. const div = {
  218. offsetLeft,
  219. offsetTop,
  220. clientWidth,
  221. clientHeight,
  222. clientLeft: BORDER_WIDTH,
  223. clientTop: BORDER_WIDTH,
  224. };
  225. result.push({ id, div });
  226. ++id;
  227. offsetLeft += clientWidth + SPACING;
  228. }
  229. lineTop += lineHeight + SPACING;
  230. }
  231. return result;
  232. }
  233. // This is a reimplementation of getVisibleElements without the
  234. // optimizations.
  235. function slowGetVisibleElements(scroll, pages) {
  236. const views = [],
  237. ids = new Set();
  238. const { scrollLeft, scrollTop } = scroll;
  239. const scrollRight = scrollLeft + scroll.clientWidth;
  240. const scrollBottom = scrollTop + scroll.clientHeight;
  241. for (const view of pages) {
  242. const { div } = view;
  243. const viewLeft = div.offsetLeft + div.clientLeft;
  244. const viewRight = viewLeft + div.clientWidth;
  245. const viewTop = div.offsetTop + div.clientTop;
  246. const viewBottom = viewTop + div.clientHeight;
  247. if (
  248. viewLeft < scrollRight &&
  249. viewRight > scrollLeft &&
  250. viewTop < scrollBottom &&
  251. viewBottom > scrollTop
  252. ) {
  253. const hiddenHeight =
  254. Math.max(0, scrollTop - viewTop) +
  255. Math.max(0, viewBottom - scrollBottom);
  256. const hiddenWidth =
  257. Math.max(0, scrollLeft - viewLeft) +
  258. Math.max(0, viewRight - scrollRight);
  259. const fractionHeight =
  260. (div.clientHeight - hiddenHeight) / div.clientHeight;
  261. const fractionWidth =
  262. (div.clientWidth - hiddenWidth) / div.clientWidth;
  263. const percent = (fractionHeight * fractionWidth * 100) | 0;
  264. views.push({
  265. id: view.id,
  266. x: viewLeft,
  267. y: viewTop,
  268. view,
  269. percent,
  270. widthPercent: (fractionWidth * 100) | 0,
  271. });
  272. ids.add(view.id);
  273. }
  274. }
  275. return { first: views[0], last: views.at(-1), views, ids };
  276. }
  277. // This function takes a fixed layout of pages and compares the system under
  278. // test to the slower implementation above, for a range of scroll viewport
  279. // sizes and positions.
  280. function scrollOverDocument(pages, horizontal = false, rtl = false) {
  281. const size = pages.reduce(function (max, { div }) {
  282. return Math.max(
  283. max,
  284. horizontal
  285. ? Math.abs(div.offsetLeft + div.clientLeft + div.clientWidth)
  286. : div.offsetTop + div.clientTop + div.clientHeight
  287. );
  288. }, 0);
  289. // The numbers (7 and 5) are mostly arbitrary, not magic: increase them to
  290. // make scrollOverDocument tests faster, decrease them to make the tests
  291. // more scrupulous, and keep them coprime to reduce the chance of missing
  292. // weird edge case bugs.
  293. for (let i = -size; i < size; i += 7) {
  294. // The screen height (or width) here (j - i) doubles on each inner loop
  295. // iteration; again, this is just to test an interesting range of cases
  296. // without slowing the tests down to check every possible case.
  297. for (let j = i + 5; j < size; j += j - i) {
  298. const scrollEl = horizontal
  299. ? {
  300. scrollTop: 0,
  301. scrollLeft: i,
  302. clientHeight: 10000,
  303. clientWidth: j - i,
  304. }
  305. : {
  306. scrollTop: i,
  307. scrollLeft: 0,
  308. clientHeight: j - i,
  309. clientWidth: 10000,
  310. };
  311. expect(
  312. getVisibleElements({
  313. scrollEl,
  314. views: pages,
  315. sortByVisibility: false,
  316. horizontal,
  317. rtl,
  318. })
  319. ).toEqual(slowGetVisibleElements(scrollEl, pages));
  320. }
  321. }
  322. }
  323. it("with pages of varying height", function () {
  324. const pages = makePages([
  325. [
  326. [50, 20],
  327. [20, 50],
  328. ],
  329. [
  330. [30, 12],
  331. [12, 30],
  332. ],
  333. [
  334. [20, 50],
  335. [50, 20],
  336. ],
  337. [
  338. [50, 20],
  339. [20, 50],
  340. ],
  341. ]);
  342. scrollOverDocument(pages);
  343. });
  344. it("widescreen challenge", function () {
  345. const pages = makePages([
  346. [
  347. [10, 50],
  348. [10, 60],
  349. [10, 70],
  350. [10, 80],
  351. [10, 90],
  352. ],
  353. [
  354. [10, 90],
  355. [10, 80],
  356. [10, 70],
  357. [10, 60],
  358. [10, 50],
  359. ],
  360. [
  361. [10, 50],
  362. [10, 60],
  363. [10, 70],
  364. [10, 80],
  365. [10, 90],
  366. ],
  367. ]);
  368. scrollOverDocument(pages);
  369. });
  370. it("works with horizontal scrolling", function () {
  371. const pages = makePages([
  372. [
  373. [10, 50],
  374. [20, 20],
  375. [30, 10],
  376. ],
  377. ]);
  378. scrollOverDocument(pages, /* horizontal = */ true);
  379. });
  380. it("works with horizontal scrolling with RTL-documents", function () {
  381. const pages = makePages([
  382. [
  383. [-10, 50],
  384. [-20, 20],
  385. [-30, 10],
  386. ],
  387. ]);
  388. scrollOverDocument(pages, /* horizontal = */ true, /* rtl = */ true);
  389. });
  390. it("handles `sortByVisibility` correctly", function () {
  391. const scrollEl = {
  392. scrollTop: 75,
  393. scrollLeft: 0,
  394. clientHeight: 750,
  395. clientWidth: 1500,
  396. };
  397. const views = makePages([[[100, 150]], [[100, 150]], [[100, 150]]]);
  398. const visible = getVisibleElements({ scrollEl, views });
  399. const visibleSorted = getVisibleElements({
  400. scrollEl,
  401. views,
  402. sortByVisibility: true,
  403. });
  404. const viewsOrder = [],
  405. viewsSortedOrder = [];
  406. for (const view of visible.views) {
  407. viewsOrder.push(view.id);
  408. }
  409. for (const view of visibleSorted.views) {
  410. viewsSortedOrder.push(view.id);
  411. }
  412. expect(viewsOrder).toEqual([0, 1, 2]);
  413. expect(viewsSortedOrder).toEqual([1, 2, 0]);
  414. });
  415. it("handles views being empty", function () {
  416. const scrollEl = {
  417. scrollTop: 10,
  418. scrollLeft: 0,
  419. clientHeight: 750,
  420. clientWidth: 1500,
  421. };
  422. const views = [];
  423. expect(getVisibleElements({ scrollEl, views })).toEqual({
  424. first: undefined,
  425. last: undefined,
  426. views: [],
  427. ids: new Set(),
  428. });
  429. });
  430. it("handles all views being hidden (without errors)", function () {
  431. const scrollEl = {
  432. scrollTop: 100000,
  433. scrollLeft: 0,
  434. clientHeight: 750,
  435. clientWidth: 1500,
  436. };
  437. const views = makePages([[[100, 150]], [[100, 150]], [[100, 150]]]);
  438. expect(getVisibleElements({ scrollEl, views })).toEqual({
  439. first: undefined,
  440. last: undefined,
  441. views: [],
  442. ids: new Set(),
  443. });
  444. });
  445. // This sub-suite is for a notionally internal helper function for
  446. // getVisibleElements.
  447. describe("backtrackBeforeAllVisibleElements", function () {
  448. // Layout elements common to all tests
  449. const tallPage = [10, 50];
  450. const shortPage = [10, 10];
  451. // A scroll position that ensures that only the tall pages in the second
  452. // row are visible
  453. const top1 =
  454. 20 +
  455. SPACING + // height of the first row
  456. 40; // a value between 30 (so the short pages on the second row are
  457. // hidden) and 50 (so the tall pages are visible)
  458. // A scroll position that ensures that all of the pages in the second row
  459. // are visible, but the tall ones are a tiny bit cut off
  460. const top2 =
  461. 20 +
  462. SPACING + // height of the first row
  463. 10; // a value greater than 0 but less than 30
  464. // These tests refer to cases enumerated in the comments of
  465. // backtrackBeforeAllVisibleElements.
  466. it("handles case 1", function () {
  467. const pages = makePages([
  468. [
  469. [10, 20],
  470. [10, 20],
  471. [10, 20],
  472. [10, 20],
  473. ],
  474. [tallPage, shortPage, tallPage, shortPage],
  475. [
  476. [10, 50],
  477. [10, 50],
  478. [10, 50],
  479. [10, 50],
  480. ],
  481. [
  482. [10, 20],
  483. [10, 20],
  484. [10, 20],
  485. [10, 20],
  486. ],
  487. [[10, 20]],
  488. ]);
  489. // binary search would land on the second row, first page
  490. const bsResult = 4;
  491. expect(
  492. backtrackBeforeAllVisibleElements(bsResult, pages, top1)
  493. ).toEqual(4);
  494. });
  495. it("handles case 2", function () {
  496. const pages = makePages([
  497. [
  498. [10, 20],
  499. [10, 20],
  500. [10, 20],
  501. [10, 20],
  502. ],
  503. [tallPage, shortPage, tallPage, tallPage],
  504. [
  505. [10, 50],
  506. [10, 50],
  507. [10, 50],
  508. [10, 50],
  509. ],
  510. [
  511. [10, 20],
  512. [10, 20],
  513. [10, 20],
  514. [10, 20],
  515. ],
  516. ]);
  517. // binary search would land on the second row, third page
  518. const bsResult = 6;
  519. expect(
  520. backtrackBeforeAllVisibleElements(bsResult, pages, top1)
  521. ).toEqual(4);
  522. });
  523. it("handles case 3", function () {
  524. const pages = makePages([
  525. [
  526. [10, 20],
  527. [10, 20],
  528. [10, 20],
  529. [10, 20],
  530. ],
  531. [tallPage, shortPage, tallPage, shortPage],
  532. [
  533. [10, 50],
  534. [10, 50],
  535. [10, 50],
  536. [10, 50],
  537. ],
  538. [
  539. [10, 20],
  540. [10, 20],
  541. [10, 20],
  542. [10, 20],
  543. ],
  544. ]);
  545. // binary search would land on the third row, first page
  546. const bsResult = 8;
  547. expect(
  548. backtrackBeforeAllVisibleElements(bsResult, pages, top1)
  549. ).toEqual(4);
  550. });
  551. it("handles case 4", function () {
  552. const pages = makePages([
  553. [
  554. [10, 20],
  555. [10, 20],
  556. [10, 20],
  557. [10, 20],
  558. ],
  559. [tallPage, shortPage, tallPage, shortPage],
  560. [
  561. [10, 50],
  562. [10, 50],
  563. [10, 50],
  564. [10, 50],
  565. ],
  566. [
  567. [10, 20],
  568. [10, 20],
  569. [10, 20],
  570. [10, 20],
  571. ],
  572. ]);
  573. // binary search would land on the second row, first page
  574. const bsResult = 4;
  575. expect(
  576. backtrackBeforeAllVisibleElements(bsResult, pages, top2)
  577. ).toEqual(4);
  578. });
  579. });
  580. });
  581. });