TestScheduler.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import { Observable } from '../Observable';
  2. import { ColdObservable } from './ColdObservable';
  3. import { HotObservable } from './HotObservable';
  4. import { SubscriptionLog } from './SubscriptionLog';
  5. import { VirtualTimeScheduler, VirtualAction } from '../scheduler/VirtualTimeScheduler';
  6. import { COMPLETE_NOTIFICATION, errorNotification, nextNotification } from '../NotificationFactories';
  7. import { dateTimestampProvider } from '../scheduler/dateTimestampProvider';
  8. import { performanceTimestampProvider } from '../scheduler/performanceTimestampProvider';
  9. import { animationFrameProvider } from '../scheduler/animationFrameProvider';
  10. import { immediateProvider } from '../scheduler/immediateProvider';
  11. import { intervalProvider } from '../scheduler/intervalProvider';
  12. import { timeoutProvider } from '../scheduler/timeoutProvider';
  13. const defaultMaxFrame = 750;
  14. export class TestScheduler extends VirtualTimeScheduler {
  15. constructor(assertDeepEqual) {
  16. super(VirtualAction, defaultMaxFrame);
  17. this.assertDeepEqual = assertDeepEqual;
  18. this.hotObservables = [];
  19. this.coldObservables = [];
  20. this.flushTests = [];
  21. this.runMode = false;
  22. }
  23. createTime(marbles) {
  24. const indexOf = this.runMode ? marbles.trim().indexOf('|') : marbles.indexOf('|');
  25. if (indexOf === -1) {
  26. throw new Error('marble diagram for time should have a completion marker "|"');
  27. }
  28. return indexOf * TestScheduler.frameTimeFactor;
  29. }
  30. createColdObservable(marbles, values, error) {
  31. if (marbles.indexOf('^') !== -1) {
  32. throw new Error('cold observable cannot have subscription offset "^"');
  33. }
  34. if (marbles.indexOf('!') !== -1) {
  35. throw new Error('cold observable cannot have unsubscription marker "!"');
  36. }
  37. const messages = TestScheduler.parseMarbles(marbles, values, error, undefined, this.runMode);
  38. const cold = new ColdObservable(messages, this);
  39. this.coldObservables.push(cold);
  40. return cold;
  41. }
  42. createHotObservable(marbles, values, error) {
  43. if (marbles.indexOf('!') !== -1) {
  44. throw new Error('hot observable cannot have unsubscription marker "!"');
  45. }
  46. const messages = TestScheduler.parseMarbles(marbles, values, error, undefined, this.runMode);
  47. const subject = new HotObservable(messages, this);
  48. this.hotObservables.push(subject);
  49. return subject;
  50. }
  51. materializeInnerObservable(observable, outerFrame) {
  52. const messages = [];
  53. observable.subscribe({
  54. next: (value) => {
  55. messages.push({ frame: this.frame - outerFrame, notification: nextNotification(value) });
  56. },
  57. error: (error) => {
  58. messages.push({ frame: this.frame - outerFrame, notification: errorNotification(error) });
  59. },
  60. complete: () => {
  61. messages.push({ frame: this.frame - outerFrame, notification: COMPLETE_NOTIFICATION });
  62. },
  63. });
  64. return messages;
  65. }
  66. expectObservable(observable, subscriptionMarbles = null) {
  67. const actual = [];
  68. const flushTest = { actual, ready: false };
  69. const subscriptionParsed = TestScheduler.parseMarblesAsSubscriptions(subscriptionMarbles, this.runMode);
  70. const subscriptionFrame = subscriptionParsed.subscribedFrame === Infinity ? 0 : subscriptionParsed.subscribedFrame;
  71. const unsubscriptionFrame = subscriptionParsed.unsubscribedFrame;
  72. let subscription;
  73. this.schedule(() => {
  74. subscription = observable.subscribe({
  75. next: (x) => {
  76. const value = x instanceof Observable ? this.materializeInnerObservable(x, this.frame) : x;
  77. actual.push({ frame: this.frame, notification: nextNotification(value) });
  78. },
  79. error: (error) => {
  80. actual.push({ frame: this.frame, notification: errorNotification(error) });
  81. },
  82. complete: () => {
  83. actual.push({ frame: this.frame, notification: COMPLETE_NOTIFICATION });
  84. },
  85. });
  86. }, subscriptionFrame);
  87. if (unsubscriptionFrame !== Infinity) {
  88. this.schedule(() => subscription.unsubscribe(), unsubscriptionFrame);
  89. }
  90. this.flushTests.push(flushTest);
  91. const { runMode } = this;
  92. return {
  93. toBe(marbles, values, errorValue) {
  94. flushTest.ready = true;
  95. flushTest.expected = TestScheduler.parseMarbles(marbles, values, errorValue, true, runMode);
  96. },
  97. toEqual: (other) => {
  98. flushTest.ready = true;
  99. flushTest.expected = [];
  100. this.schedule(() => {
  101. subscription = other.subscribe({
  102. next: (x) => {
  103. const value = x instanceof Observable ? this.materializeInnerObservable(x, this.frame) : x;
  104. flushTest.expected.push({ frame: this.frame, notification: nextNotification(value) });
  105. },
  106. error: (error) => {
  107. flushTest.expected.push({ frame: this.frame, notification: errorNotification(error) });
  108. },
  109. complete: () => {
  110. flushTest.expected.push({ frame: this.frame, notification: COMPLETE_NOTIFICATION });
  111. },
  112. });
  113. }, subscriptionFrame);
  114. },
  115. };
  116. }
  117. expectSubscriptions(actualSubscriptionLogs) {
  118. const flushTest = { actual: actualSubscriptionLogs, ready: false };
  119. this.flushTests.push(flushTest);
  120. const { runMode } = this;
  121. return {
  122. toBe(marblesOrMarblesArray) {
  123. const marblesArray = typeof marblesOrMarblesArray === 'string' ? [marblesOrMarblesArray] : marblesOrMarblesArray;
  124. flushTest.ready = true;
  125. flushTest.expected = marblesArray
  126. .map((marbles) => TestScheduler.parseMarblesAsSubscriptions(marbles, runMode))
  127. .filter((marbles) => marbles.subscribedFrame !== Infinity);
  128. },
  129. };
  130. }
  131. flush() {
  132. const hotObservables = this.hotObservables;
  133. while (hotObservables.length > 0) {
  134. hotObservables.shift().setup();
  135. }
  136. super.flush();
  137. this.flushTests = this.flushTests.filter((test) => {
  138. if (test.ready) {
  139. this.assertDeepEqual(test.actual, test.expected);
  140. return false;
  141. }
  142. return true;
  143. });
  144. }
  145. static parseMarblesAsSubscriptions(marbles, runMode = false) {
  146. if (typeof marbles !== 'string') {
  147. return new SubscriptionLog(Infinity);
  148. }
  149. const characters = [...marbles];
  150. const len = characters.length;
  151. let groupStart = -1;
  152. let subscriptionFrame = Infinity;
  153. let unsubscriptionFrame = Infinity;
  154. let frame = 0;
  155. for (let i = 0; i < len; i++) {
  156. let nextFrame = frame;
  157. const advanceFrameBy = (count) => {
  158. nextFrame += count * this.frameTimeFactor;
  159. };
  160. const c = characters[i];
  161. switch (c) {
  162. case ' ':
  163. if (!runMode) {
  164. advanceFrameBy(1);
  165. }
  166. break;
  167. case '-':
  168. advanceFrameBy(1);
  169. break;
  170. case '(':
  171. groupStart = frame;
  172. advanceFrameBy(1);
  173. break;
  174. case ')':
  175. groupStart = -1;
  176. advanceFrameBy(1);
  177. break;
  178. case '^':
  179. if (subscriptionFrame !== Infinity) {
  180. throw new Error("found a second subscription point '^' in a " + 'subscription marble diagram. There can only be one.');
  181. }
  182. subscriptionFrame = groupStart > -1 ? groupStart : frame;
  183. advanceFrameBy(1);
  184. break;
  185. case '!':
  186. if (unsubscriptionFrame !== Infinity) {
  187. throw new Error("found a second unsubscription point '!' in a " + 'subscription marble diagram. There can only be one.');
  188. }
  189. unsubscriptionFrame = groupStart > -1 ? groupStart : frame;
  190. break;
  191. default:
  192. if (runMode && c.match(/^[0-9]$/)) {
  193. if (i === 0 || characters[i - 1] === ' ') {
  194. const buffer = characters.slice(i).join('');
  195. const match = buffer.match(/^([0-9]+(?:\.[0-9]+)?)(ms|s|m) /);
  196. if (match) {
  197. i += match[0].length - 1;
  198. const duration = parseFloat(match[1]);
  199. const unit = match[2];
  200. let durationInMs;
  201. switch (unit) {
  202. case 'ms':
  203. durationInMs = duration;
  204. break;
  205. case 's':
  206. durationInMs = duration * 1000;
  207. break;
  208. case 'm':
  209. durationInMs = duration * 1000 * 60;
  210. break;
  211. default:
  212. break;
  213. }
  214. advanceFrameBy(durationInMs / this.frameTimeFactor);
  215. break;
  216. }
  217. }
  218. }
  219. throw new Error("there can only be '^' and '!' markers in a " + "subscription marble diagram. Found instead '" + c + "'.");
  220. }
  221. frame = nextFrame;
  222. }
  223. if (unsubscriptionFrame < 0) {
  224. return new SubscriptionLog(subscriptionFrame);
  225. }
  226. else {
  227. return new SubscriptionLog(subscriptionFrame, unsubscriptionFrame);
  228. }
  229. }
  230. static parseMarbles(marbles, values, errorValue, materializeInnerObservables = false, runMode = false) {
  231. if (marbles.indexOf('!') !== -1) {
  232. throw new Error('conventional marble diagrams cannot have the ' + 'unsubscription marker "!"');
  233. }
  234. const characters = [...marbles];
  235. const len = characters.length;
  236. const testMessages = [];
  237. const subIndex = runMode ? marbles.replace(/^[ ]+/, '').indexOf('^') : marbles.indexOf('^');
  238. let frame = subIndex === -1 ? 0 : subIndex * -this.frameTimeFactor;
  239. const getValue = typeof values !== 'object'
  240. ? (x) => x
  241. : (x) => {
  242. if (materializeInnerObservables && values[x] instanceof ColdObservable) {
  243. return values[x].messages;
  244. }
  245. return values[x];
  246. };
  247. let groupStart = -1;
  248. for (let i = 0; i < len; i++) {
  249. let nextFrame = frame;
  250. const advanceFrameBy = (count) => {
  251. nextFrame += count * this.frameTimeFactor;
  252. };
  253. let notification;
  254. const c = characters[i];
  255. switch (c) {
  256. case ' ':
  257. if (!runMode) {
  258. advanceFrameBy(1);
  259. }
  260. break;
  261. case '-':
  262. advanceFrameBy(1);
  263. break;
  264. case '(':
  265. groupStart = frame;
  266. advanceFrameBy(1);
  267. break;
  268. case ')':
  269. groupStart = -1;
  270. advanceFrameBy(1);
  271. break;
  272. case '|':
  273. notification = COMPLETE_NOTIFICATION;
  274. advanceFrameBy(1);
  275. break;
  276. case '^':
  277. advanceFrameBy(1);
  278. break;
  279. case '#':
  280. notification = errorNotification(errorValue || 'error');
  281. advanceFrameBy(1);
  282. break;
  283. default:
  284. if (runMode && c.match(/^[0-9]$/)) {
  285. if (i === 0 || characters[i - 1] === ' ') {
  286. const buffer = characters.slice(i).join('');
  287. const match = buffer.match(/^([0-9]+(?:\.[0-9]+)?)(ms|s|m) /);
  288. if (match) {
  289. i += match[0].length - 1;
  290. const duration = parseFloat(match[1]);
  291. const unit = match[2];
  292. let durationInMs;
  293. switch (unit) {
  294. case 'ms':
  295. durationInMs = duration;
  296. break;
  297. case 's':
  298. durationInMs = duration * 1000;
  299. break;
  300. case 'm':
  301. durationInMs = duration * 1000 * 60;
  302. break;
  303. default:
  304. break;
  305. }
  306. advanceFrameBy(durationInMs / this.frameTimeFactor);
  307. break;
  308. }
  309. }
  310. }
  311. notification = nextNotification(getValue(c));
  312. advanceFrameBy(1);
  313. break;
  314. }
  315. if (notification) {
  316. testMessages.push({ frame: groupStart > -1 ? groupStart : frame, notification });
  317. }
  318. frame = nextFrame;
  319. }
  320. return testMessages;
  321. }
  322. createAnimator() {
  323. if (!this.runMode) {
  324. throw new Error('animate() must only be used in run mode');
  325. }
  326. let lastHandle = 0;
  327. let map;
  328. const delegate = {
  329. requestAnimationFrame(callback) {
  330. if (!map) {
  331. throw new Error('animate() was not called within run()');
  332. }
  333. const handle = ++lastHandle;
  334. map.set(handle, callback);
  335. return handle;
  336. },
  337. cancelAnimationFrame(handle) {
  338. if (!map) {
  339. throw new Error('animate() was not called within run()');
  340. }
  341. map.delete(handle);
  342. },
  343. };
  344. const animate = (marbles) => {
  345. if (map) {
  346. throw new Error('animate() must not be called more than once within run()');
  347. }
  348. if (/[|#]/.test(marbles)) {
  349. throw new Error('animate() must not complete or error');
  350. }
  351. map = new Map();
  352. const messages = TestScheduler.parseMarbles(marbles, undefined, undefined, undefined, true);
  353. for (const message of messages) {
  354. this.schedule(() => {
  355. const now = this.now();
  356. const callbacks = Array.from(map.values());
  357. map.clear();
  358. for (const callback of callbacks) {
  359. callback(now);
  360. }
  361. }, message.frame);
  362. }
  363. };
  364. return { animate, delegate };
  365. }
  366. createDelegates() {
  367. let lastHandle = 0;
  368. const scheduleLookup = new Map();
  369. const run = () => {
  370. const now = this.now();
  371. const scheduledRecords = Array.from(scheduleLookup.values());
  372. const scheduledRecordsDue = scheduledRecords.filter(({ due }) => due <= now);
  373. const dueImmediates = scheduledRecordsDue.filter(({ type }) => type === 'immediate');
  374. if (dueImmediates.length > 0) {
  375. const { handle, handler } = dueImmediates[0];
  376. scheduleLookup.delete(handle);
  377. handler();
  378. return;
  379. }
  380. const dueIntervals = scheduledRecordsDue.filter(({ type }) => type === 'interval');
  381. if (dueIntervals.length > 0) {
  382. const firstDueInterval = dueIntervals[0];
  383. const { duration, handler } = firstDueInterval;
  384. firstDueInterval.due = now + duration;
  385. firstDueInterval.subscription = this.schedule(run, duration);
  386. handler();
  387. return;
  388. }
  389. const dueTimeouts = scheduledRecordsDue.filter(({ type }) => type === 'timeout');
  390. if (dueTimeouts.length > 0) {
  391. const { handle, handler } = dueTimeouts[0];
  392. scheduleLookup.delete(handle);
  393. handler();
  394. return;
  395. }
  396. throw new Error('Expected a due immediate or interval');
  397. };
  398. const immediate = {
  399. setImmediate: (handler) => {
  400. const handle = ++lastHandle;
  401. scheduleLookup.set(handle, {
  402. due: this.now(),
  403. duration: 0,
  404. handle,
  405. handler,
  406. subscription: this.schedule(run, 0),
  407. type: 'immediate',
  408. });
  409. return handle;
  410. },
  411. clearImmediate: (handle) => {
  412. const value = scheduleLookup.get(handle);
  413. if (value) {
  414. value.subscription.unsubscribe();
  415. scheduleLookup.delete(handle);
  416. }
  417. },
  418. };
  419. const interval = {
  420. setInterval: (handler, duration = 0) => {
  421. const handle = ++lastHandle;
  422. scheduleLookup.set(handle, {
  423. due: this.now() + duration,
  424. duration,
  425. handle,
  426. handler,
  427. subscription: this.schedule(run, duration),
  428. type: 'interval',
  429. });
  430. return handle;
  431. },
  432. clearInterval: (handle) => {
  433. const value = scheduleLookup.get(handle);
  434. if (value) {
  435. value.subscription.unsubscribe();
  436. scheduleLookup.delete(handle);
  437. }
  438. },
  439. };
  440. const timeout = {
  441. setTimeout: (handler, duration = 0) => {
  442. const handle = ++lastHandle;
  443. scheduleLookup.set(handle, {
  444. due: this.now() + duration,
  445. duration,
  446. handle,
  447. handler,
  448. subscription: this.schedule(run, duration),
  449. type: 'timeout',
  450. });
  451. return handle;
  452. },
  453. clearTimeout: (handle) => {
  454. const value = scheduleLookup.get(handle);
  455. if (value) {
  456. value.subscription.unsubscribe();
  457. scheduleLookup.delete(handle);
  458. }
  459. },
  460. };
  461. return { immediate, interval, timeout };
  462. }
  463. run(callback) {
  464. const prevFrameTimeFactor = TestScheduler.frameTimeFactor;
  465. const prevMaxFrames = this.maxFrames;
  466. TestScheduler.frameTimeFactor = 1;
  467. this.maxFrames = Infinity;
  468. this.runMode = true;
  469. const animator = this.createAnimator();
  470. const delegates = this.createDelegates();
  471. animationFrameProvider.delegate = animator.delegate;
  472. dateTimestampProvider.delegate = this;
  473. immediateProvider.delegate = delegates.immediate;
  474. intervalProvider.delegate = delegates.interval;
  475. timeoutProvider.delegate = delegates.timeout;
  476. performanceTimestampProvider.delegate = this;
  477. const helpers = {
  478. cold: this.createColdObservable.bind(this),
  479. hot: this.createHotObservable.bind(this),
  480. flush: this.flush.bind(this),
  481. time: this.createTime.bind(this),
  482. expectObservable: this.expectObservable.bind(this),
  483. expectSubscriptions: this.expectSubscriptions.bind(this),
  484. animate: animator.animate,
  485. };
  486. try {
  487. const ret = callback(helpers);
  488. this.flush();
  489. return ret;
  490. }
  491. finally {
  492. TestScheduler.frameTimeFactor = prevFrameTimeFactor;
  493. this.maxFrames = prevMaxFrames;
  494. this.runMode = false;
  495. animationFrameProvider.delegate = undefined;
  496. dateTimestampProvider.delegate = undefined;
  497. immediateProvider.delegate = undefined;
  498. intervalProvider.delegate = undefined;
  499. timeoutProvider.delegate = undefined;
  500. performanceTimestampProvider.delegate = undefined;
  501. }
  502. }
  503. }
  504. TestScheduler.frameTimeFactor = 10;
  505. //# sourceMappingURL=TestScheduler.js.map