@applicaster/quick-brick-core 16.0.0-alpha.1701179921 → 16.0.0-alpha.1733304298

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/App/ActionsProvider/LegacyActionsRegistryAdapter.tsx +16 -0
  2. package/App/ActionsProvider/__tests__/LegacyActionsRegistryAdapter.test.tsx +64 -0
  3. package/App/DeepLinking/URLSchemeHandler/SchemeHandlerHooks/usePresentSchemeHandler.ts +3 -0
  4. package/App/NavigationProvider/NavigationProvider.tsx +14 -5
  5. package/App/NavigationProvider/__tests__/navToolbox.test.ts +227 -0
  6. package/App/NavigationProvider/navToolbox.ts +224 -0
  7. package/App/NavigationProvider/utils.ts +21 -0
  8. package/App/NetworkStatusProvider/NetworkStatusProvider.tsx +32 -4
  9. package/App/NetworkStatusProvider/ResumeVerificationController.ts +282 -0
  10. package/App/NetworkStatusProvider/__tests__/NetworkStatusProvider.test.tsx +152 -1
  11. package/App/NetworkStatusProvider/__tests__/ResumeVerificationController.test.ts +471 -0
  12. package/App/NetworkStatusProvider/__tests__/resumeVerification.test.ts +126 -0
  13. package/App/NetworkStatusProvider/__tests__/useResumeVerifiedNetInfo.test.ts +189 -0
  14. package/App/NetworkStatusProvider/resumeVerification.ts +54 -0
  15. package/App/NetworkStatusProvider/useResumeVerifiedNetInfo.ts +33 -0
  16. package/App/index.tsx +8 -2
  17. package/App/layoutPreview/LayoutPreviewReminder.tsx +136 -0
  18. package/App/layoutPreview/__tests__/persistence.test.ts +63 -0
  19. package/App/layoutPreview/persistence.ts +228 -0
  20. package/App/remoteContextReloader/getRemoteContextData/getNativeRemoteContextData.ts +64 -19
  21. package/App/uiActions/index.ts +13 -0
  22. package/App/uiActions/muteUnmute/__tests__/assetResolver.test.ts +226 -0
  23. package/App/uiActions/muteUnmute/__tests__/binding.test.ts +51 -0
  24. package/App/uiActions/muteUnmute/__tests__/defaultAssets.test.ts +73 -0
  25. package/App/uiActions/muteUnmute/__tests__/entryState.test.ts +57 -0
  26. package/App/uiActions/muteUnmute/__tests__/labelResolver.test.ts +35 -0
  27. package/App/uiActions/muteUnmute/assetResolver.ts +87 -0
  28. package/App/uiActions/muteUnmute/assets/action/flavour_1/mute.png +0 -0
  29. package/App/uiActions/muteUnmute/assets/action/flavour_1/unmute.png +0 -0
  30. package/App/uiActions/muteUnmute/assets/action/flavour_2/mute.png +0 -0
  31. package/App/uiActions/muteUnmute/assets/action/flavour_2/unmute.png +0 -0
  32. package/App/uiActions/muteUnmute/assets/button/flavour_1/mute.png +0 -0
  33. package/App/uiActions/muteUnmute/assets/button/flavour_1/unmute.png +0 -0
  34. package/App/uiActions/muteUnmute/assets/button/flavour_2/mute.png +0 -0
  35. package/App/uiActions/muteUnmute/assets/button/flavour_2/unmute.png +0 -0
  36. package/App/uiActions/muteUnmute/defaultAssets.ts +33 -0
  37. package/App/uiActions/muteUnmute/entryState.ts +28 -0
  38. package/App/uiActions/muteUnmute/index.ts +79 -0
  39. package/App/uiActions/muteUnmute/labelResolver.ts +15 -0
  40. package/App/uiActions/muteUnmute/types.ts +18 -0
  41. package/package.json +8 -8
@@ -0,0 +1,228 @@
1
+ import { NativeModules } from "react-native";
2
+ import { localStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage";
3
+
4
+ // Separate from active_layout_id, which the switchLayout action owns.
5
+ export const LAYOUT_PREVIEW_ID_KEY = "layout_preview_id";
6
+
7
+ export const LAYOUT_PREVIEW_ACK_KEY = "layout_preview_ack_at";
8
+
9
+ // Set by Back to Default; makes the reload that follows skip layout selectors once.
10
+ export const LAYOUT_PREVIEW_RESTORE_DEFAULT_KEY =
11
+ "layout_preview_restore_default";
12
+
13
+ export const SUPPRESSION_MS = 2 * 60 * 60 * 1000;
14
+
15
+ export const PREVIEW_TITLE = "You're Viewing a Preview Layout";
16
+
17
+ export const PREVIEW_MESSAGE =
18
+ "This device is currently displaying a preview layout instead of the version default layout.";
19
+
20
+ export const PREVIEW_CONTINUE = "Continue Preview";
21
+
22
+ export const PREVIEW_BACK = "Back to Default Layout";
23
+
24
+ export const UNAVAILABLE_TITLE = "Preview layout unavailable";
25
+
26
+ export const UNAVAILABLE_MESSAGE =
27
+ "This device was returned to the version default layout.";
28
+
29
+ export type PreviewApplyResult = "applied" | "absent" | "invalid" | "default";
30
+
31
+ let unavailableNotice = false;
32
+
33
+ export function shouldKeepOverride(
34
+ layoutId: string | null | undefined,
35
+ defaultId: string | null | undefined
36
+ ): boolean {
37
+ return Boolean(layoutId) && layoutId !== defaultId;
38
+ }
39
+
40
+ export function shouldShowReminder(
41
+ hasOverride: boolean,
42
+ ackAt: number | null,
43
+ now: number
44
+ ): boolean {
45
+ if (!hasOverride) return false;
46
+ if (ackAt == null || Number.isNaN(ackAt)) return true;
47
+
48
+ return now - ackAt >= SUPPRESSION_MS;
49
+ }
50
+
51
+ export function markPreviewUnavailable(): void {
52
+ unavailableNotice = true;
53
+ }
54
+
55
+ export function consumePreviewUnavailable(): boolean {
56
+ const pending = unavailableNotice;
57
+ unavailableNotice = false;
58
+
59
+ return pending;
60
+ }
61
+
62
+ async function removeKey(key: string): Promise<void> {
63
+ try {
64
+ await localStorage.removeItem(key);
65
+ } catch {
66
+ // key was already absent
67
+ }
68
+ }
69
+
70
+ async function readOverrideId(): Promise<string | null> {
71
+ try {
72
+ const id = await localStorage.getItem(LAYOUT_PREVIEW_ID_KEY);
73
+
74
+ return id || null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ export async function getDefaultLayoutId(): Promise<string | null> {
81
+ try {
82
+ const id = await NativeModules.AppLoaderBridge?.getDefaultLayoutId?.();
83
+
84
+ return id || null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ export async function clearLayoutPreview(): Promise<void> {
91
+ await removeKey(LAYOUT_PREVIEW_ID_KEY);
92
+ await removeKey(LAYOUT_PREVIEW_ACK_KEY);
93
+ }
94
+
95
+ /**
96
+ * Drops the pin and points the loader at the version default.
97
+ * Used by Back to Default and by a preview link that targets the default layout.
98
+ */
99
+ export async function returnToDefaultLayout(): Promise<void> {
100
+ const defaultId = await getDefaultLayoutId();
101
+
102
+ await clearLayoutPreview();
103
+
104
+ if (!defaultId || !NativeModules.AppLoaderBridge?.switchLayout) return;
105
+
106
+ try {
107
+ await NativeModules.AppLoaderBridge.switchLayout(defaultId, null);
108
+ } catch {
109
+ // the pin is already gone; the next load uses the version default
110
+ }
111
+ }
112
+
113
+ /** Back to Default from the reminder. The app reloads next, and that load must stay on the version default. */
114
+ export async function leavePreviewForDefault(): Promise<void> {
115
+ await localStorage.setItem(LAYOUT_PREVIEW_RESTORE_DEFAULT_KEY, "true");
116
+ await returnToDefaultLayout();
117
+ }
118
+
119
+ /** Same as returnToDefaultLayout, plus a one-shot unavailable notice. */
120
+ export async function restoreInvalidPreview(): Promise<void> {
121
+ markPreviewUnavailable();
122
+ await returnToDefaultLayout();
123
+ }
124
+
125
+ /**
126
+ * Pins a layout id, or clears the pin when the id is the version default.
127
+ * A storage failure still leaves the in-session preview in place.
128
+ */
129
+ export async function rememberLayoutPreview(layoutId: string): Promise<void> {
130
+ if (!layoutId) return;
131
+
132
+ try {
133
+ const defaultId = await getDefaultLayoutId();
134
+
135
+ if (!shouldKeepOverride(layoutId, defaultId)) {
136
+ await returnToDefaultLayout();
137
+
138
+ return;
139
+ }
140
+
141
+ const current = await readOverrideId();
142
+
143
+ if (current !== layoutId) {
144
+ await removeKey(LAYOUT_PREVIEW_ACK_KEY);
145
+ }
146
+
147
+ await localStorage.setItem(LAYOUT_PREVIEW_ID_KEY, layoutId);
148
+ } catch {
149
+ // this session already loaded the layout; the pin is best-effort
150
+ }
151
+ }
152
+
153
+ export async function acknowledgeLayoutPreview(): Promise<void> {
154
+ await localStorage.setItem(LAYOUT_PREVIEW_ACK_KEY, String(Date.now()));
155
+ }
156
+
157
+ export async function readReminderState(): Promise<{
158
+ hasOverride: boolean;
159
+ ackAt: number | null;
160
+ }> {
161
+ const layoutId = await readOverrideId();
162
+ const defaultId = await getDefaultLayoutId();
163
+ let ackRaw: string | null = null;
164
+
165
+ try {
166
+ ackRaw = await localStorage.getItem(LAYOUT_PREVIEW_ACK_KEY);
167
+ } catch {
168
+ ackRaw = null;
169
+ }
170
+
171
+ const ackAt = ackRaw == null || ackRaw === "" ? null : Number(ackRaw);
172
+
173
+ return {
174
+ hasOverride: shouldKeepOverride(layoutId, defaultId),
175
+ ackAt: ackAt != null && !Number.isNaN(ackAt) ? ackAt : null,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Re-applies a stored preview before layout-manager selection.
181
+ * "invalid" means the pin was cleared and the version default was restored,
182
+ * so the caller must not run selectLayout over the top of that fallback.
183
+ */
184
+ export async function applyPersistedLayoutPreview(): Promise<PreviewApplyResult> {
185
+ let restoreDefault = null;
186
+
187
+ try {
188
+ restoreDefault = await localStorage.getItem(
189
+ LAYOUT_PREVIEW_RESTORE_DEFAULT_KEY
190
+ );
191
+ } catch {
192
+ restoreDefault = null;
193
+ }
194
+
195
+ if (restoreDefault) {
196
+ await removeKey(LAYOUT_PREVIEW_RESTORE_DEFAULT_KEY);
197
+
198
+ const defaultId = await getDefaultLayoutId();
199
+
200
+ if (defaultId) {
201
+ await NativeModules.AppLoaderBridge.switchLayout(defaultId, null);
202
+ }
203
+
204
+ return "default";
205
+ }
206
+
207
+ const layoutId = await readOverrideId();
208
+
209
+ if (!layoutId) return "absent";
210
+
211
+ const defaultId = await getDefaultLayoutId();
212
+
213
+ if (!shouldKeepOverride(layoutId, defaultId)) {
214
+ await clearLayoutPreview();
215
+
216
+ return "absent";
217
+ }
218
+
219
+ try {
220
+ await NativeModules.AppLoaderBridge.switchLayout(layoutId, null);
221
+
222
+ return "applied";
223
+ } catch {
224
+ await restoreInvalidPreview();
225
+
226
+ return "invalid";
227
+ }
228
+ }
@@ -14,6 +14,56 @@ import { ACTIVE_LAYOUT_ID_STORAGE_KEY } from "../consts";
14
14
  const { AppLoaderBridge } = NativeModules;
15
15
  import { localStorage } from "@applicaster/zapp-react-native-bridge/ZappStorage/LocalStorage";
16
16
  import { useErrorStore } from "../../ErrorBoundary/store";
17
+ import {
18
+ PreviewApplyResult,
19
+ applyPersistedLayoutPreview,
20
+ restoreInvalidPreview,
21
+ } from "../../layoutPreview/persistence";
22
+
23
+ /**
24
+ * Points the loader at the layout to fetch. A persisted device preview wins
25
+ * over the layout-manager plugin (experiment, geo, language) and over active_layout_id.
26
+ */
27
+ async function selectActiveLayout(
28
+ runtimeConfigurationUrls,
29
+ plugins,
30
+ isTabletDevice: boolean
31
+ ): Promise<PreviewApplyResult> {
32
+ let preview: PreviewApplyResult = "absent";
33
+
34
+ try {
35
+ preview = await applyPersistedLayoutPreview();
36
+
37
+ if (preview !== "absent") return preview;
38
+
39
+ const layoutManagerPlugin = R.find(
40
+ R.pathSatisfies(R.is(Function), ["module", "selectLayout"])
41
+ )(plugins);
42
+
43
+ if (layoutManagerPlugin) {
44
+ await layoutManagerPlugin.module?.selectLayout({
45
+ runtimeConfigurationUrls,
46
+ plugin: layoutManagerPlugin,
47
+ isTablet: isTabletDevice,
48
+ });
49
+
50
+ return preview;
51
+ }
52
+
53
+ const layoutId = await localStorage.getItem(ACTIVE_LAYOUT_ID_STORAGE_KEY);
54
+
55
+ if (layoutId) {
56
+ await AppLoaderBridge?.switchLayout(layoutId, null);
57
+ }
58
+ } catch (error) {
59
+ logger.error({
60
+ message: "Error selecting layout using layout manager plugin",
61
+ data: error,
62
+ });
63
+ }
64
+
65
+ return preview;
66
+ }
17
67
 
18
68
  export async function getNativeRemoteContextData(
19
69
  dispatch,
@@ -35,32 +85,27 @@ export async function getNativeRemoteContextData(
35
85
 
36
86
  loadAppContextData(dispatch, R.mergeAll([...generalConfiguration]));
37
87
 
38
- try {
39
- const layoutManagerPlugin = R.find(
40
- R.pathSatisfies(R.is(Function), ["module", "selectLayout"])
41
- )(plugins);
88
+ const preview = await selectActiveLayout(
89
+ runtimeConfigurationUrls,
90
+ plugins,
91
+ isTabletDevice
92
+ );
42
93
 
43
- if (layoutManagerPlugin) {
44
- await layoutManagerPlugin.module?.selectLayout({
45
- runtimeConfigurationUrls,
46
- plugin: layoutManagerPlugin,
47
- isTablet: isTabletDevice,
48
- });
49
- } else {
50
- const layoutId = await localStorage.getItem(ACTIVE_LAYOUT_ID_STORAGE_KEY);
94
+ let layout;
51
95
 
52
- if (layoutId) {
53
- await AppLoaderBridge?.switchLayout(layoutId, null);
54
- }
55
- }
96
+ try {
97
+ layout = await getPromiseForType("layout");
56
98
  } catch (error) {
99
+ if (preview !== "applied") throw error;
100
+
57
101
  logger.error({
58
- message: "Error selecting layout using layout manager plugin",
102
+ message: "Persisted preview layout failed to load",
59
103
  data: error,
60
104
  });
61
- }
62
105
 
63
- const layout = await getPromiseForType("layout");
106
+ await restoreInvalidPreview();
107
+ layout = await getPromiseForType("layout");
108
+ }
64
109
 
65
110
  const layoutLoadingErrorMessage = "Failed to load layout data";
66
111
 
@@ -0,0 +1,13 @@
1
+ import { isTV } from "@applicaster/zapp-react-native-utils/reactUtils";
2
+
3
+ import { registerMuteUnmuteUIAction } from "./muteUnmute";
4
+
5
+ /**
6
+ * App-wide UI actions owned by QuickBrick, registered once at startup.
7
+ * Actions scoped to a live object (e.g. player speed) register with their owner.
8
+ */
9
+ export const registerCoreUIActions = (): (() => void) => {
10
+ const unregisters = isTV() ? [] : [registerMuteUnmuteUIAction()];
11
+
12
+ return () => unregisters.forEach((unregister) => unregister());
13
+ };
@@ -0,0 +1,226 @@
1
+ import {
2
+ resolveActionAssets,
3
+ resolveButtonAssets,
4
+ isConfiguredAsset,
5
+ firstConfiguredAsset,
6
+ selectActionAsset,
7
+ toFlavourTuple,
8
+ } from "../assetResolver";
9
+ import { ACTION_DEFAULT_ASSETS, BUTTON_DEFAULT_ASSETS } from "../defaultAssets";
10
+
11
+ const createManifest = require("../../../../../../plugins/mute-unmute-action/manifests/manifest.config");
12
+
13
+ // distinct value per slot, so a resolver mixing up flavours or states is caught.
14
+ // The real defaults are covered by defaultAssets.test.ts
15
+ jest.mock("../defaultAssets", () => {
16
+ const action = (f: string) => ({
17
+ mute: `action_${f}_mute`,
18
+ unmute: `action_${f}_unmute`,
19
+ });
20
+
21
+ const button = (f: string) => ({
22
+ mute: `button_${f}_mute`,
23
+ unmute: `button_${f}_unmute`,
24
+ });
25
+
26
+ return {
27
+ ACTION_DEFAULT_ASSETS: { flavour_1: action("1"), flavour_2: action("2") },
28
+ BUTTON_DEFAULT_ASSETS: { flavour_1: button("1"), flavour_2: button("2") },
29
+ };
30
+ });
31
+
32
+ const FLAVOURS = ["flavour_1", "flavour_2"];
33
+ const ACTION_STATES = ["mute", "unmute"];
34
+ const BUTTON_STATES = ["mute", "unmute"];
35
+
36
+ const actionKey = (flavour: string, state: string) =>
37
+ `mobile_action_${flavour}_${state}_asset`;
38
+
39
+ const buttonKey = (flavour: string, state: string) =>
40
+ `mobile_button_${flavour}_${state}_asset`;
41
+
42
+ describe("isConfiguredAsset", () => {
43
+ it.each([
44
+ ["a URL", "https://x.com/a.png", true],
45
+ ["an empty string", "", false],
46
+ ["whitespace", " \n", false],
47
+ ["undefined", undefined, false],
48
+ ["null", null, false],
49
+ ["a number", 5, false],
50
+ ["an object", {}, false],
51
+ ])("%s", (_, value, expected) => {
52
+ expect(isConfiguredAsset(value)).toBe(expected);
53
+ });
54
+ });
55
+
56
+ describe("firstConfiguredAsset", () => {
57
+ it("returns the first configured candidate", () => {
58
+ expect(firstConfiguredAsset(undefined, " ", "a", "b")).toBe("a");
59
+ });
60
+
61
+ it("returns undefined when nothing is configured", () => {
62
+ expect(firstConfiguredAsset(undefined, "", null)).toBeUndefined();
63
+ expect(firstConfiguredAsset()).toBeUndefined();
64
+ });
65
+ });
66
+
67
+ describe("resolveActionAssets", () => {
68
+ it("equals the defaults without configuration", () => {
69
+ expect(resolveActionAssets({ configuration: {} })).toEqual(
70
+ ACTION_DEFAULT_ASSETS
71
+ );
72
+ });
73
+
74
+ FLAVOURS.forEach((flavour) =>
75
+ ACTION_STATES.forEach((state) =>
76
+ it(`overrides ${flavour} ${state} on its own`, () => {
77
+ const result = resolveActionAssets({
78
+ configuration: { [actionKey(flavour, state)]: "plugin.png" },
79
+ });
80
+
81
+ const expected = JSON.parse(JSON.stringify(ACTION_DEFAULT_ASSETS));
82
+ expected[flavour][state] = "plugin.png";
83
+ expect(result).toEqual(expected);
84
+ })
85
+ )
86
+ );
87
+
88
+ it("never returns an empty URI", () => {
89
+ const blank = (keys: string[]) =>
90
+ Object.fromEntries(keys.map((key) => [key, " "]));
91
+
92
+ const configuration = blank(
93
+ FLAVOURS.flatMap((f) => [
94
+ ...ACTION_STATES.map((s) => actionKey(f, s)),
95
+ ...BUTTON_STATES.map((s) => buttonKey(f, s)),
96
+ ])
97
+ );
98
+
99
+ const values = [
100
+ ...Object.values(
101
+ resolveActionAssets({
102
+ configuration,
103
+ })
104
+ ),
105
+ ...Object.values(resolveButtonAssets({ configuration })),
106
+ ].flatMap((assets) => Object.values(assets));
107
+
108
+ values.forEach((value) => expect(isConfiguredAsset(value)).toBe(true));
109
+ });
110
+
111
+ it("applies a plugin override to its own key and flavour only", () => {
112
+ const result = resolveActionAssets({
113
+ configuration: { [actionKey("flavour_2", "unmute")]: "plugin.png" },
114
+ });
115
+
116
+ expect(result.flavour_2.unmute).toBe("plugin.png");
117
+ expect(result.flavour_2.mute).toBe(ACTION_DEFAULT_ASSETS.flavour_2.mute);
118
+ expect(result.flavour_1).toEqual(ACTION_DEFAULT_ASSETS.flavour_1);
119
+ });
120
+
121
+ it("falls through whitespace plugin values", () => {
122
+ const result = resolveActionAssets({
123
+ configuration: {
124
+ [actionKey("flavour_1", "mute")]: "plugin.png",
125
+ [actionKey("flavour_1", "unmute")]: " ",
126
+ },
127
+ });
128
+
129
+ expect(result.flavour_1.mute).toBe("plugin.png");
130
+
131
+ expect(result.flavour_1.unmute).toBe(
132
+ ACTION_DEFAULT_ASSETS.flavour_1.unmute
133
+ );
134
+ });
135
+ });
136
+
137
+ describe("resolveButtonAssets", () => {
138
+ it("equals the defaults without configuration", () => {
139
+ expect(resolveButtonAssets({ configuration: {} })).toEqual(
140
+ BUTTON_DEFAULT_ASSETS
141
+ );
142
+ });
143
+
144
+ FLAVOURS.forEach((flavour) =>
145
+ BUTTON_STATES.forEach((state) =>
146
+ it(`overrides ${flavour} ${state} on its own`, () => {
147
+ const result = resolveButtonAssets({
148
+ configuration: { [buttonKey(flavour, state)]: "plugin.png" },
149
+ });
150
+
151
+ const expected = JSON.parse(JSON.stringify(BUTTON_DEFAULT_ASSETS));
152
+ expected[flavour][state] = "plugin.png";
153
+ expect(result).toEqual(expected);
154
+ })
155
+ )
156
+ );
157
+
158
+ it("falls back for whitespace values", () => {
159
+ const result = resolveButtonAssets({
160
+ configuration: { [buttonKey("flavour_1", "mute")]: " " },
161
+ });
162
+
163
+ expect(result).toEqual(BUTTON_DEFAULT_ASSETS);
164
+ });
165
+ });
166
+
167
+ describe("select helpers", () => {
168
+ const action = { mute: "m", unmute: "u" };
169
+
170
+ it.each([
171
+ [false, "m"],
172
+ [true, "u"],
173
+ ])("selectActionAsset muted=%p -> %s", (muted, expected) => {
174
+ expect(selectActionAsset(action, muted)).toBe(expected);
175
+ });
176
+ });
177
+
178
+ describe("toFlavourTuple", () => {
179
+ it("returns [flavour_1, flavour_2] picks", () => {
180
+ expect(
181
+ toFlavourTuple(
182
+ { flavour_1: { v: "a" }, flavour_2: { v: "b" } },
183
+ (x) => x.v
184
+ )
185
+ ).toEqual(["a", "b"]);
186
+ });
187
+ });
188
+
189
+ describe("manifest key contract", () => {
190
+ const manifestKeys = createManifest({
191
+ platform: "ios_for_quickbrick",
192
+ version: "1.0.0",
193
+ })
194
+ .custom_configuration_fields.flatMap((group) => group.fields)
195
+ .filter((field) => field.type === "uploader")
196
+ .map((field) => field.key)
197
+ .sort();
198
+
199
+ const readKeys = () => {
200
+ const read = new Set<string>();
201
+
202
+ const configuration = new Proxy(
203
+ {},
204
+ {
205
+ get: (_, key) => {
206
+ read.add(String(key));
207
+
208
+ return undefined;
209
+ },
210
+ }
211
+ );
212
+
213
+ resolveActionAssets({ configuration });
214
+ resolveButtonAssets({ configuration });
215
+
216
+ return [...read].sort();
217
+ };
218
+
219
+ it("reads exactly the uploader keys defined in the manifest", () => {
220
+ expect(readKeys()).toEqual(manifestKeys);
221
+
222
+ expect(manifestKeys).toHaveLength(
223
+ FLAVOURS.length * (ACTION_STATES.length + BUTTON_STATES.length)
224
+ );
225
+ });
226
+ });
@@ -0,0 +1,51 @@
1
+ jest.mock("@applicaster/zapp-react-native-utils/pluginUtils", () => ({
2
+ getPluginByIdentifier: () => undefined,
3
+ }));
4
+
5
+ jest.mock(
6
+ "@applicaster/zapp-react-native-utils/reactHooks/usePluginConfiguration",
7
+ () => ({ getPluginConfiguration: () => ({}) })
8
+ );
9
+
10
+ jest.mock(
11
+ "@applicaster/zapp-react-native-ui-components/Components/VideoLive/MuteUnmuteController",
12
+ () => ({
13
+ MUTE_UNMUTE_ACTION_IDENTIFIER: "mute_unmute_action",
14
+ muteUnmuteController: {
15
+ getEntryState: () => ({ muted: true }),
16
+ subscribe: jest.fn(() => jest.fn()),
17
+ toggle: jest.fn(),
18
+ },
19
+ })
20
+ );
21
+
22
+ import { muteUnmuteUIAction } from "../index";
23
+ import { muteUnmuteController } from "@applicaster/zapp-react-native-ui-components/Components/VideoLive/MuteUnmuteController";
24
+
25
+ it("uses bundled visuals on the first read without a configuration plugin", () => {
26
+ expect(muteUnmuteUIAction.initialEntryState({ id: "1" })).toEqual(
27
+ expect.objectContaining({ checked: true, state: 1, label: "Unmute" })
28
+ );
29
+ });
30
+
31
+ it("delegates the command without a second imperative UI update", () => {
32
+ const updateState = jest.fn();
33
+ muteUnmuteUIAction.invokeAction({ id: "1" }, { updateState });
34
+ expect(muteUnmuteController.toggle).toHaveBeenCalledWith("1");
35
+ expect(updateState).not.toHaveBeenCalled();
36
+ });
37
+
38
+ it("hydrates a late subscriber and cleans up its controller subscription", () => {
39
+ const listener = jest.fn();
40
+ const remove = muteUnmuteUIAction.addListener("1", listener);
41
+
42
+ const controllerRemove = jest.mocked(muteUnmuteController.subscribe).mock
43
+ .results[0].value;
44
+
45
+ expect(listener).toHaveBeenCalledWith(
46
+ expect.objectContaining({ checked: true })
47
+ );
48
+
49
+ remove();
50
+ expect(controllerRemove).toHaveBeenCalledTimes(1);
51
+ });
@@ -0,0 +1,73 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { Image } from "react-native";
4
+
5
+ jest
6
+ .spyOn(Image, "resolveAssetSource")
7
+ .mockReturnValue({ uri: "asset://mock" } as any);
8
+
9
+ // imports are hoisted above the spy, so the module is loaded after it is set up
10
+ const {
11
+ ACTION_DEFAULT_ASSETS,
12
+ BUTTON_DEFAULT_ASSETS,
13
+ } = require("../defaultAssets");
14
+
15
+ const FLAVOURS = ["flavour_1", "flavour_2"];
16
+ const ACTION_STATES = ["mute", "unmute"];
17
+ const BUTTON_STATES = ["mute", "unmute"];
18
+
19
+ const EXPECTED_PATHS = FLAVOURS.flatMap((flavour) => [
20
+ ...ACTION_STATES.map((state) => `./assets/action/${flavour}/${state}.png`),
21
+ ...BUTTON_STATES.map((state) => `./assets/button/${flavour}/${state}.png`),
22
+ ]);
23
+
24
+ describe("defaultAssets", () => {
25
+ it("defines a non-empty action asset for every flavour and state", () => {
26
+ expect(Object.keys(ACTION_DEFAULT_ASSETS).sort()).toEqual(FLAVOURS);
27
+
28
+ FLAVOURS.forEach((flavour) => {
29
+ const assets = ACTION_DEFAULT_ASSETS[flavour];
30
+
31
+ expect(Object.keys(assets).sort()).toEqual([...ACTION_STATES].sort());
32
+
33
+ ACTION_STATES.forEach((state) => {
34
+ expect(typeof assets[state]).toBe("string");
35
+ expect(assets[state].length).toBeGreaterThan(0);
36
+ });
37
+ });
38
+ });
39
+
40
+ it("defines a non-empty button asset for every flavour and state", () => {
41
+ expect(Object.keys(BUTTON_DEFAULT_ASSETS).sort()).toEqual(FLAVOURS);
42
+
43
+ FLAVOURS.forEach((flavour) => {
44
+ const assets = BUTTON_DEFAULT_ASSETS[flavour];
45
+
46
+ expect(Object.keys(assets).sort()).toEqual([...BUTTON_STATES].sort());
47
+
48
+ BUTTON_STATES.forEach((state) => {
49
+ expect(typeof assets[state]).toBe("string");
50
+ expect(assets[state].length).toBeGreaterThan(0);
51
+ });
52
+ });
53
+ });
54
+
55
+ it("requires exactly the 8 bundled PNGs by literal path", () => {
56
+ const source = fs.readFileSync(
57
+ path.resolve(__dirname, "..", "defaultAssets.ts"),
58
+ "utf8"
59
+ );
60
+
61
+ const required = [
62
+ ...source.matchAll(/require\("(\.\/assets\/[^"]+)"\)/g),
63
+ ].map((match) => match[1]);
64
+
65
+ expect([...required].sort()).toEqual([...EXPECTED_PATHS].sort());
66
+
67
+ required.forEach((relativePath) => {
68
+ expect(fs.existsSync(path.resolve(__dirname, "..", relativePath))).toBe(
69
+ true
70
+ );
71
+ });
72
+ });
73
+ });