@jeremyckahn/farmhand 1.20.1 → 1.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/assets/index-dORrBwcH.js +844 -0
  2. package/dist/assets/index-dORrBwcH.js.map +1 -0
  3. package/dist/assets/{workbox-window.prod.es5-Cch4wiA5.js → workbox-window.prod.es5-Bd17z0YL.js} +2 -2
  4. package/dist/assets/{workbox-window.prod.es5-Cch4wiA5.js.map → workbox-window.prod.es5-Bd17z0YL.js.map} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/service-worker.js +1 -1
  7. package/dist/service-worker.js.map +1 -1
  8. package/package.json +3 -3
  9. package/src/components/Cellar/Cellar.tsx +7 -5
  10. package/src/components/Farmhand/hooks/useFarmhandDayAdvancement.ts +2 -1
  11. package/src/components/Farmhand/hooks/useFarmhandNetwork.ts +2 -1
  12. package/src/components/Farmhand/useFarmhand.ts +103 -31
  13. package/src/components/Shop/Shop.tsx +8 -3
  14. package/src/components/Workshop/Workshop.tsx +11 -3
  15. package/src/handlers/ui-events.tsx +8 -3
  16. package/src/hooks/useTabQueryParam.test.tsx +86 -0
  17. package/src/hooks/useTabQueryParam.ts +57 -0
  18. package/src/setupTests.ts +6 -0
  19. package/src/shell/hash-view-restoration.test.ts +89 -0
  20. package/src/shell/navigation.test.ts +4 -4
  21. package/src/utils/getGrowingPhase.test.ts +15 -0
  22. package/src/utils/getGrowingPhase.ts +2 -11
  23. package/src/utils/getGrowingPhaseForTimeline.ts +1 -1
  24. package/src/utils/getValidatedStageFocusFromHash.test.ts +72 -0
  25. package/src/utils/getValidatedStageFocusFromHash.ts +43 -0
  26. package/src/utils/getViewList.test.ts +73 -0
  27. package/src/utils/getViewList.ts +46 -0
  28. package/src/utils/hashQueryParams.test.ts +153 -0
  29. package/src/utils/hashQueryParams.ts +72 -0
  30. package/dist/assets/index-Bx-fejeL.js +0 -844
  31. package/dist/assets/index-Bx-fejeL.js.map +0 -1
@@ -0,0 +1,57 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ import {
4
+ getHashQueryParams,
5
+ setHashQueryParam,
6
+ } from '../utils/hashQueryParams.js'
7
+
8
+ const TAB_QUERY_PARAM_NAME = 'tab'
9
+
10
+ const getTabIndexFromHash = (tabLabels: readonly string[]): number => {
11
+ const tabLabelFromUrl = getHashQueryParams().get(TAB_QUERY_PARAM_NAME)
12
+ const tabIndexFromUrl = tabLabels.indexOf(tabLabelFromUrl ?? '')
13
+
14
+ return tabIndexFromUrl === -1 ? 0 : tabIndexFromUrl
15
+ }
16
+
17
+ /**
18
+ * Drop-in replacement for `useState(0)` for a MUI `Tabs`/`Tab` `value`,
19
+ * that also mirrors the active tab's label into the URL hash's `tab`
20
+ * query param so a page reload restores the same tab. Tabs are matched by
21
+ * label rather than raw index, since some screens conditionally omit tabs
22
+ * (e.g. Shop's Saplings tab), which shifts indices around depending on
23
+ * what's unlocked.
24
+ *
25
+ * `tabLabels` can legitimately change shape after the initial render (e.g.
26
+ * a screen mounts before its unlock-dependent tabs are known, then a
27
+ * persisted save loads a moment later and adds one) - re-deriving the
28
+ * index whenever the label list itself changes keeps the restored tab
29
+ * pointing at the right label instead of freezing on its first-render
30
+ * position.
31
+ */
32
+ export const useTabQueryParam = (
33
+ tabLabels: readonly string[]
34
+ ): [number, (tabIndex: number) => void] => {
35
+ const [currentTab, setCurrentTabState] = useState(() =>
36
+ getTabIndexFromHash(tabLabels)
37
+ )
38
+
39
+ const tabLabelsKey = tabLabels.join('|')
40
+
41
+ useEffect(() => {
42
+ setCurrentTabState(getTabIndexFromHash(tabLabels))
43
+ // eslint-disable-next-line react-hooks/exhaustive-deps
44
+ }, [tabLabelsKey])
45
+
46
+ const setCurrentTab = (tabIndex: number) => {
47
+ setCurrentTabState(tabIndex)
48
+
49
+ const tabLabel = tabLabels[tabIndex]
50
+
51
+ if (tabLabel) {
52
+ setHashQueryParam(TAB_QUERY_PARAM_NAME, tabLabel)
53
+ }
54
+ }
55
+
56
+ return [currentTab, setCurrentTab]
57
+ }
package/src/setupTests.ts CHANGED
@@ -52,4 +52,10 @@ beforeEach(() => {
52
52
 
53
53
  afterEach(() => {
54
54
  cleanup()
55
+ // Farmhand mirrors navigation state (current view, tabbed screens' active
56
+ // tab) into the real window.location.hash (see src/utils/hashQueryParams.ts),
57
+ // which - unlike React/component state - isn't reset between tests on its
58
+ // own and would otherwise leak into whichever test runs next in the same
59
+ // file.
60
+ window.history.replaceState({}, '', window.location.pathname)
55
61
  })
@@ -0,0 +1,89 @@
1
+ import { waitFor, within } from '@testing-library/react'
2
+
3
+ import { saveDataStubFactory } from '../test-utils/stubs/saveDataStubFactory.js'
4
+ import { farmhandStub } from '../test-utils/stubs/farmhandStub.js'
5
+
6
+ describe('restoring stageFocus from the URL hash on boot', () => {
7
+ const getHeader = () => {
8
+ const [header] = Array.from(document.getElementsByTagName('header'))
9
+
10
+ return header
11
+ }
12
+
13
+ test('restores a view named in the URL hash when a fresh game has it unlocked', async () => {
14
+ window.history.replaceState(
15
+ {},
16
+ '',
17
+ `${window.location.pathname}#?view=FIELD`
18
+ )
19
+
20
+ await farmhandStub()
21
+
22
+ await waitFor(() => {
23
+ expect(within(getHeader()).getByText('Field')).toBeInTheDocument()
24
+ })
25
+ })
26
+
27
+ test('falls back to Home when a fresh game does not have the hash view unlocked', async () => {
28
+ window.history.replaceState(
29
+ {},
30
+ '',
31
+ `${window.location.pathname}#?view=FOREST`
32
+ )
33
+
34
+ await farmhandStub()
35
+
36
+ await waitFor(() => {
37
+ expect(within(getHeader()).getByText('Home')).toBeInTheDocument()
38
+ })
39
+ expect(within(getHeader()).queryByText('Forest')).not.toBeInTheDocument()
40
+ })
41
+
42
+ test('falls back to Home when the loaded save does not have the hash view unlocked', async () => {
43
+ window.history.replaceState(
44
+ {},
45
+ '',
46
+ `${window.location.pathname}#?view=FOREST`
47
+ )
48
+
49
+ const loadedState = saveDataStubFactory({
50
+ experience: 0,
51
+ purchasedForest: 0,
52
+ })
53
+
54
+ await farmhandStub({
55
+ localforage: {
56
+ getItem: () => Promise.resolve(loadedState),
57
+ setItem: (_key: string, data: unknown) => Promise.resolve(data),
58
+ },
59
+ })
60
+
61
+ await waitFor(() => {
62
+ expect(within(getHeader()).getByText('Home')).toBeInTheDocument()
63
+ })
64
+ expect(within(getHeader()).queryByText('Forest')).not.toBeInTheDocument()
65
+ })
66
+
67
+ test('restores the hash view when the loaded save does have it unlocked', async () => {
68
+ window.history.replaceState(
69
+ {},
70
+ '',
71
+ `${window.location.pathname}#?view=FOREST`
72
+ )
73
+
74
+ const loadedState = saveDataStubFactory({
75
+ experience: 20_000,
76
+ })
77
+
78
+ await farmhandStub({
79
+ localforage: {
80
+ getItem: () => Promise.resolve(loadedState),
81
+ setItem: (_key: string, data: unknown) => Promise.resolve(data),
82
+ },
83
+ })
84
+
85
+ await waitFor(() => {
86
+ expect(within(getHeader()).getByText('Forest')).toBeInTheDocument()
87
+ })
88
+ })
89
+ })
@@ -13,7 +13,7 @@ describe('navigation', () => {
13
13
  await nextView()
14
14
  expect(within(header).getByText(viewName)).toBeInTheDocument()
15
15
  }
16
- })
16
+ }, 10000)
17
17
 
18
18
  test('cycles backwards through the standard views', async () => {
19
19
  await farmhandStub()
@@ -23,7 +23,7 @@ describe('navigation', () => {
23
23
  await previousView()
24
24
  expect(within(header).getByText(viewName)).toBeInTheDocument()
25
25
  }
26
- })
26
+ }, 10000)
27
27
 
28
28
  test('cycles forwards through the unlocked views', async () => {
29
29
  const loadedState = saveDataStubFactory({
@@ -43,7 +43,7 @@ describe('navigation', () => {
43
43
  await nextView()
44
44
  expect(within(header).getByText(viewName)).toBeInTheDocument()
45
45
  }
46
- })
46
+ }, 10000)
47
47
 
48
48
  test('cycles backwards through the unlocked views', async () => {
49
49
  const loadedState = saveDataStubFactory({
@@ -63,5 +63,5 @@ describe('navigation', () => {
63
63
  await previousView()
64
64
  expect(within(header).getByText(viewName)).toBeInTheDocument()
65
65
  }
66
- })
66
+ }, 10000)
67
67
  })
@@ -11,4 +11,19 @@ describe('getGrowingPhase', () => {
11
11
 
12
12
  expect(getGrowingPhase(crop as any)).toEqual(phase)
13
13
  })
14
+
15
+ test.each([
16
+ [0, 2.5],
17
+ [4, 6.5],
18
+ [5, 7.5],
19
+ [6, 8.5],
20
+ ])(
21
+ 'it handles fractional daysWatered correctly (returns phase %s when days watered is %s for pumpkin)',
22
+ (phase, daysWatered) => {
23
+ // pumpkin cropTimeline: [3, 1, 1, 1, 1, 1]
24
+ const crop = { itemId: 'pumpkin', daysWatered }
25
+
26
+ expect(getGrowingPhase(crop as any)).toEqual(phase)
27
+ }
28
+ )
14
29
  })
@@ -2,23 +2,14 @@ import { itemsMap } from '../data/maps.js'
2
2
  import { LARGEST_PURCHASABLE_FIELD_SIZE } from '../constants.js'
3
3
 
4
4
  import { memoize } from './memoize.js'
5
+ import { getGrowingPhaseForTimeline } from './getGrowingPhaseForTimeline.js'
5
6
 
6
7
  export const getGrowingPhase = memoize(
7
8
  (crop: farmhand.crop) => {
8
9
  const { itemId, daysWatered = 0 } = crop
9
10
  const { cropTimeline = [] } = itemsMap[itemId]
10
11
 
11
- let daysGrowing = daysWatered + 1
12
- let phase = 0
13
-
14
- for (let value of cropTimeline) {
15
- if (daysGrowing - value <= 0) break
16
-
17
- daysGrowing -= value
18
- phase += 1
19
- }
20
-
21
- return phase
12
+ return getGrowingPhaseForTimeline(cropTimeline, daysWatered)
22
13
  },
23
14
  {
24
15
  cacheSize:
@@ -2,7 +2,7 @@ export const getGrowingPhaseForTimeline = (
2
2
  timeline: number[],
3
3
  daysElapsed: number
4
4
  ): number => {
5
- let daysGrowing = daysElapsed + 1
5
+ let daysGrowing = Math.floor(daysElapsed) + 1
6
6
  let phase = 0
7
7
 
8
8
  for (const value of timeline) {
@@ -0,0 +1,72 @@
1
+ import { stageFocusType } from '../enums.js'
2
+
3
+ import { getValidatedStageFocusFromHash } from './getValidatedStageFocusFromHash.js'
4
+
5
+ const { FIELD, FOREST, HOME } = stageFocusType
6
+
7
+ const defaultState = {
8
+ experience: 0,
9
+ purchasedCellar: 0,
10
+ purchasedCowPen: 0,
11
+ showHomeScreen: true,
12
+ }
13
+
14
+ describe('getValidatedStageFocusFromHash', () => {
15
+ test('returns undefined when the hash has no view param', () => {
16
+ window.history.replaceState({}, '', `${window.location.pathname}`)
17
+
18
+ expect(getValidatedStageFocusFromHash(defaultState)).toBeUndefined()
19
+ })
20
+
21
+ test('returns the view when it is unlocked for the given state', () => {
22
+ window.history.replaceState(
23
+ {},
24
+ '',
25
+ `${window.location.pathname}#?view=FIELD`
26
+ )
27
+
28
+ expect(getValidatedStageFocusFromHash(defaultState)).toEqual(FIELD)
29
+ })
30
+
31
+ test('returns undefined when the view is not unlocked for the given state', () => {
32
+ window.history.replaceState(
33
+ {},
34
+ '',
35
+ `${window.location.pathname}#?view=FOREST`
36
+ )
37
+
38
+ expect(getValidatedStageFocusFromHash(defaultState)).toBeUndefined()
39
+ })
40
+
41
+ test('returns the view once its unlock requirement is met', () => {
42
+ window.history.replaceState(
43
+ {},
44
+ '',
45
+ `${window.location.pathname}#?view=FOREST`
46
+ )
47
+
48
+ expect(
49
+ getValidatedStageFocusFromHash({ ...defaultState, experience: 20_000 })
50
+ ).toEqual(FOREST)
51
+ })
52
+
53
+ test('returns undefined when the hash names a view outside stageFocusType entirely', () => {
54
+ window.history.replaceState(
55
+ {},
56
+ '',
57
+ `${window.location.pathname}#?view=NOT_A_REAL_VIEW`
58
+ )
59
+
60
+ expect(getValidatedStageFocusFromHash(defaultState)).toBeUndefined()
61
+ })
62
+
63
+ test('returns HOME when showHomeScreen is true, matching the default view list', () => {
64
+ window.history.replaceState(
65
+ {},
66
+ '',
67
+ `${window.location.pathname}#?view=HOME`
68
+ )
69
+
70
+ expect(getValidatedStageFocusFromHash(defaultState)).toEqual(HOME)
71
+ })
72
+ })
@@ -0,0 +1,43 @@
1
+ import { stageFocusType } from '../enums.js'
2
+
3
+ import { getLevelEntitlements } from './getLevelEntitlements.js'
4
+ import { getViewList } from './getViewList.js'
5
+ import {
6
+ getHashQueryParams,
7
+ VIEW_QUERY_PARAM_NAME,
8
+ } from './hashQueryParams.js'
9
+ import { levelAchieved } from './levelAchieved.js'
10
+
11
+ /**
12
+ * Reads the URL hash's `view` param and returns it only if it names a view
13
+ * that's actually unlocked for the given state - guards against a
14
+ * bookmarked/shared URL (or a stale browser history entry reached via
15
+ * back/forward) pointing at a screen the current save hasn't unlocked,
16
+ * since Stage.tsx renders whichever view stageFocus names with no unlock
17
+ * check of its own. Used both at boot and when reacting to popstate.
18
+ */
19
+ export const getValidatedStageFocusFromHash = (
20
+ candidateState: Pick<
21
+ farmhand.state,
22
+ 'experience' | 'purchasedCellar' | 'purchasedCowPen' | 'showHomeScreen'
23
+ >
24
+ ): farmhand.stageFocusType | undefined => {
25
+ const stageFocusFromUrl = getHashQueryParams().get(VIEW_QUERY_PARAM_NAME)
26
+
27
+ if (!stageFocusFromUrl) return undefined
28
+
29
+ const isForestUnlockedForState = getLevelEntitlements(
30
+ levelAchieved(candidateState.experience)
31
+ ).stageFocusType[stageFocusType.FOREST]
32
+
33
+ const availableViews = getViewList({
34
+ isForestUnlocked: isForestUnlockedForState,
35
+ purchasedCellar: candidateState.purchasedCellar,
36
+ purchasedCowPen: candidateState.purchasedCowPen,
37
+ showHomeScreen: candidateState.showHomeScreen,
38
+ })
39
+
40
+ return availableViews.find(
41
+ availableView => availableView === stageFocusFromUrl
42
+ )
43
+ }
@@ -0,0 +1,73 @@
1
+ import { stageFocusType } from '../enums.js'
2
+
3
+ import { getViewList } from './getViewList.js'
4
+
5
+ const { CELLAR, COW_PEN, FIELD, HOME, SHOP, WORKSHOP, FOREST } = stageFocusType
6
+
7
+ describe('getViewList', () => {
8
+ test('returns the standard views plus Home and Workshop by default', () => {
9
+ expect(
10
+ getViewList({
11
+ isForestUnlocked: false,
12
+ purchasedCellar: 0,
13
+ purchasedCowPen: 0,
14
+ showHomeScreen: true,
15
+ })
16
+ ).toEqual([HOME, SHOP, FIELD, WORKSHOP])
17
+ })
18
+
19
+ test('omits Home when showHomeScreen is false', () => {
20
+ expect(
21
+ getViewList({
22
+ isForestUnlocked: false,
23
+ purchasedCellar: 0,
24
+ purchasedCowPen: 0,
25
+ showHomeScreen: false,
26
+ })
27
+ ).toEqual([SHOP, FIELD, WORKSHOP])
28
+ })
29
+
30
+ test('includes Forest only when isForestUnlocked is true', () => {
31
+ expect(
32
+ getViewList({
33
+ isForestUnlocked: true,
34
+ purchasedCellar: 0,
35
+ purchasedCowPen: 0,
36
+ showHomeScreen: true,
37
+ })
38
+ ).toEqual([HOME, SHOP, FIELD, FOREST, WORKSHOP])
39
+ })
40
+
41
+ test('includes Cow Pen only when purchasedCowPen is truthy', () => {
42
+ expect(
43
+ getViewList({
44
+ isForestUnlocked: false,
45
+ purchasedCellar: 0,
46
+ purchasedCowPen: 1,
47
+ showHomeScreen: true,
48
+ })
49
+ ).toEqual([HOME, SHOP, FIELD, COW_PEN, WORKSHOP])
50
+ })
51
+
52
+ test('includes Cellar only when purchasedCellar is truthy', () => {
53
+ expect(
54
+ getViewList({
55
+ isForestUnlocked: false,
56
+ purchasedCellar: 1,
57
+ purchasedCowPen: 0,
58
+ showHomeScreen: true,
59
+ })
60
+ ).toEqual([HOME, SHOP, FIELD, WORKSHOP, CELLAR])
61
+ })
62
+
63
+ test('includes every optional view in the correct order when all are unlocked', () => {
64
+ expect(
65
+ getViewList({
66
+ isForestUnlocked: true,
67
+ purchasedCellar: 1,
68
+ purchasedCowPen: 1,
69
+ showHomeScreen: true,
70
+ })
71
+ ).toEqual([HOME, SHOP, FIELD, FOREST, COW_PEN, WORKSHOP, CELLAR])
72
+ })
73
+ })
@@ -0,0 +1,46 @@
1
+ import { STANDARD_VIEW_LIST } from '../constants.js'
2
+ import { stageFocusType } from '../enums.js'
3
+
4
+ /**
5
+ * The ordered list of views available for a given state, mirroring what's
6
+ * actually unlocked (Home screen preference, Forest, Cow Pen, Cellar).
7
+ * Pulled out as a standalone function (rather than only living inline as a
8
+ * useMemo in useFarmhand.ts) so it can also be used to validate an
9
+ * externally-supplied view (e.g. from the URL hash) against a specific
10
+ * state snapshot - which may not be the live, currently-rendered state,
11
+ * such as a save being loaded during boot or import.
12
+ */
13
+ export const getViewList = ({
14
+ isForestUnlocked,
15
+ purchasedCellar,
16
+ purchasedCowPen,
17
+ showHomeScreen,
18
+ }: {
19
+ isForestUnlocked: boolean
20
+ purchasedCellar: number
21
+ purchasedCowPen: number
22
+ showHomeScreen: boolean
23
+ }): farmhand.stageFocusType[] => {
24
+ const { CELLAR, COW_PEN, HOME, WORKSHOP, FOREST } = stageFocusType
25
+ const list: farmhand.stageFocusType[] = [...STANDARD_VIEW_LIST]
26
+
27
+ if (showHomeScreen) {
28
+ list.unshift(HOME)
29
+ }
30
+
31
+ if (isForestUnlocked) {
32
+ list.push(FOREST)
33
+ }
34
+
35
+ if (purchasedCowPen) {
36
+ list.push(COW_PEN)
37
+ }
38
+
39
+ list.push(WORKSHOP)
40
+
41
+ if (purchasedCellar) {
42
+ list.push(CELLAR)
43
+ }
44
+
45
+ return list
46
+ }
@@ -0,0 +1,153 @@
1
+ import {
2
+ getHashQueryParams,
3
+ removeHashQueryParam,
4
+ setHashQueryParam,
5
+ withCurrentHashQuery,
6
+ } from './hashQueryParams.js'
7
+
8
+ describe('hashQueryParams', () => {
9
+ describe('getHashQueryParams', () => {
10
+ test('returns an empty URLSearchParams when there is no hash', () => {
11
+ window.history.replaceState({}, '', `${window.location.pathname}`)
12
+
13
+ expect(getHashQueryParams().toString()).toEqual('')
14
+ })
15
+
16
+ test('parses query params from the hash', () => {
17
+ window.history.replaceState(
18
+ {},
19
+ '',
20
+ `${window.location.pathname}#?view=SHOP&tab=Upgrades`
21
+ )
22
+
23
+ const params = getHashQueryParams()
24
+
25
+ expect(params.get('view')).toEqual('SHOP')
26
+ expect(params.get('tab')).toEqual('Upgrades')
27
+ })
28
+
29
+ test('parses query params from a hash with a router path prefix', () => {
30
+ window.history.replaceState(
31
+ {},
32
+ '',
33
+ `${window.location.pathname}#online/my-room?view=FIELD`
34
+ )
35
+
36
+ expect(getHashQueryParams().get('view')).toEqual('FIELD')
37
+ })
38
+ })
39
+
40
+ describe('setHashQueryParam', () => {
41
+ test('adds a query param to an empty hash', () => {
42
+ window.history.replaceState({}, '', `${window.location.pathname}`)
43
+
44
+ setHashQueryParam('view', 'SHOP')
45
+
46
+ expect(window.location.hash).toEqual('#?view=SHOP')
47
+ })
48
+
49
+ test('preserves the hash path prefix', () => {
50
+ window.history.replaceState(
51
+ {},
52
+ '',
53
+ `${window.location.pathname}#online/my-room`
54
+ )
55
+
56
+ setHashQueryParam('view', 'SHOP')
57
+
58
+ expect(window.location.hash).toEqual('#online/my-room?view=SHOP')
59
+ })
60
+
61
+ test('preserves other existing query params', () => {
62
+ window.history.replaceState(
63
+ {},
64
+ '',
65
+ `${window.location.pathname}#?tab=Upgrades`
66
+ )
67
+
68
+ setHashQueryParam('view', 'SHOP')
69
+
70
+ const params = getHashQueryParams()
71
+
72
+ expect(params.get('view')).toEqual('SHOP')
73
+ expect(params.get('tab')).toEqual('Upgrades')
74
+ })
75
+
76
+ test('overwrites an existing value for the same key', () => {
77
+ window.history.replaceState(
78
+ {},
79
+ '',
80
+ `${window.location.pathname}#?view=SHOP`
81
+ )
82
+
83
+ setHashQueryParam('view', 'FIELD')
84
+
85
+ expect(getHashQueryParams().get('view')).toEqual('FIELD')
86
+ })
87
+ })
88
+
89
+ describe('removeHashQueryParam', () => {
90
+ test('removes the given key, leaving other params intact', () => {
91
+ window.history.replaceState(
92
+ {},
93
+ '',
94
+ `${window.location.pathname}#?view=SHOP&tab=Upgrades`
95
+ )
96
+
97
+ removeHashQueryParam('tab')
98
+
99
+ const params = getHashQueryParams()
100
+
101
+ expect(params.get('view')).toEqual('SHOP')
102
+ expect(params.has('tab')).toEqual(false)
103
+ })
104
+
105
+ test('drops the "?" entirely when removing the last param', () => {
106
+ window.history.replaceState(
107
+ {},
108
+ '',
109
+ `${window.location.pathname}#online/my-room?tab=Upgrades`
110
+ )
111
+
112
+ removeHashQueryParam('tab')
113
+
114
+ expect(window.location.hash).toEqual('#online/my-room')
115
+ })
116
+
117
+ test('is a no-op when the key is not present', () => {
118
+ window.history.replaceState(
119
+ {},
120
+ '',
121
+ `${window.location.pathname}#?view=SHOP`
122
+ )
123
+
124
+ removeHashQueryParam('tab')
125
+
126
+ expect(window.location.hash).toEqual('#?view=SHOP')
127
+ })
128
+ })
129
+
130
+ describe('withCurrentHashQuery', () => {
131
+ test('returns the path unchanged when the hash has no query params', () => {
132
+ window.history.replaceState(
133
+ {},
134
+ '',
135
+ `${window.location.pathname}#online/my-room`
136
+ )
137
+
138
+ expect(withCurrentHashQuery('/')).toEqual('/')
139
+ })
140
+
141
+ test('appends the current hash query params to the given path', () => {
142
+ window.history.replaceState(
143
+ {},
144
+ '',
145
+ `${window.location.pathname}#?view=SHOP&tab=Upgrades`
146
+ )
147
+
148
+ expect(withCurrentHashQuery('/online/my-room')).toEqual(
149
+ '/online/my-room?view=SHOP&tab=Upgrades'
150
+ )
151
+ })
152
+ })
153
+ })