@applicaster/zapp-react-native-utils 15.1.14 → 15.1.15-rc.0

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 (72) hide show
  1. package/actionsExecutor/ActionExecutor.ts +40 -2
  2. package/actionsExecutor/ActionExecutorContext.tsx +70 -16
  3. package/actionsExecutor/actions/__tests__/dismissBottomSheet.test.ts +25 -0
  4. package/actionsExecutor/actions/__tests__/loadItemsFromSource.test.ts +88 -0
  5. package/actionsExecutor/actions/__tests__/openBottomSheet.customContent.test.ts +76 -0
  6. package/actionsExecutor/actions/__tests__/openBottomSheet.inlineItems.test.ts +212 -0
  7. package/actionsExecutor/actions/__tests__/showToast.test.ts +2 -2
  8. package/actionsExecutor/actions/dismissBottomSheet.ts +23 -0
  9. package/actionsExecutor/actions/index.ts +4 -0
  10. package/actionsExecutor/actions/openBottomSheet.ts +329 -0
  11. package/actionsExecutor/actions/refreshComponent.ts +6 -0
  12. package/actionsExecutor/actions/sendCloudEvent.ts +6 -1
  13. package/actionsExecutor/actions/showToast.ts +2 -2
  14. package/actionsExecutor/consts.ts +23 -0
  15. package/actionsExecutor/types.ts +1 -1
  16. package/appUtils/playerManager/OverlayObserver/OverlaysObserver.ts +5 -2
  17. package/appUtils/playerManager/OverlayObserver/__tests__/utils.test.ts +55 -2
  18. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.android.tv.ts +4 -0
  19. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ios.tv.ts +4 -0
  20. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.ts +11 -0
  21. package/appUtils/playerManager/OverlayObserver/getUpcomingQueue.web.ts +4 -0
  22. package/appUtils/playerManager/OverlayObserver/utils.ts +51 -20
  23. package/arrayUtils/__tests__/reorderByIds.test.ts +50 -0
  24. package/arrayUtils/index.ts +79 -0
  25. package/configurationUtils/__tests__/manifestKeyParser.test.ts +21 -0
  26. package/configurationUtils/__tests__/modalBlocksParser.test.ts +260 -0
  27. package/configurationUtils/manifestKeyParser.ts +50 -10
  28. package/configurationUtils/modalBlocksParser.ts +296 -0
  29. package/entryActions/__tests__/buildEntryActions.test.ts +195 -0
  30. package/entryActions/aliasPresentation.ts +124 -0
  31. package/entryActions/buildEntryActions.ts +167 -0
  32. package/entryActions/index.ts +17 -0
  33. package/entryActions/types.ts +39 -0
  34. package/manifestUtils/defaultManifestConfigurations/generalContent.js +6 -0
  35. package/manifestUtils/defaultManifestConfigurations/player.js +22 -1
  36. package/manifestUtils/index.js +3 -0
  37. package/manifestUtils/modalBlocks.js +304 -0
  38. package/modalState/ContentViewModel.ts +113 -0
  39. package/modalState/EditableCollection.ts +17 -0
  40. package/modalState/EditableCollectionRegistry.ts +51 -0
  41. package/modalState/ModalOrchestrator.ts +211 -0
  42. package/modalState/RemoteEditableCollection.ts +235 -0
  43. package/modalState/__tests__/ContentViewModel.editable.test.ts +69 -0
  44. package/modalState/__tests__/ContentViewModel.test.ts +165 -0
  45. package/modalState/__tests__/EditableCollectionRegistry.test.ts +112 -0
  46. package/modalState/__tests__/ModalOrchestrator.phase3.test.ts +47 -0
  47. package/modalState/__tests__/RemoteEditableCollection.test.ts +415 -0
  48. package/modalState/__tests__/index.test.ts +30 -1
  49. package/modalState/__tests__/useBottomSheetContent.test.ts +349 -0
  50. package/modalState/__tests__/useModalStoreState.test.ts +142 -0
  51. package/modalState/index.ts +35 -83
  52. package/modalState/store.ts +109 -0
  53. package/modalState/types.ts +160 -0
  54. package/modalState/useBottomSheetContent.ts +109 -0
  55. package/package.json +2 -2
  56. package/playerUtils/__tests__/resolvePlayerActions.test.ts +138 -0
  57. package/playerUtils/index.ts +17 -1
  58. package/playerUtils/resolvePlayerActions.ts +71 -0
  59. package/reactHooks/actions/index.ts +67 -2
  60. package/reactHooks/navigation/__tests__/index.test.tsx +124 -1
  61. package/reactHooks/navigation/useRoute.ts +8 -4
  62. package/reactHooks/usePluginConfiguration.ts +6 -3
  63. package/uiActionsRegistrator/__tests__/resolveActionIdentifiers.test.ts +93 -0
  64. package/uiActionsRegistrator/__tests__/subscribe.test.ts +107 -0
  65. package/uiActionsRegistrator/__tests__/usableActionItems.test.ts +52 -0
  66. package/uiActionsRegistrator/index.ts +25 -203
  67. package/uiActionsRegistrator/registry.ts +335 -0
  68. package/uiActionsRegistrator/resolveActionIdentifiers.ts +100 -0
  69. package/uiActionsRegistrator/usableActionItems.ts +66 -0
  70. package/playerUtils/__tests__/getPlayerActionButtons.test.ts +0 -54
  71. package/playerUtils/getPlayerActionButtons.ts +0 -17
  72. /package/reactHooks/actions/__tests__/{index.test.js → index.test.tsx} +0 -0
@@ -6,6 +6,7 @@ import { ActionsContext } from "@applicaster/zapp-react-native-ui-components/Con
6
6
  import {
7
7
  observeEntryState,
8
8
  RegisteredActionValue,
9
+ uiActionsRegistry,
9
10
  } from "../../uiActionsRegistrator";
10
11
  import { reactHooksLogger } from "../logger";
11
12
 
@@ -34,7 +35,13 @@ export function useActions<T = unknown>(plugId: string): any {
34
35
  if (!logged) {
35
36
  logger.warning({
36
37
  message: `useActions: Couldn't find an action for ${plugId} plugin`,
37
- data: { actions: context?.actions },
38
+ // Identifiers only. The action map holds every plugin's module and
39
+ // live React context, which is far too large for a log line and can
40
+ // carry whatever state those plugins hold.
41
+ data: {
42
+ plugId,
43
+ availableActions: Object.keys(context?.actions ?? {}),
44
+ },
38
45
  });
39
46
 
40
47
  setLogged(true);
@@ -80,7 +87,31 @@ export function useEntryActionState(
80
87
  useEffect(() => {
81
88
  if (!action) return undefined;
82
89
 
83
- const subscription = observeEntryState(action, entry).subscribe(setState);
90
+ // An action's stream is third-party code. Subscribing with only a `next`
91
+ // handler lets RxJS rethrow an error notification asynchronously, where no
92
+ // error boundary catches it - one plugin faulting would take the app down
93
+ // rather than this one button. The button keeps its last known state.
94
+ let subscription;
95
+
96
+ try {
97
+ subscription = observeEntryState(action, entry).subscribe({
98
+ next: setState,
99
+ error: (error) =>
100
+ logger.warning({
101
+ message:
102
+ "useEntryActionState: the action's state stream failed - the button keeps its last state",
103
+ data: { entryId, error },
104
+ }),
105
+ });
106
+ } catch (error) {
107
+ logger.warning({
108
+ message:
109
+ "useEntryActionState: could not observe the action - the button keeps its initial state",
110
+ data: { entryId, error },
111
+ });
112
+
113
+ return undefined;
114
+ }
84
115
 
85
116
  return () => subscription.unsubscribe();
86
117
  // eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
@@ -88,6 +119,18 @@ export function useEntryActionState(
88
119
 
89
120
  const invokeAction = useCallback(
90
121
  (options: InvokeArgsOptions = {}) => {
122
+ if (typeof action?.invokeAction !== "function") {
123
+ // A button was rendered and pressed, and the press goes nowhere. Only
124
+ // reachable on a user interaction, so it costs nothing on render. The
125
+ // action value carries no identifier, so the label is what locates the
126
+ // button that misbehaved.
127
+ logger.warning({
128
+ message:
129
+ "useEntryActionState: the pressed action has no invokeAction - the press did nothing",
130
+ data: { entryId, label: state?.label },
131
+ });
132
+ }
133
+
91
134
  action?.invokeAction?.(entry, { updateState: setState, ...options });
92
135
  },
93
136
  // eslint-disable-next-line @wogns3623/better-exhaustive-deps/exhaustive-deps
@@ -96,3 +139,25 @@ export function useEntryActionState(
96
139
 
97
140
  return { state, invokeAction };
98
141
  }
142
+
143
+ /**
144
+ * A value that changes whenever a provider is added to or removed from
145
+ * `uiActionsRegistry`.
146
+ *
147
+ * Providers can register after the UI that wants them has rendered - the audio
148
+ * player registers playback speed only once its controller has state, and
149
+ * plugin-backed actions are bridged in from an effect. Put this in the
150
+ * dependency list of anything that resolves actions, so the list is re-read
151
+ * instead of being frozen at first paint.
152
+ */
153
+ export function useRegistryRevision(): number {
154
+ const [revision, setRevision] = useState(0);
155
+
156
+ useEffect(
157
+ () =>
158
+ uiActionsRegistry.subscribe(() => setRevision((current) => current + 1)),
159
+ []
160
+ );
161
+
162
+ return revision;
163
+ }
@@ -1,12 +1,13 @@
1
1
  import React from "react";
2
2
  import { PathnameContext } from "@applicaster/zapp-react-native-ui-components/Contexts/PathnameContext";
3
- import { renderHook } from "@testing-library/react-hooks";
3
+ import { act, render, renderHook } from "@testing-library/react-native";
4
4
  import { isNavBarVisible, useRoute } from "../";
5
5
  import { Provider } from "react-redux";
6
6
  import configureMockStore from "redux-mock-store";
7
7
  import { NavigationContext } from "@applicaster/zapp-react-native-ui-components/Contexts/NavigationContext";
8
8
  import { ROUTE_TYPES } from "@applicaster/zapp-react-native-utils/navigationUtils/routeTypes";
9
9
  import { ScreenDataContext } from "@applicaster/zapp-react-native-ui-components/Contexts/ScreenDataContext";
10
+ import { modalStore } from "@applicaster/zapp-react-native-utils/modalState";
10
11
 
11
12
  const plugins = [];
12
13
 
@@ -45,6 +46,9 @@ const videoItem = {
45
46
  type: { value: "video" },
46
47
  };
47
48
 
49
+ const modalEntry = { id: "modal-entry-1" };
50
+ const modalPathname = `${ROUTE_TYPES.MODAL}/${rivers["river-general"].id}`;
51
+
48
52
  const mockGetStackForPathname = jest.fn().mockReturnValue(homeStack);
49
53
 
50
54
  const mainStackNavigator = {
@@ -168,6 +172,45 @@ const hookModalWrapper = ({ children }) => (
168
172
  </Provider>
169
173
  );
170
174
 
175
+ // Reports every render of useRoute so the tests below can assert on route
176
+ // transitions and on subscription behaviour, not just on a one-shot result.
177
+ function RouteProbe({ onRender }: { onRender: (route: any) => void }) {
178
+ onRender(useRoute());
179
+
180
+ return null;
181
+ }
182
+
183
+ const openRiverModal = () =>
184
+ act(() => {
185
+ modalStore.getState().openModal({
186
+ item: rivers["river-general"] as any,
187
+ props: { entry: modalEntry },
188
+ });
189
+ });
190
+
191
+ const renderRouteProbe = (pathname: string, onRender: (route: any) => void) => {
192
+ const tree = (currentPathname: string) => (
193
+ <Provider store={store}>
194
+ <ScreenDataContext.Provider value={homeStack.state}>
195
+ <NavigationContext.Provider value={videoModalNavigator}>
196
+ <PathnameContext.Provider value={currentPathname}>
197
+ <RouteProbe onRender={onRender} />
198
+ </PathnameContext.Provider>
199
+ </NavigationContext.Provider>
200
+ </ScreenDataContext.Provider>
201
+ </Provider>
202
+ );
203
+
204
+ const { rerender } = render(tree(pathname));
205
+
206
+ return {
207
+ navigateTo: (nextPathname: string) =>
208
+ act(() => {
209
+ rerender(tree(nextPathname));
210
+ }),
211
+ };
212
+ };
213
+
171
214
  describe("navigation", () => {
172
215
  describe("useRoute", () => {
173
216
  describe("Main stack components", () => {
@@ -233,6 +276,86 @@ describe("navigation", () => {
233
276
  );
234
277
  });
235
278
  });
279
+
280
+ describe("store subscriptions", () => {
281
+ afterEach(() => {
282
+ act(() => {
283
+ modalStore.getState().dismissModal();
284
+ });
285
+ });
286
+
287
+ // Every hook in useRoute must run on every render. When the modal and
288
+ // hook-modal subscriptions lived inside the route branches, walking a
289
+ // single mounted component through the route types changed the hook
290
+ // count and React threw "Rendered more hooks than during the previous
291
+ // render".
292
+ it("keeps a stable hook order when the route type changes", () => {
293
+ let route: any;
294
+
295
+ const { navigateTo } = renderRouteProbe(homeStack.route, (current) => {
296
+ route = current;
297
+ });
298
+
299
+ expect(route.screenData.id).toEqual(entry.id);
300
+ expect(route.screenData.targetScreen.id).toEqual(screen.id);
301
+
302
+ openRiverModal();
303
+ navigateTo(modalPathname);
304
+
305
+ expect(route.pathname).toEqual(modalPathname);
306
+ expect(route.screenData.id).toEqual(rivers["river-general"].id);
307
+
308
+ navigateTo(hooksModalPathname);
309
+
310
+ expect(route.pathname).toEqual(hooksModalPathname);
311
+
312
+ expect(route.screenData.id).toEqual(
313
+ hookModalContextState.state.screenData.payload.id
314
+ );
315
+
316
+ navigateTo(videoModalPathname);
317
+
318
+ expect(route.pathname).toEqual(videoModalPathname);
319
+ expect(route.screenData.id).toEqual(videoItem.id);
320
+
321
+ expect(route.screenData.targetScreen.id).toEqual(
322
+ rivers["river-player"].id
323
+ );
324
+
325
+ navigateTo(homeStack.route);
326
+
327
+ expect(route.pathname).toEqual(homeStack.route);
328
+ expect(route.screenData.id).toEqual(entry.id);
329
+ });
330
+
331
+ it("does not re-render a non-modal route when modal state changes", () => {
332
+ let renders = 0;
333
+
334
+ renderRouteProbe(homeStack.route, () => {
335
+ renders += 1;
336
+ });
337
+
338
+ const rendersBeforeOpen = renders;
339
+
340
+ openRiverModal();
341
+
342
+ expect(renders).toBe(rendersBeforeOpen);
343
+ });
344
+
345
+ it("re-renders a modal route when modal state changes", () => {
346
+ let route: any;
347
+
348
+ renderRouteProbe(modalPathname, (current) => {
349
+ route = current;
350
+ });
351
+
352
+ expect(route.screenData).toEqual({});
353
+
354
+ openRiverModal();
355
+
356
+ expect(route.screenData.id).toEqual(rivers["river-general"].id);
357
+ });
358
+ });
236
359
  });
237
360
 
238
361
  describe("isNavBarVisible", () => {
@@ -48,14 +48,18 @@ export const useRoute = (
48
48
  "contentTypes",
49
49
  ]);
50
50
 
51
- const modalState = useModalStoreState();
52
-
53
- const modalScreenData = modalState.screen;
54
-
51
+ // useRoute runs in every cell, so both modal stores are subscribed to only on
52
+ // the routes that actually read them. Off those routes the selectors return a
53
+ // stable reference, which keeps an unrelated modal opening or closing from
54
+ // re-rendering the whole tree.
55
55
  const hookModalScreenData = useHookModalScreenData(
56
56
  !isHookModalPathname(pathname)
57
57
  );
58
58
 
59
+ const modalState = useModalStoreState(!isModalPathname(pathname));
60
+
61
+ const modalScreenData = modalState.screen;
62
+
59
63
  const videoModalScreenData =
60
64
  navigator?.videoModalState?.item &&
61
65
  legacyScreenData(
@@ -1,7 +1,7 @@
1
1
  import * as R from "ramda";
2
2
  import { useMemo } from "react";
3
3
 
4
- import { usePickFromState } from "@applicaster/zapp-react-native-redux/hooks";
4
+ import { usePlugins } from "@applicaster/zapp-react-native-redux/hooks";
5
5
 
6
6
  // importing this way prevents typescript packages importing from this
7
7
  // file to go crazy reporting errors on JS
@@ -24,8 +24,11 @@ export function parsePluginConfiguration(plugin, manifest) {
24
24
  return populateFields(plugin, manifest.custom_configuration_fields);
25
25
  }
26
26
 
27
- export function usePluginConfiguration<T, S>(identifier, manifest) {
28
- const { plugins } = usePickFromState(["plugins"]);
27
+ export function usePluginConfiguration<T = any, S = Record<string, any>>(
28
+ identifier: string,
29
+ manifest?: any
30
+ ) {
31
+ const plugins = usePlugins();
29
32
 
30
33
  const plugin = useMemo(
31
34
  () => R.find(R.propEq("identifier", identifier), plugins),
@@ -0,0 +1,93 @@
1
+ import {
2
+ parseActionIdentifiers,
3
+ resolveActionIdentifiers,
4
+ } from "../resolveActionIdentifiers";
5
+ import { uiActionsRegistry } from "../registry";
6
+
7
+ const action = (identifier: string) =>
8
+ ({ identifier, action: { invokeAction: jest.fn() } }) as any;
9
+
10
+ describe("parseActionIdentifiers", () => {
11
+ it("parses comma-separated identifiers", () => {
12
+ expect(parseActionIdentifiers(" a, b , ,c ")).toEqual(["a", "b", "c"]);
13
+ });
14
+
15
+ it("parses identifier arrays", () => {
16
+ expect(parseActionIdentifiers([" a ", "", "b", " ", "c"])).toEqual([
17
+ "a",
18
+ "b",
19
+ "c",
20
+ ]);
21
+ });
22
+
23
+ it("returns empty array for nil config", () => {
24
+ expect(parseActionIdentifiers(null)).toEqual([]);
25
+ expect(parseActionIdentifiers(undefined)).toEqual([]);
26
+ });
27
+ });
28
+
29
+ describe("resolveActionIdentifiers", () => {
30
+ const entry = { id: "entry-1" } as any;
31
+
32
+ beforeEach(() => {
33
+ jest
34
+ .spyOn(uiActionsRegistry, "getActions")
35
+ .mockImplementation((identifier) => [action(identifier)] as any);
36
+ });
37
+
38
+ afterEach(() => {
39
+ jest.restoreAllMocks();
40
+ });
41
+
42
+ it("resolves through registry by default", () => {
43
+ const result = resolveActionIdentifiers({
44
+ identifiers: "favorites,share",
45
+ entry,
46
+ });
47
+
48
+ expect(result.map((item) => item.identifier)).toEqual([
49
+ "favorites",
50
+ "share",
51
+ ]);
52
+
53
+ expect(uiActionsRegistry.getActions).toHaveBeenCalledWith(
54
+ "favorites",
55
+ entry
56
+ );
57
+
58
+ expect(uiActionsRegistry.getActions).toHaveBeenCalledWith("share", entry);
59
+ });
60
+
61
+ it("expands entry_actions through the caller, not the registry", () => {
62
+ const expanded = [action("entry_action_0"), action("entry_action_1")];
63
+ const expandEntryActions = jest.fn(() => expanded);
64
+
65
+ const unregister = uiActionsRegistry.registerAction("share", {
66
+ invokeAction: jest.fn(),
67
+ });
68
+
69
+ try {
70
+ const result = resolveActionIdentifiers({
71
+ identifiers: "entry_actions,share",
72
+ entry,
73
+ expandEntryActions,
74
+ });
75
+
76
+ expect(expandEntryActions).toHaveBeenCalledWith(entry);
77
+
78
+ expect(result.map((item) => item.identifier)).toEqual([
79
+ "entry_action_0",
80
+ "entry_action_1",
81
+ "share",
82
+ ]);
83
+ } finally {
84
+ unregister();
85
+ }
86
+ });
87
+
88
+ it("resolves nothing for entry_actions when the caller cannot expand it", () => {
89
+ expect(
90
+ resolveActionIdentifiers({ identifiers: "entry_actions", entry })
91
+ ).toEqual([]);
92
+ });
93
+ });
@@ -0,0 +1,107 @@
1
+ import { isActionAvailableFor, uiActionsRegistry } from "../registry";
2
+
3
+ const value = { invokeAction: jest.fn() };
4
+
5
+ describe("uiActionsRegistry.subscribe", () => {
6
+ it("fires on registration and on unregistration", () => {
7
+ const listener = jest.fn();
8
+ const unsubscribe = uiActionsRegistry.subscribe(listener);
9
+
10
+ const unregister = uiActionsRegistry.registerAction("late-action", value);
11
+
12
+ expect(listener).toHaveBeenCalledTimes(1);
13
+
14
+ unregister();
15
+
16
+ expect(listener).toHaveBeenCalledTimes(2);
17
+
18
+ unsubscribe();
19
+ });
20
+
21
+ it("lets a listener see the action it was told about", () => {
22
+ let seen: string[] = [];
23
+
24
+ const unsubscribe = uiActionsRegistry.subscribe(() => {
25
+ seen = uiActionsRegistry
26
+ .getActions("late-action", { id: "e1" } as any)
27
+ .map((item) => item.identifier);
28
+ });
29
+
30
+ const unregister = uiActionsRegistry.registerAction("late-action", value);
31
+
32
+ expect(seen).toEqual(["late-action"]);
33
+
34
+ unregister();
35
+ unsubscribe();
36
+ });
37
+
38
+ it("stops calling a listener once unsubscribed", () => {
39
+ const listener = jest.fn();
40
+
41
+ uiActionsRegistry.subscribe(listener)();
42
+
43
+ uiActionsRegistry.registerAction("another-action", value)();
44
+
45
+ expect(listener).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it("survives a listener that unsubscribes itself while being notified", () => {
49
+ const other = jest.fn();
50
+
51
+ const unsubscribeOther = uiActionsRegistry.subscribe(other);
52
+
53
+ const unsubscribeSelf = uiActionsRegistry.subscribe(() => {
54
+ unsubscribeSelf();
55
+ });
56
+
57
+ expect(() =>
58
+ uiActionsRegistry.registerAction("self-removing", value)()
59
+ ).not.toThrow();
60
+
61
+ expect(other).toHaveBeenCalled();
62
+
63
+ unsubscribeOther();
64
+ });
65
+ });
66
+
67
+ describe("isActionAvailableFor", () => {
68
+ const entry = { id: "e1" } as any;
69
+
70
+ it("shows an action that states no opinion", () => {
71
+ expect(isActionAvailableFor({ invokeAction: jest.fn() }, entry)).toBe(true);
72
+ });
73
+
74
+ it.each([
75
+ ["isSupportDownloads"],
76
+ ["isActionSupported"],
77
+ ["isActionAvailable"],
78
+ ])("honours %s", (predicate) => {
79
+ expect(
80
+ isActionAvailableFor(
81
+ { invokeAction: jest.fn(), [predicate]: () => false } as any,
82
+ entry
83
+ )
84
+ ).toBe(false);
85
+ });
86
+
87
+ it("asks only the first predicate the action defines", () => {
88
+ const isActionAvailable = jest.fn(() => true);
89
+
90
+ expect(
91
+ isActionAvailableFor(
92
+ {
93
+ invokeAction: jest.fn(),
94
+ isSupportDownloads: () => false,
95
+ isActionAvailable,
96
+ } as any,
97
+ entry
98
+ )
99
+ ).toBe(false);
100
+
101
+ expect(isActionAvailable).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("treats a missing action as unavailable-free", () => {
105
+ expect(isActionAvailableFor(undefined, entry)).toBe(true);
106
+ });
107
+ });
@@ -0,0 +1,52 @@
1
+ import { usableActionItems } from "../usableActionItems";
2
+
3
+ const action = (identifier: string) =>
4
+ ({ identifier, action: { invokeAction: jest.fn() } }) as any;
5
+
6
+ describe("usableActionItems", () => {
7
+ it("keeps a usable list untouched, in order", () => {
8
+ const base = [action("share"), action("favorites")];
9
+
10
+ expect(usableActionItems(base).map((item) => item.identifier)).toEqual([
11
+ "share",
12
+ "favorites",
13
+ ]);
14
+ });
15
+
16
+ it("keeps the first of a repeated identifier", () => {
17
+ const firstShare = action("share");
18
+ const secondShare = action("share");
19
+
20
+ const result = usableActionItems([
21
+ firstShare,
22
+ action("favorites"),
23
+ secondShare,
24
+ ]);
25
+
26
+ expect(result.map((item) => item.identifier)).toEqual([
27
+ "share",
28
+ "favorites",
29
+ ]);
30
+
31
+ expect(result[0]).toBe(firstShare);
32
+ });
33
+
34
+ it("drops items that cannot be invoked or have no identifier", () => {
35
+ const result = usableActionItems([
36
+ action("share"),
37
+ { identifier: "blank" } as any,
38
+ { identifier: "no-invoke", action: {} } as any,
39
+ { action: { invokeAction: jest.fn() } } as any,
40
+ action("entry_action_0"),
41
+ ]);
42
+
43
+ expect(result.map((item) => item.identifier)).toEqual([
44
+ "share",
45
+ "entry_action_0",
46
+ ]);
47
+ });
48
+
49
+ it("tolerates a missing list", () => {
50
+ expect(usableActionItems(undefined as any)).toEqual([]);
51
+ });
52
+ });