@applicaster/zapp-react-native-utils 15.0.0-alpha.1107527841 → 15.0.0-alpha.1123384087

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 (41) hide show
  1. package/README.md +0 -6
  2. package/appUtils/focusManager/treeDataStructure/Tree/__tests__/Tree.test.js +46 -0
  3. package/appUtils/focusManager/treeDataStructure/Tree/index.js +18 -18
  4. package/appUtils/focusManagerAux/utils/index.ts +2 -4
  5. package/appUtils/focusManagerAux/utils/utils.ios.ts +6 -3
  6. package/appUtils/playerManager/playerNative.ts +2 -1
  7. package/manifestUtils/_internals/__tests__/index.test.js +41 -0
  8. package/manifestUtils/_internals/index.js +33 -0
  9. package/manifestUtils/defaultManifestConfigurations/player.js +0 -16
  10. package/manifestUtils/fieldUtils/__tests__/fieldUtils.test.js +49 -0
  11. package/manifestUtils/fieldUtils/index.js +54 -0
  12. package/manifestUtils/index.js +6 -0
  13. package/manifestUtils/keys.js +240 -0
  14. package/manifestUtils/mobileAction/button/__tests__/mobileActionButton.test.js +168 -0
  15. package/manifestUtils/mobileAction/button/index.js +140 -0
  16. package/manifestUtils/mobileAction/container/__tests__/mobileActionButtonsContainer.test.js +104 -0
  17. package/manifestUtils/mobileAction/container/index.js +79 -0
  18. package/manifestUtils/mobileAction/groups/__tests__/buildMobileActionButtonGroups.test.js +127 -0
  19. package/manifestUtils/mobileAction/groups/defaults.js +77 -0
  20. package/manifestUtils/mobileAction/groups/index.js +80 -0
  21. package/numberUtils/__tests__/toNumber.test.ts +27 -12
  22. package/numberUtils/__tests__/toPositiveNumber.test.ts +32 -4
  23. package/numberUtils/index.ts +5 -1
  24. package/package.json +2 -2
  25. package/reactHooks/feed/index.ts +0 -2
  26. package/reactHooks/feed/useInflatedUrl.ts +1 -1
  27. package/reactHooks/state/__tests__/useComponentScreenState.test.ts +246 -0
  28. package/reactHooks/state/index.ts +2 -0
  29. package/reactHooks/state/useComponentScreenState.ts +45 -0
  30. package/refreshUtils/RefreshCoordinator/__tests__/refreshCoordinator.test.ts +161 -0
  31. package/refreshUtils/RefreshCoordinator/index.ts +216 -0
  32. package/refreshUtils/RefreshCoordinator/utils/__tests__/getDataRefreshConfig.test.ts +104 -0
  33. package/refreshUtils/RefreshCoordinator/utils/index.ts +29 -0
  34. package/screenPickerUtils/index.ts +5 -0
  35. package/screenUtils/index.ts +3 -0
  36. package/utils/__tests__/clone.test.ts +158 -0
  37. package/utils/__tests__/path.test.ts +7 -0
  38. package/utils/clone.ts +7 -0
  39. package/utils/index.ts +2 -1
  40. package/reactHooks/feed/__tests__/useFeedRefresh.test.tsx +0 -75
  41. package/reactHooks/feed/useFeedRefresh.tsx +0 -65
@@ -0,0 +1,80 @@
1
+ const { mobileActionButtonsContainer } = require("../container");
2
+ const { mobileActionButton } = require("../button");
3
+
4
+ const {
5
+ DEFAULT_MOBILE_ACTION_BUTTONS_CONTAINER_DEFAULTS,
6
+ DEFAULT_MOBILE_ACTION_BUTTON_SHARED_DEFAULTS,
7
+ DEFAULT_MOBILE_ACTION_BUTTON_PRESETS,
8
+ } = require("./defaults");
9
+
10
+ const CONTAINER_GROUP_LABEL = "Mobile Buttons Container"; // NOTE: used as key – "Mobile Buttons Container" -> "mobile_buttons_container"
11
+
12
+ const CONTAINER_GROUP_DESCRIPTION =
13
+ "Configuration for mobile action buttons container";
14
+
15
+ const BUTTON_GROUPS = [
16
+ {
17
+ index: 1,
18
+ label: "Mobile Button 1",
19
+ description: "Primary mobile action button",
20
+ },
21
+ {
22
+ index: 2,
23
+ label: "Mobile Button 2",
24
+ description: "Secondary mobile action button",
25
+ },
26
+ {
27
+ index: 3,
28
+ label: "Mobile Button 3",
29
+ description: "Tertiary mobile action button",
30
+ },
31
+ ];
32
+
33
+ /**
34
+ * Builds the shared mobile action button manifest groups while letting each
35
+ * plugin supply only its supported insertion positions and explicit overrides.
36
+ *
37
+ * @param {object} options
38
+ * @param {object} [options.containerDefaults={}]
39
+ * @param {object} [options.sharedButtonDefaults={}]
40
+ * @param {Record<number, object>} [options.buttonOverrides={}]
41
+ * @returns {object[]}
42
+ */
43
+ function buildMobileActionButtonGroups({
44
+ containerDefaults = {},
45
+ sharedButtonDefaults = {},
46
+ buttonOverrides = {},
47
+ }) {
48
+ const groups = [
49
+ mobileActionButtonsContainer({
50
+ label: CONTAINER_GROUP_LABEL,
51
+ description: CONTAINER_GROUP_DESCRIPTION,
52
+ defaults: {
53
+ ...DEFAULT_MOBILE_ACTION_BUTTONS_CONTAINER_DEFAULTS,
54
+ ...containerDefaults,
55
+ },
56
+ }),
57
+ ];
58
+
59
+ BUTTON_GROUPS.forEach(({ index, label, description }) => {
60
+ groups.push(
61
+ mobileActionButton({
62
+ isFirstButton: index === 1,
63
+ label,
64
+ description,
65
+ defaults: {
66
+ ...DEFAULT_MOBILE_ACTION_BUTTON_SHARED_DEFAULTS,
67
+ ...sharedButtonDefaults,
68
+ ...DEFAULT_MOBILE_ACTION_BUTTON_PRESETS[index],
69
+ ...(buttonOverrides[index] || {}),
70
+ },
71
+ })
72
+ );
73
+ });
74
+
75
+ return groups;
76
+ }
77
+
78
+ module.exports = {
79
+ buildMobileActionButtonGroups,
80
+ };
@@ -11,16 +11,6 @@ describe("toNumber", () => {
11
11
  });
12
12
  });
13
13
 
14
- it("return number if input is BigInt", () => {
15
- const inputs = [5n, -5n];
16
- expect.assertions(inputs.length);
17
-
18
- inputs.forEach((input) => {
19
- const output = toNumber(input);
20
- expect(output).toBe(Number(input));
21
- });
22
- });
23
-
24
14
  it("return number if input is string as number", () => {
25
15
  const inputs = ["-1", "0", "1", "100", "0.2"];
26
16
  expect.assertions(inputs.length);
@@ -34,6 +24,8 @@ describe("toNumber", () => {
34
24
  it("return undefined if input is not a number", () => {
35
25
  const inputs = [
36
26
  "vfdvf",
27
+ "5n",
28
+ "-5n",
37
29
  null,
38
30
  undefined,
39
31
  NaN,
@@ -42,8 +34,6 @@ describe("toNumber", () => {
42
34
  { test: 1 },
43
35
  [],
44
36
  [1],
45
- "5n",
46
- "-5n",
47
37
  ];
48
38
 
49
39
  expect.assertions(inputs.length);
@@ -53,4 +43,29 @@ describe("toNumber", () => {
53
43
  expect(output).toBeUndefined();
54
44
  });
55
45
  });
46
+
47
+ describe("BigInt support", () => {
48
+ // Conditional test based on BigInt availability
49
+ const isBigIntSupported = typeof BigInt !== "undefined";
50
+
51
+ if (isBigIntSupported) {
52
+ it("converts BigInt to number when BigInt is supported", () => {
53
+ expect(toNumber(BigInt(5))).toBe(5);
54
+ expect(toNumber(BigInt(0))).toBe(0);
55
+ expect(toNumber(BigInt(-10))).toBe(-10);
56
+ expect(toNumber(BigInt(1000))).toBe(1000);
57
+ });
58
+
59
+ it("handles large BigInt values that may lose precision", () => {
60
+ const largeBigInt = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1);
61
+ const result = toNumber(largeBigInt);
62
+ expect(result).toBe(Number(largeBigInt));
63
+ });
64
+ } else {
65
+ it("skips BigInt tests when BigInt is not supported", () => {
66
+ // Placeholder test to indicate BigInt is not available
67
+ expect(typeof BigInt).toBe("undefined");
68
+ });
69
+ }
70
+ });
56
71
  });
@@ -156,10 +156,38 @@ describe("toPositiveNumber", () => {
156
156
  it("handles Symbol values", () => {
157
157
  expect(toPositiveNumber(Symbol("test"))).toBeUndefined();
158
158
  });
159
+ });
159
160
 
160
- it("converts BigInt values", () => {
161
- expect(toPositiveNumber(5n)).toBe(5);
162
- expect(toPositiveNumber(-5n)).toBeUndefined();
163
- });
161
+ describe("BigInt support", () => {
162
+ // Conditional test based on BigInt availability
163
+ const isBigIntSupported = typeof BigInt !== "undefined";
164
+
165
+ if (isBigIntSupported) {
166
+ it("converts positive BigInt values to numbers when BigInt is supported", () => {
167
+ expect(toPositiveNumber(BigInt(5))).toBe(5);
168
+ expect(toPositiveNumber(BigInt(100))).toBe(100);
169
+ expect(toPositiveNumber(BigInt(1000))).toBe(1000);
170
+ });
171
+
172
+ it("returns undefined for zero BigInt", () => {
173
+ expect(toPositiveNumber(BigInt(0))).toBeUndefined();
174
+ });
175
+
176
+ it("returns undefined for negative BigInt values", () => {
177
+ expect(toPositiveNumber(BigInt(-5))).toBeUndefined();
178
+ expect(toPositiveNumber(BigInt(-100))).toBeUndefined();
179
+ });
180
+
181
+ it("handles large positive BigInt values", () => {
182
+ const largeBigInt = BigInt(Number.MAX_SAFE_INTEGER);
183
+ const result = toPositiveNumber(largeBigInt);
184
+ expect(result).toBe(Number(largeBigInt));
185
+ });
186
+ } else {
187
+ it("skips BigInt tests when BigInt is not supported", () => {
188
+ // Placeholder test to indicate BigInt is not available
189
+ expect(typeof BigInt).toBe("undefined");
190
+ });
191
+ }
164
192
  });
165
193
  });
@@ -8,7 +8,11 @@ export const toNumber = (value: unknown): number | undefined => {
8
8
  return undefined;
9
9
  }
10
10
 
11
- if (R.is(Number, value) || R.is(BigInt, value) || R.is(String, value)) {
11
+ // Feature-detect BigInt support to avoid ReferenceError on older runtimes
12
+ const isBigIntSupported = typeof BigInt !== "undefined";
13
+ const isBigIntValue = isBigIntSupported && R.is(BigInt, value);
14
+
15
+ if (R.is(Number, value) || isBigIntValue || R.is(String, value)) {
12
16
  const numberOrNan = Number(value);
13
17
 
14
18
  return Number.isNaN(numberOrNan) ? undefined : numberOrNan;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applicaster/zapp-react-native-utils",
3
- "version": "15.0.0-alpha.1107527841",
3
+ "version": "15.0.0-alpha.1123384087",
4
4
  "description": "Applicaster Zapp React Native utilities package",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "homepage": "https://github.com/applicaster/quickbrick#readme",
29
29
  "dependencies": {
30
- "@applicaster/applicaster-types": "15.0.0-alpha.1107527841",
30
+ "@applicaster/applicaster-types": "15.0.0-alpha.1123384087",
31
31
  "buffer": "^5.2.1",
32
32
  "camelize": "^1.0.0",
33
33
  "dayjs": "^1.11.10",
@@ -1,5 +1,3 @@
1
- export { useFeedRefresh, feedRefreshLogger } from "./useFeedRefresh";
2
-
3
1
  export { useFeedLoader } from "./useFeedLoader";
4
2
 
5
3
  export { getSearchContext, getInflatedDataSourceUrl } from "./useInflatedUrl";
@@ -45,7 +45,7 @@ interface GetInflatedDataSourceUrl {
45
45
  (properties: {
46
46
  source?: string;
47
47
  contexts: {
48
- entry?: ZappEntry;
48
+ entry?: ZappEntry & { parent?: ZappEntry };
49
49
  screen?: ZappRiver;
50
50
  search?: object | string;
51
51
  };
@@ -0,0 +1,246 @@
1
+ import { renderHook, act } from "@testing-library/react-native";
2
+ import { useComponentScreenState } from "../useComponentScreenState";
3
+ import { useScreenContextV2 } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext";
4
+
5
+ import { create } from "zustand";
6
+
7
+ const createMockStore = () => create(() => ({}));
8
+
9
+ // Mock the external dependencies
10
+ jest.mock(
11
+ "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext",
12
+ () => ({
13
+ useScreenContextV2: jest.fn(),
14
+ })
15
+ );
16
+
17
+ const mockUseScreenContextV2 = useScreenContextV2 as jest.MockedFunction<
18
+ typeof useScreenContextV2
19
+ >;
20
+
21
+ describe("useComponentScreenState", () => {
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+ });
25
+
26
+ it("should return initial value when no state exists for componentId", () => {
27
+ const mockStore = createMockStore();
28
+
29
+ mockUseScreenContextV2.mockReturnValue({
30
+ _componentStateStore: mockStore,
31
+ } as any);
32
+
33
+ const initialValue = "default-value";
34
+ const componentId = "test-component";
35
+
36
+ const { result } = renderHook(() =>
37
+ useComponentScreenState(componentId, initialValue)
38
+ );
39
+
40
+ expect(result.current[0]).toBe(initialValue);
41
+ });
42
+
43
+ it("should return stored value when state exists for componentId", () => {
44
+ const mockStore = createMockStore();
45
+ mockStore.setState({ "existing-component": "stored-value" });
46
+
47
+ mockUseScreenContextV2.mockReturnValue({
48
+ _componentStateStore: mockStore,
49
+ } as any);
50
+
51
+ const { result } = renderHook(() =>
52
+ useComponentScreenState("existing-component", "default")
53
+ );
54
+
55
+ expect(result.current[0]).toBe("stored-value");
56
+ });
57
+
58
+ it("should update state when setter is called", () => {
59
+ const mockStore = createMockStore();
60
+
61
+ mockUseScreenContextV2.mockReturnValue({
62
+ _componentStateStore: mockStore,
63
+ } as any);
64
+
65
+ const { result } = renderHook(() =>
66
+ useComponentScreenState("update-test", "initial")
67
+ );
68
+
69
+ // Verify initial value
70
+ expect(result.current[0]).toBe("initial");
71
+
72
+ // Update the value
73
+ act(() => {
74
+ result.current[1]("updated-value");
75
+ });
76
+
77
+ // Verify updated value
78
+ expect(result.current[0]).toBe("updated-value");
79
+ });
80
+
81
+ it("should handle different data types correctly", () => {
82
+ const mockStore = createMockStore();
83
+
84
+ mockUseScreenContextV2.mockReturnValue({
85
+ _componentStateStore: mockStore,
86
+ } as any);
87
+
88
+ // Test with number
89
+ const { result: numberResult } = renderHook(() =>
90
+ useComponentScreenState<number>("number-test", 42)
91
+ );
92
+
93
+ expect(numberResult.current[0]).toBe(42);
94
+
95
+ // Test with boolean
96
+ const { result: boolResult } = renderHook(() =>
97
+ useComponentScreenState<boolean>("bool-test", true)
98
+ );
99
+
100
+ expect(boolResult.current[0]).toBe(true);
101
+
102
+ // Test with object
103
+ const testObj = { key: "value" };
104
+
105
+ const { result: objResult } = renderHook(() =>
106
+ useComponentScreenState<{ key: string }>("obj-test", testObj)
107
+ );
108
+
109
+ expect(objResult.current[0]).toEqual(testObj);
110
+ });
111
+
112
+ it("should maintain separate states for different componentIds", () => {
113
+ const mockStore = createMockStore();
114
+
115
+ mockUseScreenContextV2.mockReturnValue({
116
+ _componentStateStore: mockStore,
117
+ } as any);
118
+
119
+ const { result: firstResult } = renderHook(() =>
120
+ useComponentScreenState("first", "first-initial")
121
+ );
122
+
123
+ const { result: secondResult } = renderHook(() =>
124
+ useComponentScreenState("second", "second-initial")
125
+ );
126
+
127
+ // Verify initial values are separate
128
+ expect(firstResult.current[0]).toBe("first-initial");
129
+ expect(secondResult.current[0]).toBe("second-initial");
130
+
131
+ // Update first component's state
132
+ act(() => {
133
+ firstResult.current[1]("first-updated");
134
+ });
135
+
136
+ // Verify only first component's state changed
137
+ expect(firstResult.current[0]).toBe("first-updated");
138
+ expect(secondResult.current[0]).toBe("second-initial");
139
+ });
140
+
141
+ it("should return a memoized setter function", () => {
142
+ const mockStore = createMockStore();
143
+
144
+ mockUseScreenContextV2.mockReturnValue({
145
+ _componentStateStore: mockStore,
146
+ } as any);
147
+
148
+ const { result, rerender } = renderHook(
149
+ ({ id, initial }) => useComponentScreenState(id, initial),
150
+ {
151
+ initialProps: { id: "memo-test", initial: "initial" },
152
+ }
153
+ );
154
+
155
+ const firstSetter = result.current[1];
156
+
157
+ // Rerender with same props
158
+ rerender({ id: "memo-test", initial: "initial" });
159
+
160
+ const secondSetter = result.current[1];
161
+
162
+ // Setter should be the same instance due to useCallback
163
+ expect(firstSetter).toBe(secondSetter);
164
+ });
165
+
166
+ it("should update setter when componentId changes", () => {
167
+ const mockStore = createMockStore();
168
+
169
+ mockUseScreenContextV2.mockReturnValue({
170
+ _componentStateStore: mockStore,
171
+ } as any);
172
+
173
+ const { result, rerender } = renderHook(
174
+ ({ id, initial }) => useComponentScreenState(id, initial),
175
+ {
176
+ initialProps: { id: "old-id", initial: "initial" },
177
+ }
178
+ );
179
+
180
+ const firstSetter = result.current[1];
181
+
182
+ // Rerender with different componentId
183
+ rerender({ id: "new-id", initial: "initial" });
184
+
185
+ const secondSetter = result.current[1];
186
+
187
+ // Setter should be different because componentId changed
188
+ expect(firstSetter).not.toBe(secondSetter);
189
+ });
190
+
191
+ it("should call store.setState with correct parameters", () => {
192
+ const mockStore = createMockStore();
193
+ const setStateSpy = jest.spyOn(mockStore, "setState");
194
+
195
+ mockUseScreenContextV2.mockReturnValue({
196
+ _componentStateStore: mockStore,
197
+ } as any);
198
+
199
+ const { result } = renderHook(() =>
200
+ useComponentScreenState("state-test", "initial")
201
+ );
202
+
203
+ const newValue = "new-value";
204
+
205
+ act(() => {
206
+ result.current[1](newValue);
207
+ });
208
+
209
+ expect(setStateSpy).toHaveBeenCalledWith({
210
+ "state-test": newValue,
211
+ });
212
+ });
213
+
214
+ it("should handle falsy initial values correctly", () => {
215
+ const mockStore = createMockStore();
216
+
217
+ mockUseScreenContextV2.mockReturnValue({
218
+ _componentStateStore: mockStore,
219
+ } as any);
220
+
221
+ // Test with falsy values
222
+ const { result: nullResult } = renderHook(() =>
223
+ useComponentScreenState("null-test", null)
224
+ );
225
+
226
+ expect(nullResult.current[0]).toBeNull();
227
+
228
+ const { result: falseResult } = renderHook(() =>
229
+ useComponentScreenState("false-test", false)
230
+ );
231
+
232
+ expect(falseResult.current[0]).toBe(false);
233
+
234
+ const { result: zeroResult } = renderHook(() =>
235
+ useComponentScreenState("zero-test", 0)
236
+ );
237
+
238
+ expect(zeroResult.current[0]).toBe(0);
239
+
240
+ const { result: emptyStrResult } = renderHook(() =>
241
+ useComponentScreenState("empty-test", "")
242
+ );
243
+
244
+ expect(emptyStrResult.current[0]).toBe("");
245
+ });
246
+ });
@@ -3,3 +3,5 @@ export { useRivers } from "./useRivers";
3
3
  export { useHomeRiver, getHomeRiver } from "./useHomeRiver";
4
4
 
5
5
  export { ZStoreProvider, useZStore } from "./ZStoreProvider";
6
+
7
+ export { useComponentScreenState } from "./useComponentScreenState";
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import { useScreenContextV2 } from "@applicaster/zapp-react-native-utils/reactHooks/screen/useScreenContext";
3
+ import { useStore } from "zustand";
4
+
5
+ /**
6
+ * A custom hook that provides persistent state storage across component re-mounts
7
+ * using the screen's component state store.
8
+ *
9
+ * @see {@link DOCS/adr/0010-useComponentScreenState.md} - ADR explaining why useComponentScreenState is used for List/Hero/Grid/Gallery components
10
+ *
11
+ * The state is shared across all components using the same componentId within the same screen context.
12
+ * If no value exists for the componentId, the initial value is returned as a fallback but is not persisted in the store.
13
+ * The initial value must be explicitly set using the returned setter function to persist it.
14
+ *
15
+ * @template T - The type of value being stored
16
+ * @param componentId - Unique identifier for this state in the component state store
17
+ * @param initialValue - The default value if no value exists for the componentId
18
+ * @returns A tuple containing the current value and a memoized setter function
19
+ *
20
+ * @example
21
+ * ```tsx
22
+ * const [count, setCount] = useComponentScreenState<number>('counter', 0);
23
+ * const [name, setName] = useComponentScreenState<string>('username', 'Anonymous');
24
+ * ```
25
+ */
26
+ export const useComponentScreenState = <T = unknown>(
27
+ componentId: string,
28
+ initialValue: T
29
+ ): [T, (value: T) => void] => {
30
+ const store = useScreenContextV2()._componentStateStore;
31
+
32
+ const value = useStore(
33
+ store,
34
+ (state) => (state[componentId] as T | undefined) ?? initialValue
35
+ );
36
+
37
+ const setValue = React.useCallback(
38
+ (nextValue: T) => {
39
+ store.setState({ [componentId]: nextValue });
40
+ },
41
+ [componentId, store]
42
+ );
43
+
44
+ return [value, setValue] as const;
45
+ };
@@ -0,0 +1,161 @@
1
+ import { refreshCoordinator } from "..";
2
+ import { getDataRefreshConfig } from "../utils";
3
+ import { Subscription } from "rxjs";
4
+
5
+ jest.mock("../utils", () => ({
6
+ getDataRefreshConfig: jest.fn(),
7
+ }));
8
+
9
+ describe("RefreshCoordinator", () => {
10
+ const component = { id: "c1" } as ZappUIComponent;
11
+ const screenId = "screen-1";
12
+
13
+ beforeEach(() => {
14
+ jest.clearAllMocks();
15
+ refreshCoordinator.clear();
16
+ jest.useFakeTimers();
17
+ });
18
+
19
+ afterEach(() => {
20
+ jest.runOnlyPendingTimers();
21
+ jest.useRealTimers();
22
+ });
23
+
24
+ // ----------------------------------------
25
+ // Singleton
26
+ // ----------------------------------------
27
+ it("returns the same instance via getInstance", () => {
28
+ const instance1 = refreshCoordinator;
29
+ const instance2 = (refreshCoordinator as any).constructor.getInstance();
30
+ expect(instance1).toBe(instance2);
31
+ });
32
+
33
+ // ----------------------------------------
34
+ // register & unregister
35
+ // ----------------------------------------
36
+ it("register starts a timer and returns an unregister function", () => {
37
+ (getDataRefreshConfig as jest.Mock).mockReturnValue({
38
+ refreshIntervalMs: 1000,
39
+ isRefreshingEnabled: true,
40
+ });
41
+
42
+ const unregister = refreshCoordinator.register(component, screenId);
43
+
44
+ expect(typeof unregister).toBe("function");
45
+ expect((refreshCoordinator as any).timers.size).toBe(1);
46
+
47
+ // Calling unregister should remove timer
48
+ unregister();
49
+ expect((refreshCoordinator as any).timers.size).toBe(0);
50
+ });
51
+
52
+ it("does not register if refreshing is disabled", () => {
53
+ (getDataRefreshConfig as jest.Mock).mockReturnValue({
54
+ refreshIntervalMs: 1000,
55
+ isRefreshingEnabled: false,
56
+ });
57
+
58
+ const unregister = refreshCoordinator.register(component, screenId);
59
+
60
+ expect(unregister).toBeInstanceOf(Function);
61
+ expect((refreshCoordinator as any).timers.size).toBe(0);
62
+ });
63
+
64
+ it("replaces existing timer if component is re-registered", () => {
65
+ (getDataRefreshConfig as jest.Mock).mockReturnValue({
66
+ refreshIntervalMs: 1000,
67
+ isRefreshingEnabled: true,
68
+ });
69
+
70
+ const unregister1 = refreshCoordinator.register(component, screenId);
71
+ const unregister2 = refreshCoordinator.register(component, screenId);
72
+
73
+ expect((refreshCoordinator as any).timers.size).toBe(1);
74
+ expect(unregister1).toBeInstanceOf(Function);
75
+ expect(unregister2).toBeInstanceOf(Function);
76
+ });
77
+
78
+ // ----------------------------------------
79
+ // subscribeTo
80
+ // ----------------------------------------
81
+ it("subscribeTo only triggers listener for matching component & screen", () => {
82
+ (getDataRefreshConfig as jest.Mock).mockReturnValue({
83
+ refreshIntervalMs: 1000,
84
+ isRefreshingEnabled: true,
85
+ });
86
+
87
+ const listener = jest.fn();
88
+ refreshCoordinator.register(component, screenId);
89
+ const sub = refreshCoordinator.subscribeTo(component, screenId, listener);
90
+
91
+ // Emit a refresh manually via private Subject
92
+ (refreshCoordinator as any).refresh$.next({ component, screenId });
93
+ expect(listener).toHaveBeenCalledTimes(1);
94
+
95
+ // Non-matching component should not call listener
96
+ (refreshCoordinator as any).refresh$.next({
97
+ component: { id: "other" } as ZappUIComponent,
98
+ screenId,
99
+ });
100
+
101
+ expect(listener).toHaveBeenCalledTimes(1);
102
+
103
+ sub.unsubscribe();
104
+ });
105
+
106
+ // ----------------------------------------
107
+ // subscribeToAny
108
+ // ----------------------------------------
109
+ it("subscribeToAny triggers listener with throttling and replaces previous listener", () => {
110
+ const listener1 = jest.fn();
111
+ const listener2 = jest.fn();
112
+
113
+ // First subscription
114
+ const sub1: Subscription = refreshCoordinator.subscribeToAny(listener1);
115
+ expect(sub1).toBeInstanceOf(Subscription);
116
+
117
+ // Second subscription replaces first
118
+ const sub2: Subscription = refreshCoordinator.subscribeToAny(listener2);
119
+ expect(sub2).toBeInstanceOf(Subscription);
120
+
121
+ // Emit multiple events quickly
122
+ (refreshCoordinator as any).refresh$.next({ component, screenId });
123
+ (refreshCoordinator as any).refresh$.next({ component, screenId });
124
+
125
+ // Because of throttleTime(500ms), fast events are ignored
126
+ jest.advanceTimersByTime(500);
127
+ expect(listener1).not.toHaveBeenCalled();
128
+ expect(listener2).toHaveBeenCalledTimes(1);
129
+
130
+ sub2.unsubscribe();
131
+ });
132
+
133
+ // ----------------------------------------
134
+ // clear
135
+ // ----------------------------------------
136
+ it("clear stops all timers, anyListener, and recreates subject", () => {
137
+ (getDataRefreshConfig as jest.Mock).mockReturnValue({
138
+ refreshIntervalMs: 1000,
139
+ isRefreshingEnabled: true,
140
+ });
141
+
142
+ const listener = jest.fn();
143
+ refreshCoordinator.register(component, screenId);
144
+ refreshCoordinator.subscribeToAny(listener);
145
+
146
+ expect((refreshCoordinator as any).timers.size).toBe(1);
147
+ expect((refreshCoordinator as any).anyListenerSubscription).toBeDefined();
148
+
149
+ refreshCoordinator.clear();
150
+
151
+ expect((refreshCoordinator as any).timers.size).toBe(0);
152
+ expect((refreshCoordinator as any).anyListenerSubscription).toBeUndefined();
153
+
154
+ // New events on new Subject still work
155
+ const newListener = jest.fn();
156
+ refreshCoordinator.subscribeToAny(newListener);
157
+ (refreshCoordinator as any).refresh$.next({ component, screenId });
158
+ jest.advanceTimersByTime(500);
159
+ expect(newListener).toHaveBeenCalled();
160
+ });
161
+ });