bindtwostepcarettoattribute.js 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /* global document */
  6. import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
  7. import DomEmitterMixin from '@ckeditor/ckeditor5-utils/src/dom/emittermixin';
  8. import DomEventData from '../../src/view/observer/domeventdata';
  9. import EventInfo from '@ckeditor/ckeditor5-utils/src/eventinfo';
  10. import bindTwoStepCaretToAttribute, { TwoStepCaretHandler } from '../../src/utils/bindtwostepcarettoattribute';
  11. import Position from '../../src/model/position';
  12. import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
  13. import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
  14. import { setData } from '../../src/dev-utils/model';
  15. describe( 'bindTwoStepCaretToAttribute()', () => {
  16. let editor, model, emitter, selection, view, locale;
  17. let preventDefaultSpy, evtStopSpy;
  18. testUtils.createSinonSandbox();
  19. beforeEach( () => {
  20. emitter = Object.create( DomEmitterMixin );
  21. return VirtualTestEditor.create().then( newEditor => {
  22. editor = newEditor;
  23. model = editor.model;
  24. selection = model.document.selection;
  25. view = editor.editing.view;
  26. locale = editor.locale;
  27. preventDefaultSpy = sinon.spy();
  28. evtStopSpy = sinon.spy();
  29. editor.model.schema.extend( '$text', {
  30. allowAttributes: [ 'a', 'b', 'c' ],
  31. allowIn: '$root'
  32. } );
  33. model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
  34. editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'a', model: 'a' } );
  35. editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'b', model: 'b' } );
  36. editor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } );
  37. editor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  38. bindTwoStepCaretToAttribute( {
  39. view: editor.editing.view,
  40. model: editor.model,
  41. emitter,
  42. attribute: 'a',
  43. locale
  44. } );
  45. } );
  46. } );
  47. afterEach( () => {
  48. return editor.destroy();
  49. } );
  50. describe( 'moving right', () => {
  51. it( 'should do nothing for unrelated attribute (at the beginning)', () => {
  52. setData( model, '[]<$text c="true">foo</$text>' );
  53. testTwoStepCaretMovement( [
  54. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  55. '→',
  56. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  57. ] );
  58. } );
  59. it( 'should do nothing for unrelated attribute (at the end)', () => {
  60. setData( model, '<$text c="true">foo[]</$text>' );
  61. testTwoStepCaretMovement( [
  62. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  63. '→',
  64. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  65. ] );
  66. } );
  67. it( 'should "enter" the text with attribute in two steps', () => {
  68. setData( model, '<$text c="true">foo[]</$text><$text a="true" b="true">bar</$text>' );
  69. testTwoStepCaretMovement( [
  70. // Gravity is not overridden, caret is at the beginning of the text but is "outside" of the text.
  71. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  72. '→',
  73. // Gravity is overridden, caret movement is blocked, selection at the beginning but "inside" the text.
  74. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  75. '→',
  76. // Caret movement was not blocked this time (still once) so everything works normally.
  77. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  78. ] );
  79. } );
  80. it( 'should "leave" the text with attribute in two steps', () => {
  81. setData( model, '<$text a="true" b="true">bar[]</$text><$text c="true">foo</$text>' );
  82. testTwoStepCaretMovement( [
  83. // Gravity is not overridden, caret is at the end of the text but is "inside" of the text.
  84. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  85. '→',
  86. // Gravity is overridden, caret movement is blocked, selection at the end but "outside" the text.
  87. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  88. '→',
  89. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  90. ] );
  91. } );
  92. it( 'should use two-steps movement when between nodes with the same attribute but different value', () => {
  93. setData( model, '<$text a="1">bar[]</$text><$text a="2">foo</$text>' );
  94. testTwoStepCaretMovement( [
  95. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  96. '→',
  97. // <$text a="1">bar</$text>[]<$text a="2">foo</$text>
  98. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  99. '→',
  100. // <$text a="1">bar</$text><$text a="2">[]foo</$text>
  101. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  102. '→',
  103. // <$text a="1">bar</$text><$text a="2">f[]oo</$text>
  104. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  105. ] );
  106. } );
  107. // https://github.com/ckeditor/ckeditor5/issues/937
  108. it( 'should not require two-steps between unrelated attributes inside the initial attribute', () => {
  109. setData( model, '<$text a="1">fo[]o</$text><$text a="1" b="2">bar</$text><$text a="1">baz</$text>' );
  110. testTwoStepCaretMovement( [
  111. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  112. '→',
  113. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  114. '→',
  115. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  116. '→',
  117. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  118. '→',
  119. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  120. '→',
  121. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  122. ] );
  123. } );
  124. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  125. it( 'should handle passing through the only character in the block', () => {
  126. setData( model, '[]<$text a="1">x</$text>' );
  127. testTwoStepCaretMovement( [
  128. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  129. '→',
  130. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  131. '→',
  132. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  133. '→',
  134. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
  135. ] );
  136. } );
  137. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  138. it( 'should handle passing through the only character in the block (no attribute in the initial selection)', () => {
  139. setData( model, '[]<$text a="1">x</$text>' );
  140. model.change( writer => writer.removeSelectionAttribute( 'a' ) );
  141. testTwoStepCaretMovement( [
  142. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  143. '→',
  144. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  145. '→',
  146. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  147. '→',
  148. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  149. '→',
  150. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 }
  151. ] );
  152. } );
  153. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  154. it( 'should handle passing through the only-child with an attribute (multiple characters)', () => {
  155. setData( model, '[]<$text a="1">xyz</$text>' );
  156. testTwoStepCaretMovement( [
  157. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  158. '→',
  159. // <$text a="1">x{}yz</$text>
  160. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  161. '→',
  162. // <$text a="1">xy{}z</$text>
  163. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  164. '→',
  165. // <$text a="1">xyz{}</$text>
  166. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  167. '→',
  168. // <$text a="1">xyz</$text>{}
  169. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  170. '→',
  171. // <$text a="1">xyz</$text>{}
  172. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
  173. ] );
  174. } );
  175. it( 'should handle leaving an attribute followed by another block', () => {
  176. setData( model, '<paragraph><$text a="1">foo[]</$text></paragraph><paragraph><$text b="1">bar</$text></paragraph>' );
  177. testTwoStepCaretMovement( [
  178. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  179. '→',
  180. // <paragraph><$text a="1">bar</$text>[]</paragraph><paragraph>foo</paragraph>
  181. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  182. '→',
  183. // <paragraph><$text a="1">bar</$text></paragraph><paragraph>f[]oo</paragraph>
  184. { selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  185. '→',
  186. // <paragraph><$text a="1">bar</$text></paragraph><paragraph>fo[]o</paragraph>
  187. { selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  188. ] );
  189. } );
  190. } );
  191. describe( 'moving left', () => {
  192. it( 'should "enter" the text with attribute in two steps', () => {
  193. setData( model, '<$text>foo</$text><$text a="true" b="true">bar</$text><$text c="true">b[]iz</$text>' );
  194. testTwoStepCaretMovement( [
  195. // Gravity is not overridden, caret is a one character after the and of the text.
  196. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  197. '←',
  198. // Caret movement was not blocked but the gravity is overridden.
  199. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  200. '←',
  201. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  202. '←',
  203. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  204. ] );
  205. } );
  206. it( 'should "leave" the text with attribute in two steps', () => {
  207. setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
  208. testTwoStepCaretMovement( [
  209. // Gravity is not overridden, caret is a one character after the beginning of the text.
  210. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  211. '←',
  212. // Caret movement was not blocked.
  213. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  214. '←',
  215. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  216. '←',
  217. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  218. ] );
  219. } );
  220. it( 'should do nothing for unrelated attribute (at the beginning)', () => {
  221. setData( model, '<$text c="true">[]foo</$text>' );
  222. testTwoStepCaretMovement( [
  223. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  224. '←',
  225. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  226. '←',
  227. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  228. ] );
  229. } );
  230. it( 'should do nothing for unrelated attribute (at the end)', () => {
  231. setData( model, '<$text c="true">foo</$text>[]' );
  232. testTwoStepCaretMovement( [
  233. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  234. '←',
  235. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  236. '←',
  237. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  238. ] );
  239. } );
  240. it( 'should do nothing when caret is at the beginning of block element', () => {
  241. setData( model, '[]foo', { lastRangeBackward: true } );
  242. testTwoStepCaretMovement( [
  243. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  244. '←',
  245. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  246. ] );
  247. } );
  248. it( 'should require two-steps movement when caret goes between text node with the same attribute but different value', () => {
  249. setData( model, '<$text a="2">foo</$text><$text a="1">b[]ar</$text>' );
  250. testTwoStepCaretMovement( [
  251. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  252. '←',
  253. // <$text a="2">foo</$text><$text a="1">[]bar</$text>
  254. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  255. '←',
  256. // <$text a="2">foo</$text>[]<$text a="1">bar</$text>
  257. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  258. '←',
  259. // <$text a="2">foo[]</$text><$text a="1">bar</$text>
  260. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  261. '←',
  262. // <$text a="2">fo[]o</$text><$text a="1">bar</$text>
  263. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  264. ] );
  265. } );
  266. // https://github.com/ckeditor/ckeditor5/issues/937
  267. it( 'should not require two-steps between unrelated attributes inside the initial attribute', () => {
  268. setData( model, '<$text a="1">foo</$text><$text a="1" b="2">bar</$text><$text a="1">b[]az</$text>' );
  269. testTwoStepCaretMovement( [
  270. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  271. '←',
  272. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  273. '←',
  274. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  275. '←',
  276. { selectionAttributes: [ 'a', 'b' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  277. '←',
  278. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 }
  279. ] );
  280. } );
  281. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  282. it( 'should handle passing through the only-child with an attribute (single character)', () => {
  283. setData( model, '<$text a="1">x</$text>[]' );
  284. testTwoStepCaretMovement( [
  285. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  286. '←',
  287. // <$text a="1">{}x</$text>
  288. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  289. '←',
  290. // {}<$text a="1">x</$text> (because it's a first-child)
  291. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  292. '←',
  293. // {}<$text a="1">x</$text>
  294. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  295. ] );
  296. } );
  297. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  298. it( 'should handle passing through the only character in the block (no attribute in the initial selection)', () => {
  299. setData( model, '<$text a="1">x</$text>[]' );
  300. model.change( writer => writer.removeSelectionAttribute( 'a' ) );
  301. testTwoStepCaretMovement( [
  302. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  303. '←',
  304. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  305. '←',
  306. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  307. '←',
  308. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  309. '←',
  310. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  311. ] );
  312. } );
  313. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  314. it( 'should handle passing through the only-child with an attribute (single character, text before)', () => {
  315. setData( model, 'abc<$text a="1">x</$text>[]' );
  316. testTwoStepCaretMovement( [
  317. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  318. '←',
  319. // abc<$text a="1">{}x</$text>
  320. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  321. '←',
  322. // abc{}<$text a="1">x</$text> (because it's a first-child)
  323. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  324. '←',
  325. // abc{}<$text a="1">x</$text>
  326. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  327. ] );
  328. } );
  329. // https://github.com/ckeditor/ckeditor5-engine/issues/1301
  330. it( 'should handle passing through the only-child with an attribute (multiple characters)', () => {
  331. setData( model, '<$text a="1">xyz</$text>[]' );
  332. testTwoStepCaretMovement( [
  333. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  334. '←',
  335. // <$text a="1">xy{}z</$text>
  336. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  337. '←',
  338. // <$text a="1">x{}yz</$text>
  339. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  340. '←',
  341. // <$text a="1">{}xyz</$text>
  342. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  343. '←',
  344. // {}<$text a="1">xyz</$text>
  345. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  346. '←',
  347. // {}<$text a="1">xyz</$text>
  348. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  349. ] );
  350. } );
  351. it( 'should handle leaving an attribute preceded by another block', () => {
  352. setData( model, '<paragraph><$text b="1">foo</$text></paragraph><paragraph><$text a="1">[]bar</$text></paragraph>' );
  353. testTwoStepCaretMovement( [
  354. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  355. '←',
  356. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  357. '←',
  358. { selectionAttributes: [ 'b' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  359. ] );
  360. } );
  361. } );
  362. describe( 'moving and typing around the attribute', () => {
  363. it( 'should handle typing after the attribute', () => {
  364. setData( model, '<$text a="1">x[]</$text>' );
  365. testTwoStepCaretMovement( [
  366. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  367. 'y',
  368. // <$text a="1">xy[]</$text>
  369. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  370. '→',
  371. // <$text a="1">xy</$text>[]
  372. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  373. 'z',
  374. // <$text a="1">xy</$text>z[]
  375. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  376. '←',
  377. // <$text a="1">xy</$text>[]z
  378. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  379. '←',
  380. // <$text a="1">xy[]</$text>z
  381. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  382. 'w',
  383. // <$text a="1">xyw[]</$text>
  384. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  385. ] );
  386. } );
  387. it( 'should handle typing before the attribute', () => {
  388. setData( model, '<$text a="1">[]x</$text>' );
  389. testTwoStepCaretMovement( [
  390. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  391. '←',
  392. // []<$text a="1">x</$text>
  393. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  394. 'z',
  395. // z[]<$text a="1">x</$text>
  396. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  397. 'x',
  398. // zx[]<$text a="1">x</$text>
  399. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  400. '→',
  401. // zx<$text a="1">[]x</$text>
  402. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  403. 'a',
  404. // zx<$text a="1">a[]x</$text>
  405. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  406. ] );
  407. } );
  408. // https://github.com/ckeditor/ckeditor5-engine/issues/1346
  409. // https://github.com/ckeditor/ckeditor5/issues/946
  410. it( 'should correctly re-renter the attribute', () => {
  411. setData( model, 'fo[]o <$text a="1">bar</$text>' );
  412. testTwoStepCaretMovement( [
  413. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  414. '→',
  415. // foo[] <$text a="1">bar</$text>
  416. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  417. '→',
  418. // foo []<$text a="1">bar</$text>
  419. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  420. '→',
  421. // foo <$text a="1">[]bar</$text>
  422. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  423. '→',
  424. // foo <$text a="1">b[]ar</$text>
  425. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  426. '←',
  427. // foo <$text a="1">[]bar</$text>
  428. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  429. '←',
  430. // foo []<$text a="1">bar</$text>
  431. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  432. '←',
  433. // foo[] <$text a="1">bar</$text>
  434. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  435. '←',
  436. // fo[]o <$text a="1">bar</$text>
  437. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 }
  438. ] );
  439. } );
  440. // https://github.com/ckeditor/ckeditor5/issues/922
  441. it( 'should not lose the new attribute when typing (after)', () => {
  442. setData( model, '<$text a="1">x[]</$text>' );
  443. testTwoStepCaretMovement( [
  444. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  445. '→',
  446. // <$text a="1">x</$text>[]
  447. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
  448. ] );
  449. model.change( writer => {
  450. writer.setSelectionAttribute( 'b', 1 );
  451. } );
  452. // <$text a="1">x</$text><$text b="1">[]</$text>
  453. expect( selection.isGravityOverridden ).to.be.true;
  454. expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
  455. model.change( writer => {
  456. writer.insertText( 'yz', selection.getAttributes(), selection.getFirstPosition() );
  457. } );
  458. // <$text a="1">x</$text><$text b="1">yz[]</$text>
  459. expect( selection.isGravityOverridden ).to.be.false;
  460. expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
  461. } );
  462. // https://github.com/ckeditor/ckeditor5/issues/922
  463. it( 'should not lose the new attribute when typing (before)', () => {
  464. setData( model, '<$text a="1">[]x</$text>' );
  465. testTwoStepCaretMovement( [
  466. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  467. '←',
  468. // []<$text a="1">x</$text>
  469. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 }
  470. ] );
  471. model.change( writer => {
  472. writer.setSelectionAttribute( 'b', 1 );
  473. } );
  474. // <$text b="1">[]</$text><$text a="1">x</$text>
  475. expect( selection.isGravityOverridden ).to.be.false;
  476. expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
  477. model.change( writer => {
  478. writer.insertText( 'yz', selection.getAttributes(), selection.getFirstPosition() );
  479. } );
  480. // <$text b="1">yz[]</$text><$text a="1">x</$text>
  481. expect( selection.isGravityOverridden ).to.be.false;
  482. expect( getSelectionAttributesArray( selection ) ).to.have.members( [ 'b' ] );
  483. } );
  484. } );
  485. describe( 'multiple attributes', () => {
  486. beforeEach( () => {
  487. bindTwoStepCaretToAttribute( {
  488. view: editor.editing.view,
  489. model: editor.model,
  490. emitter,
  491. attribute: 'c',
  492. locale
  493. } );
  494. } );
  495. it( 'should work with the two-step caret movement (moving right)', () => {
  496. setData( model, 'fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux' );
  497. testTwoStepCaretMovement( [
  498. // fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  499. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  500. '→',
  501. // foo[]<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  502. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  503. '→',
  504. // foo<$text a="true">[]foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  505. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  506. '→',
  507. '→',
  508. '→',
  509. // foo<$text a="true">foo[]</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  510. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  511. '→',
  512. // foo<$text a="true">foo</$text><$text a="true" c="true">[]bar</$text><$text c="true">baz</$text>qux
  513. { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  514. '→',
  515. '→',
  516. '→',
  517. // foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text><$text c="true">baz</$text>qux
  518. { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  519. '→',
  520. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">[]baz</$text>qux
  521. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 3, evtStopCalled: 3 },
  522. '→',
  523. '→',
  524. '→',
  525. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz[]</$text>qux
  526. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 3, evtStopCalled: 3 },
  527. '→',
  528. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>[]qux
  529. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 4, evtStopCalled: 4 },
  530. '→',
  531. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux
  532. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 }
  533. ] );
  534. } );
  535. it( 'should work with the two-step caret movement (moving left)', () => {
  536. setData( model, 'foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux' );
  537. testTwoStepCaretMovement( [
  538. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>q[]ux
  539. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  540. '←',
  541. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>[]qux
  542. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 0, evtStopCalled: 0 },
  543. '←',
  544. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz[]</$text>qux
  545. { selectionAttributes: [ 'c' ], isGravityOverridden: false, preventDefault: 1, evtStopCalled: 1 },
  546. '←',
  547. '←',
  548. '←',
  549. // foo<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">[]baz</$text>qux
  550. { selectionAttributes: [ 'c' ], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 },
  551. '←',
  552. // foo<$text a="true">foo</$text><$text a="true" c="true">bar[]</$text><$text c="true">baz</$text>qux
  553. { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: false, preventDefault: 2, evtStopCalled: 2 },
  554. '←',
  555. '←',
  556. '←',
  557. // foo<$text a="true">foo</$text><$text a="true" c="true">[]bar</$text><$text c="true">baz</$text>qux
  558. { selectionAttributes: [ 'a', 'c' ], isGravityOverridden: true, preventDefault: 2, evtStopCalled: 2 },
  559. '←',
  560. // foo<$text a="true">foo[]</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  561. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 3, evtStopCalled: 3 },
  562. '←',
  563. '←',
  564. '←',
  565. // foo<$text a="true">[]foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  566. { selectionAttributes: [ 'a' ], isGravityOverridden: true, preventDefault: 3, evtStopCalled: 3 },
  567. '←',
  568. // foo[]<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  569. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 },
  570. '←',
  571. // fo[]o<$text a="true">foo</$text><$text a="true" c="true">bar</$text><$text c="true">baz</$text>qux
  572. { selectionAttributes: [], isGravityOverridden: false, preventDefault: 4, evtStopCalled: 4 }
  573. ] );
  574. } );
  575. } );
  576. describe( 'mouse', () => {
  577. it( 'should not override gravity when selection is placed at the beginning of text', () => {
  578. setData( model, '<$text a="true">[]foo</$text>' );
  579. expect( selection.isGravityOverridden ).to.be.false;
  580. } );
  581. it( 'should not override gravity when selection is placed at the end of text', () => {
  582. setData( model, '<$text a="true">foo[]</$text>' );
  583. expect( selection.isGravityOverridden ).to.be.false;
  584. } );
  585. } );
  586. it( 'should listen with the high+1 priority on view.document#keydown', () => {
  587. const highestPrioritySpy = sinon.spy();
  588. const highPrioritySpy = sinon.spy();
  589. const normalPrioritySpy = sinon.spy();
  590. setData( model, '<$text c="true">foo[]</$text><$text a="true" b="true">bar</$text>' );
  591. emitter.listenTo( view.document, 'keydown', highestPrioritySpy, { priority: 'highest' } );
  592. emitter.listenTo( view.document, 'keydown', highPrioritySpy, { priority: 'high' } );
  593. emitter.listenTo( view.document, 'keydown', normalPrioritySpy, { priority: 'normal' } );
  594. fireKeyDownEvent( {
  595. keyCode: keyCodes.arrowright,
  596. preventDefault: preventDefaultSpy
  597. } );
  598. sinon.assert.callOrder(
  599. highestPrioritySpy,
  600. preventDefaultSpy );
  601. sinon.assert.notCalled( highPrioritySpy );
  602. sinon.assert.notCalled( normalPrioritySpy );
  603. } );
  604. it( 'should do nothing when key other then arrow left and right is pressed', () => {
  605. setData( model, '<$text a="true">foo[]</$text>' );
  606. expect( () => {
  607. fireKeyDownEvent( { keyCode: keyCodes.arrowup } );
  608. } ).to.not.throw();
  609. } );
  610. it( 'should do nothing for non-collapsed selection', () => {
  611. setData( model, '<$text c="true">fo[o]</$text><$text a="true" b="true">bar</$text>' );
  612. fireKeyDownEvent( { keyCode: keyCodes.arrowright } );
  613. expect( selection.isGravityOverridden ).to.be.false;
  614. } );
  615. it( 'should do nothing when shift key is pressed', () => {
  616. setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
  617. fireKeyDownEvent( {
  618. keyCode: keyCodes.arrowleft,
  619. shiftKey: true
  620. } );
  621. expect( selection.isGravityOverridden ).to.be.false;
  622. } );
  623. it( 'should do nothing when alt key is pressed', () => {
  624. setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
  625. fireKeyDownEvent( {
  626. keyCode: keyCodes.arrowleft,
  627. altKey: true
  628. } );
  629. expect( selection.isGravityOverridden ).to.be.false;
  630. } );
  631. it( 'should do nothing when ctrl key is pressed', () => {
  632. setData( model, '<$text c="true">foo</$text><$text a="true" b="true">b[]ar</$text>' );
  633. fireKeyDownEvent( {
  634. keyCode: keyCodes.arrowleft,
  635. ctrlKey: true
  636. } );
  637. expect( selection.isGravityOverridden ).to.be.false;
  638. } );
  639. it( 'should do nothing when the not a direct selection change but at the attribute boundary', () => {
  640. setData( model, '<$text a="true">foo[]</$text>bar' );
  641. testTwoStepCaretMovement( [
  642. { selectionAttributes: [ 'a' ], isGravityOverridden: false, preventDefault: 0, evtStopCalled: 0 },
  643. '→',
  644. { selectionAttributes: [], isGravityOverridden: true, preventDefault: 1, evtStopCalled: 1 }
  645. ] );
  646. // Simulate an external text insertion BEFORE the user selection to trigger #change:range.
  647. model.enqueueChange( 'transparent', writer => {
  648. writer.insertText( 'x', selection.getFirstPosition().getShiftedBy( -2 ) );
  649. } );
  650. expect( selection.isGravityOverridden ).to.be.true;
  651. expect( getSelectionAttributesArray( selection ) ).to.have.members( [] );
  652. } );
  653. describe( 'left–to–right and right–to–left content', () => {
  654. it( 'should call methods associated with the keys (LTR content direction)', () => {
  655. const forwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleForwardMovement' );
  656. const backwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleBackwardMovement' );
  657. setData( model, '<$text>foo[]</$text><$text a="true">bar</$text>' );
  658. fireKeyDownEvent( {
  659. keyCode: keyCodes.arrowright
  660. } );
  661. sinon.assert.calledOnce( forwardStub );
  662. sinon.assert.notCalled( backwardStub );
  663. setData( model, '<$text>foo</$text><$text a="true">[]bar</$text>' );
  664. fireKeyDownEvent( {
  665. keyCode: keyCodes.arrowleft
  666. } );
  667. sinon.assert.calledOnce( backwardStub );
  668. sinon.assert.calledOnce( forwardStub );
  669. } );
  670. it( 'should use the opposite helper methods (RTL content direction)', () => {
  671. const forwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleForwardMovement' );
  672. const backwardStub = testUtils.sinon.stub( TwoStepCaretHandler.prototype, 'handleBackwardMovement' );
  673. const emitter = Object.create( DomEmitterMixin );
  674. let model;
  675. return VirtualTestEditor
  676. .create( {
  677. language: {
  678. content: 'ar'
  679. }
  680. } )
  681. .then( newEditor => {
  682. model = newEditor.model;
  683. selection = model.document.selection;
  684. view = newEditor.editing.view;
  685. newEditor.model.schema.extend( '$text', {
  686. allowAttributes: [ 'a', 'b', 'c' ],
  687. allowIn: '$root'
  688. } );
  689. model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
  690. newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'a', model: 'a' } );
  691. newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'b', model: 'b' } );
  692. newEditor.conversion.for( 'upcast' ).elementToAttribute( { view: 'c', model: 'c' } );
  693. newEditor.conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  694. bindTwoStepCaretToAttribute( {
  695. view: newEditor.editing.view,
  696. model: newEditor.model,
  697. emitter,
  698. attribute: 'a',
  699. locale: newEditor.locale
  700. } );
  701. return newEditor;
  702. } )
  703. .then( newEditor => {
  704. setData( model, '<$text>foo[]</$text><$text a="true">bar</$text>' );
  705. fireKeyDownEvent( {
  706. keyCode: keyCodes.arrowleft
  707. } );
  708. sinon.assert.calledOnce( forwardStub );
  709. sinon.assert.notCalled( backwardStub );
  710. setData( model, '<$text>foo</$text><$text a="true">[]bar</$text>' );
  711. fireKeyDownEvent( {
  712. keyCode: keyCodes.arrowright
  713. } );
  714. sinon.assert.calledOnce( backwardStub );
  715. sinon.assert.calledOnce( forwardStub );
  716. return newEditor.destroy();
  717. } );
  718. } );
  719. } );
  720. const keyMap = {
  721. '→': 'arrowright',
  722. '←': 'arrowleft'
  723. };
  724. function fireKeyDownEvent( options ) {
  725. const eventInfo = new EventInfo( view.document, 'keydown' );
  726. const eventData = new DomEventData( view.document, {
  727. target: document.body
  728. }, options );
  729. sinon.stub( eventInfo, 'stop' ).callsFake( evtStopSpy );
  730. view.document.fire( eventInfo, eventData );
  731. }
  732. function getSelectionAttributesArray( selection ) {
  733. return Array.from( selection.getAttributeKeys() );
  734. }
  735. function testTwoStepCaretMovement( scenario ) {
  736. for ( const step of scenario ) {
  737. if ( typeof step == 'string' ) {
  738. // An arrow key pressed. Fire the view event and update the model selection.
  739. if ( keyMap[ step ] ) {
  740. let preventDefaultCalled;
  741. fireKeyDownEvent( {
  742. keyCode: keyCodes[ keyMap[ step ] ],
  743. preventDefault: () => {
  744. preventDefaultSpy();
  745. preventDefaultCalled = true;
  746. }
  747. } );
  748. const position = selection.getFirstPosition();
  749. if ( !preventDefaultCalled ) {
  750. if ( step == '→' ) {
  751. model.change( writer => {
  752. if ( position.isAtEnd ) {
  753. const nextBlock = position.parent.nextSibling;
  754. if ( nextBlock ) {
  755. writer.setSelection( Position._createAt( nextBlock, 0 ) );
  756. }
  757. } else {
  758. writer.setSelection( selection.getFirstPosition().getShiftedBy( 1 ) );
  759. }
  760. } );
  761. } else if ( step == '←' ) {
  762. model.change( writer => {
  763. if ( position.isAtStart ) {
  764. const previousBlock = position.parent.previousSibling;
  765. if ( previousBlock ) {
  766. writer.setSelection( Position._createAt( previousBlock, 'end' ) );
  767. }
  768. } else {
  769. writer.setSelection( selection.getFirstPosition().getShiftedBy( -1 ) );
  770. }
  771. } );
  772. }
  773. }
  774. }
  775. // A regular key pressed. Type some text in the model.
  776. else {
  777. model.change( writer => {
  778. writer.insertText( step, selection.getAttributes(), selection.getFirstPosition() );
  779. } );
  780. }
  781. }
  782. // If not a key, then it's an assertion.
  783. else {
  784. const stepIndex = scenario.indexOf( step );
  785. const stepString = `in step #${ stepIndex } ${ JSON.stringify( step ) }`;
  786. expect( getSelectionAttributesArray( selection ) ).to.have.members(
  787. step.selectionAttributes, `#attributes ${ stepString }` );
  788. expect( selection.isGravityOverridden ).to.equal( step.isGravityOverridden, `#isGravityOverridden ${ stepString }` );
  789. expect( preventDefaultSpy.callCount ).to.equal( step.preventDefault, `#preventDefault ${ stepString }` );
  790. expect( evtStopSpy.callCount ).to.equal( step.evtStopCalled, `#evtStopCalled ${ stepString }` );
  791. }
  792. }
  793. }
  794. } );