react-scope-value-store.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import { expect } from 'chai'
  2. import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
  3. import sinon from 'sinon'
  4. import customLocalStorage from '@/infrastructure/local-storage'
  5. function waitForWatchers(callback: () => void) {
  6. return new Promise(resolve => {
  7. callback()
  8. window.setTimeout(resolve, 1)
  9. })
  10. }
  11. describe('ReactScopeValueStore', function () {
  12. it('can set and retrieve a value', function () {
  13. const store = new ReactScopeValueStore()
  14. store.set('test', 'wombat')
  15. const retrieved = store.get('test')
  16. expect(retrieved).to.equal('wombat')
  17. })
  18. it('can overwrite a value', function () {
  19. const store = new ReactScopeValueStore()
  20. store.set('test', 'wombat')
  21. store.set('test', 'not a wombat')
  22. const retrieved = store.get('test')
  23. expect(retrieved).to.equal('not a wombat')
  24. })
  25. it('can overwrite a nested value', function () {
  26. const store = new ReactScopeValueStore()
  27. store.set('test', { prop: 'wombat' })
  28. store.set('test.prop', 'not a wombat')
  29. const retrieved = store.get('test.prop')
  30. expect(retrieved).to.equal('not a wombat')
  31. })
  32. it('throws an error when retrieving an unknown value', function () {
  33. const store = new ReactScopeValueStore()
  34. expect(() => store.get('test')).to.throw
  35. })
  36. it('can watch a value', async function () {
  37. const store = new ReactScopeValueStore()
  38. store.set('changing', 'one')
  39. store.set('fixed', 'one')
  40. const changingItemWatcher = sinon.stub()
  41. const fixedItemWatcher = sinon.stub()
  42. await waitForWatchers(() => {
  43. store.watch('changing', changingItemWatcher)
  44. store.watch('fixed', fixedItemWatcher)
  45. })
  46. expect(changingItemWatcher).to.have.been.calledWith('one')
  47. expect(fixedItemWatcher).to.have.been.calledWith('one')
  48. changingItemWatcher.reset()
  49. fixedItemWatcher.reset()
  50. await waitForWatchers(() => {
  51. store.set('changing', 'two')
  52. })
  53. expect(changingItemWatcher).to.have.been.calledWith('two')
  54. expect(fixedItemWatcher).not.to.have.been.called
  55. })
  56. it('allows synchronous watcher updates', function () {
  57. const store = new ReactScopeValueStore()
  58. store.set('test', 'wombat')
  59. const watcher = sinon.stub()
  60. store.watch('test', watcher)
  61. store.set('test', 'not a wombat')
  62. expect(watcher).not.to.have.been.called
  63. store.flushUpdates()
  64. expect(watcher).to.have.been.calledWith('not a wombat')
  65. })
  66. it('removes a watcher', async function () {
  67. const store = new ReactScopeValueStore()
  68. store.set('test', 'wombat')
  69. const watcher = sinon.stub()
  70. const removeWatcher = store.watch('test', watcher)
  71. store.flushUpdates()
  72. watcher.reset()
  73. removeWatcher()
  74. store.set('test', 'not a wombat')
  75. store.flushUpdates()
  76. expect(watcher).not.to.have.been.called
  77. })
  78. it('does not call a watcher removed between observing change and being called', async function () {
  79. const store = new ReactScopeValueStore()
  80. store.set('test', 'wombat')
  81. const watcher = sinon.stub()
  82. const removeWatcher = store.watch('test', watcher)
  83. store.flushUpdates()
  84. watcher.reset()
  85. store.set('test', 'not a wombat')
  86. removeWatcher()
  87. store.flushUpdates()
  88. expect(watcher).not.to.have.been.called
  89. })
  90. it('does not trigger watcher on setting to an identical value', async function () {
  91. const store = new ReactScopeValueStore()
  92. store.set('test', 'wombat')
  93. const watcher = sinon.stub()
  94. await waitForWatchers(() => {
  95. store.watch('test', watcher)
  96. })
  97. expect(watcher).to.have.been.calledWith('wombat')
  98. watcher.reset()
  99. await waitForWatchers(() => {
  100. store.set('test', 'wombat')
  101. })
  102. expect(watcher).not.to.have.been.called
  103. })
  104. it('can watch a value before it has been set', async function () {
  105. const store = new ReactScopeValueStore()
  106. const watcher = sinon.stub()
  107. store.watch('test', watcher)
  108. await waitForWatchers(() => {
  109. store.set('test', 'wombat')
  110. })
  111. expect(watcher).to.have.been.calledWith('wombat')
  112. })
  113. it('handles multiple watchers for the same path added at the same time before the value is set', async function () {
  114. const store = new ReactScopeValueStore()
  115. const watcherOne = sinon.stub()
  116. const watcherTwo = sinon.stub()
  117. store.watch('test', watcherOne)
  118. store.watch('test', watcherTwo)
  119. await waitForWatchers(() => {
  120. store.set('test', 'wombat')
  121. })
  122. expect(watcherOne).to.have.been.calledWith('wombat')
  123. expect(watcherTwo).to.have.been.calledWith('wombat')
  124. })
  125. it('handles multiple watchers for the same path added at the same time after the value is set', async function () {
  126. const store = new ReactScopeValueStore()
  127. store.set('test', 'wombat')
  128. const watcherOne = sinon.stub()
  129. const watcherTwo = sinon.stub()
  130. store.watch('test', watcherOne)
  131. store.watch('test', watcherTwo)
  132. store.flushUpdates()
  133. expect(watcherOne).to.have.been.calledWith('wombat')
  134. expect(watcherTwo).to.have.been.calledWith('wombat')
  135. })
  136. it('throws an error when watching an unknown value', function () {
  137. const store = new ReactScopeValueStore()
  138. expect(() => store.watch('test', () => {})).to.throw
  139. })
  140. it('sets nested value if watched', function () {
  141. const store = new ReactScopeValueStore()
  142. store.set('test', { nested: 'one' })
  143. const watcher = sinon.stub()
  144. store.watch('test.nested', watcher)
  145. const retrieved = store.get('test.nested')
  146. expect(retrieved).to.equal('one')
  147. })
  148. it('does not set nested value if not watched', function () {
  149. const store = new ReactScopeValueStore()
  150. store.set('test', { nested: 'one' })
  151. expect(() => store.get('test.nested')).to.throw
  152. })
  153. it('can watch a nested value', async function () {
  154. const store = new ReactScopeValueStore()
  155. store.set('test', { nested: 'one' })
  156. const watcher = sinon.stub()
  157. store.watch('test.nested', watcher)
  158. await waitForWatchers(() => {
  159. store.set('test', { nested: 'two' })
  160. })
  161. expect(watcher).to.have.been.calledWith('two')
  162. })
  163. it('can watch a deeply nested value', async function () {
  164. const store = new ReactScopeValueStore()
  165. store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } })
  166. const watcher = sinon.stub()
  167. store.watch('test.levelOne.levelTwo.levelThree', watcher)
  168. await waitForWatchers(() => {
  169. store.set('test', { levelOne: { levelTwo: { levelThree: 'two' } } })
  170. })
  171. expect(watcher).to.have.been.calledWith('two')
  172. })
  173. it('does not inform nested value watcher when nested value does not change', async function () {
  174. const store = new ReactScopeValueStore()
  175. store.set('test', { nestedOne: 'one', nestedTwo: 'one' })
  176. const nestedOneWatcher = sinon.stub()
  177. const nestedTwoWatcher = sinon.stub()
  178. await waitForWatchers(() => {
  179. store.watch('test.nestedOne', nestedOneWatcher)
  180. store.watch('test.nestedTwo', nestedTwoWatcher)
  181. })
  182. nestedOneWatcher.reset()
  183. nestedTwoWatcher.reset()
  184. await waitForWatchers(() => {
  185. store.set('test', { nestedOne: 'two', nestedTwo: 'one' })
  186. })
  187. expect(nestedOneWatcher).to.have.been.calledWith('two')
  188. expect(nestedTwoWatcher).not.to.have.been.called
  189. })
  190. it('deletes nested values that no longer exist', function () {
  191. const store = new ReactScopeValueStore()
  192. store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } })
  193. store.set('test', { levelOne: { different: 'wombat' } })
  194. const retrieved = store.get('test.levelOne.different')
  195. expect(retrieved).to.equal('wombat')
  196. expect(() => store.get('test.levelOne.levelTwo')).to.throw
  197. expect(() => store.get('test.levelOne.levelTwo.levelThree')).to.throw
  198. })
  199. it('does not throw for allowed non-existent path', function () {
  200. const store = new ReactScopeValueStore()
  201. store.allowNonExistentPath('wombat')
  202. store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } })
  203. store.set('test', { levelOne: { different: 'wombat' } })
  204. expect(() => store.get('test')).not.to.throw
  205. expect(store.get('wombat')).to.equal(undefined)
  206. })
  207. it('does not throw for deep allowed non-existent path', function () {
  208. const store = new ReactScopeValueStore()
  209. store.allowNonExistentPath('wombat', true)
  210. expect(() => store.get('wombat')).not.to.throw
  211. expect(() => store.get('wombat.nested')).not.to.throw
  212. expect(() => store.get('wombat.really.very.nested')).not.to.throw
  213. })
  214. it('throws for nested value in non-deep allowed non-existent path', function () {
  215. const store = new ReactScopeValueStore()
  216. store.allowNonExistentPath('wombat', false)
  217. expect(() => store.get('wombat.nested')).to.throw
  218. })
  219. it('throws for ancestor of allowed non-existent path', function () {
  220. const store = new ReactScopeValueStore()
  221. store.allowNonExistentPath('wombat.nested', true)
  222. expect(() => store.get('wombat.really.very.nested')).not.to.throw
  223. expect(() => store.get('wombat')).to.throw
  224. })
  225. it('updates ancestors', async function () {
  226. const store = new ReactScopeValueStore()
  227. const testValue = {
  228. prop1: {
  229. subProp: 'wombat',
  230. },
  231. prop2: {
  232. subProp: 'wombat',
  233. },
  234. }
  235. store.set('test', testValue)
  236. const rootWatcher = sinon.stub()
  237. const prop1Watcher = sinon.stub()
  238. const subPropWatcher = sinon.stub()
  239. const prop2Watcher = sinon.stub()
  240. await waitForWatchers(() => {
  241. store.watch('test', rootWatcher)
  242. store.watch('test.prop1', prop1Watcher)
  243. store.watch('test.prop1.subProp', subPropWatcher)
  244. store.watch('test.prop2', prop2Watcher)
  245. })
  246. rootWatcher.reset()
  247. prop1Watcher.reset()
  248. subPropWatcher.reset()
  249. prop2Watcher.reset()
  250. await waitForWatchers(() => {
  251. store.set('test.prop1.subProp', 'picard')
  252. })
  253. expect(store.get('test')).to.deep.equal({
  254. prop1: {
  255. subProp: 'picard',
  256. },
  257. prop2: {
  258. subProp: 'wombat',
  259. },
  260. })
  261. expect(store.get('test.prop2')).to.equal(testValue.prop2)
  262. expect(rootWatcher).to.have.been.called
  263. expect(prop1Watcher).to.have.been.called
  264. expect(subPropWatcher).to.have.been.called
  265. expect(prop2Watcher).not.to.have.been.called
  266. })
  267. describe('persistence', function () {
  268. beforeEach(function () {
  269. customLocalStorage.clear()
  270. })
  271. it('persists string to local storage', function () {
  272. const store = new ReactScopeValueStore()
  273. store.persisted('test-path', 'fallback value', 'test-storage-key')
  274. expect(store.get('test-path')).to.equal('fallback value')
  275. store.set('test-path', 'new value')
  276. expect(customLocalStorage.getItem('test-storage-key')).to.equal(
  277. 'new value'
  278. )
  279. })
  280. it("doesn't persist string to local storage until set() is called", function () {
  281. const store = new ReactScopeValueStore()
  282. store.persisted('test-path', 'fallback value', 'test-storage-key')
  283. expect(customLocalStorage.getItem('test-storage-key')).to.equal(null)
  284. })
  285. it('converts persisted value', function () {
  286. const store = new ReactScopeValueStore()
  287. store.persisted('test-path', false, 'test-storage-key', {
  288. toPersisted: value => (value ? 'on' : 'off'),
  289. fromPersisted: persistedValue => persistedValue === 'on',
  290. })
  291. store.set('test-path', true)
  292. expect(customLocalStorage.getItem('test-storage-key')).to.equal('on')
  293. })
  294. })
  295. })