@saooti/octopus-sdk 41.12.0-beta2 → 41.12.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 (38) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGELOG.md +7 -1
  3. package/index.ts +1 -1
  4. package/package.json +3 -3
  5. package/src/api/index.ts +1 -0
  6. package/src/api/radioApi.ts +18 -2
  7. package/src/api/rubriquesApi.ts +68 -0
  8. package/src/components/composable/form/useOctopusDropdown.ts +5 -2
  9. package/src/components/composable/usePresentationItem.ts +78 -0
  10. package/src/components/display/emission/EmissionInlineList.vue +1 -1
  11. package/src/components/display/emission/EmissionPresentationItem.vue +25 -16
  12. package/src/components/display/podcastmaker/PodcastmakerHeader.vue +60 -45
  13. package/src/components/display/podcasts/PodcastPresentationList.vue +103 -89
  14. package/src/components/form/ClassicButtonGroup.vue +12 -6
  15. package/src/components/form/OctopusMultiselect.vue +86 -9
  16. package/src/components/form/OctopusSelect.vue +29 -2
  17. package/src/components/layouts/PresentationItem.vue +149 -104
  18. package/src/locale/de.json +1 -0
  19. package/src/locale/en.json +1 -0
  20. package/src/locale/es.json +1 -0
  21. package/src/locale/fr.json +1 -0
  22. package/src/locale/it.json +1 -0
  23. package/src/locale/sl.json +1 -0
  24. package/src/stores/CacheStore.ts +17 -11
  25. package/src/stores/ParamSdkStore.ts +22 -0
  26. package/src/stores/class/general/organisation.ts +10 -0
  27. package/src/stores/class/radio/canal.ts +30 -8
  28. package/src/stores/class/radio/recurrence.ts +6 -5
  29. package/src/style/_variables.scss +2 -0
  30. package/src/style/bootstrap.scss +3 -2
  31. package/tests/components/composable/usePresentationItem.spec.ts +163 -0
  32. package/tests/components/display/emission/EmissionPresentationItem.spec.ts +45 -0
  33. package/tests/components/display/podcasts/PodcastPresentationList.spec.ts +107 -0
  34. package/tests/components/form/ClassicButtonGroup.spec.ts +26 -0
  35. package/tests/components/form/OctopusMultiselect.spec.ts +298 -0
  36. package/tests/components/form/OctopusSelect.spec.ts +71 -0
  37. package/tests/components/layouts/PresentationItem.spec.ts +36 -0
  38. package/tests/stores/CacheStore.spec.ts +109 -0
@@ -3,6 +3,7 @@ import '@tests/mocks/i18n';
3
3
  import OctopusSelect from '@/components/form/OctopusSelect.vue';
4
4
  import { DOMWrapper, type VueWrapper } from '@vue/test-utils';
5
5
  import { mount } from '@tests/utils';
6
+ import { nextTick } from 'vue';
6
7
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
8
 
8
9
  const options = [
@@ -16,6 +17,20 @@ function getDropdown(): Element | null {
16
17
  return document.body.querySelector('.octopus-select-dropdown');
17
18
  }
18
19
 
20
+ function getOptionLabels(): string[] {
21
+ return Array.from(document.body.querySelectorAll('.octopus-select-option'))
22
+ .map((el) => el.textContent?.trim() ?? '');
23
+ }
24
+
25
+ // Simulates a click outside the component to trigger onClickOutside's closeDropdown.
26
+ async function clickOutside(): Promise<void> {
27
+ const outsideElement = document.createElement('div');
28
+ document.body.appendChild(outsideElement);
29
+ outsideElement.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
30
+ await nextTick();
31
+ document.body.removeChild(outsideElement);
32
+ }
33
+
19
34
  describe('OctopusSelect', () => {
20
35
  // The dropdown teleports to .octopus-app, which is normally the app root (see App.vue).
21
36
  // It doesn't exist in the test DOM, so it must be created before mount.
@@ -165,4 +180,60 @@ describe('OctopusSelect', () => {
165
180
  const optionButtons = document.body.querySelectorAll('.octopus-select-option');
166
181
  expect(optionButtons[0].classList).toContain('selected');
167
182
  });
183
+
184
+ describe('pullSelectedToTop', () => {
185
+ it('does not reorder options when pullSelectedToTop is not set', async () => {
186
+ wrapper = await mount(OctopusSelect, {
187
+ props: { options, optionLabel: 'name', value: options[1] },
188
+ });
189
+ await wrapper.find('input').trigger('focus');
190
+ expect(getOptionLabels()).toEqual(['Alpha', 'Beta', 'Gamma']);
191
+ });
192
+
193
+ it('moves the selected option to the top when pullSelectedToTop is true', async () => {
194
+ wrapper = await mount(OctopusSelect, {
195
+ props: { options, optionLabel: 'name', value: options[1], pullSelectedToTop: true },
196
+ });
197
+ await wrapper.find('input').trigger('focus');
198
+ expect(getOptionLabels()).toEqual(['Beta', 'Alpha', 'Gamma']);
199
+ });
200
+
201
+ it('does not reorder while the dropdown stays open as the value changes', async () => {
202
+ wrapper = await mount(OctopusSelect, {
203
+ props: { options, optionLabel: 'name', pullSelectedToTop: true },
204
+ });
205
+ await wrapper.find('input').trigger('focus');
206
+ await wrapper.setProps({ value: options[2] });
207
+ expect(getOptionLabels()).toEqual(['Alpha', 'Beta', 'Gamma']);
208
+ });
209
+
210
+ it('re-pins using the latest value after closing and reopening', async () => {
211
+ wrapper = await mount(OctopusSelect, {
212
+ props: { options, optionLabel: 'name', value: options[2], pullSelectedToTop: true },
213
+ });
214
+ await wrapper.find('input').trigger('focus');
215
+ await clickOutside();
216
+ await wrapper.find('input').trigger('focus');
217
+ expect(getOptionLabels()).toEqual(['Gamma', 'Alpha', 'Beta']);
218
+ });
219
+
220
+ it('pins by optionKey even when the value is a different reference', async () => {
221
+ const value = { id: 2, name: 'Beta' };
222
+ wrapper = await mount(OctopusSelect, {
223
+ props: { options, optionLabel: 'name', optionKey: 'id', value, pullSelectedToTop: true },
224
+ });
225
+ await wrapper.find('input').trigger('focus');
226
+ expect(getOptionLabels()).toEqual(['Beta', 'Alpha', 'Gamma']);
227
+ });
228
+
229
+ it('still respects the active search filter when pinning', async () => {
230
+ wrapper = await mount(OctopusSelect, {
231
+ props: { options, optionLabel: 'name', value: options[2], pullSelectedToTop: true },
232
+ });
233
+ await wrapper.find('input').trigger('focus');
234
+ await wrapper.find('input').setValue('be');
235
+ await wrapper.find('input').trigger('input');
236
+ expect(getOptionLabels()).toEqual(['Beta']);
237
+ });
238
+ });
168
239
  });
@@ -0,0 +1,36 @@
1
+ import '@tests/mocks/i18n';
2
+ import '@tests/mocks/useRouter';
3
+
4
+ import PresentationItem from '@/components/layouts/PresentationItem.vue';
5
+ import { mount as testMount } from '@tests/utils';
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ const baseProps = {
9
+ route: 'podcast',
10
+ imageUrl: 'https://example.com/img.png',
11
+ name: 'Item name',
12
+ };
13
+
14
+ const mount = (props: Record<string, unknown>) => testMount(PresentationItem, {
15
+ props: { ...baseProps, ...props },
16
+ });
17
+
18
+ describe('PresentationItem', () => {
19
+ describe('additionalInfo', () => {
20
+ it('renders each additionalInfo entry when a description is set', async () => {
21
+ const wrapper = await mount({ description: 'Some description', additionalInfo: ['1 décembre 2025', 'Saooti'] });
22
+ const info = wrapper.findAll('.text-secondary');
23
+ expect(info.map(el => el.text())).toEqual(['1 décembre 2025', 'Saooti']);
24
+ });
25
+
26
+ it('does not render additionalInfo when it is not provided', async () => {
27
+ const wrapper = await mount({ description: 'Some description' });
28
+ expect(wrapper.find('.text-secondary').exists()).toBe(false);
29
+ });
30
+
31
+ it('does not render additionalInfo when there is no description', async () => {
32
+ const wrapper = await mount({ additionalInfo: ['Saooti'] });
33
+ expect(wrapper.find('.text-secondary').exists()).toBe(false);
34
+ });
35
+ });
36
+ });
@@ -0,0 +1,109 @@
1
+ import '@tests/mocks/i18n';
2
+
3
+ import { useCacheStore } from '@/stores/CacheStore';
4
+ import { setupPinia } from '@tests/utils';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ describe('CacheStore', () => {
8
+ beforeEach(() => {
9
+ setupPinia();
10
+ });
11
+
12
+ describe('getData', () => {
13
+ it('returns the value resolved by the callback', async () => {
14
+ const store = useCacheStore();
15
+ const result = await store.getData('key', () => Promise.resolve('value'));
16
+ expect(result).toBe('value');
17
+ });
18
+
19
+ it('does not call the callback again for a cached key', async () => {
20
+ const store = useCacheStore();
21
+ const callback = vi.fn().mockResolvedValue('value');
22
+ await store.getData('key', callback);
23
+ await store.getData('key', callback);
24
+ expect(callback).toHaveBeenCalledTimes(1);
25
+ });
26
+
27
+ it('shares the in-flight promise between concurrent callers', async () => {
28
+ const store = useCacheStore();
29
+ const callback = vi.fn().mockResolvedValue('value');
30
+ const [a, b] = await Promise.all([
31
+ store.getData('key', callback),
32
+ store.getData('key', callback),
33
+ ]);
34
+ expect(a).toBe('value');
35
+ expect(b).toBe('value');
36
+ expect(callback).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it('keeps separate cache entries per key', async () => {
40
+ const store = useCacheStore();
41
+ const callbackA = vi.fn().mockResolvedValue('value-a');
42
+ const callbackB = vi.fn().mockResolvedValue('value-b');
43
+ expect(await store.getData('key-a', callbackA)).toBe('value-a');
44
+ expect(await store.getData('key-b', callbackB)).toBe('value-b');
45
+ expect(callbackA).toHaveBeenCalledTimes(1);
46
+ expect(callbackB).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ it('does not cache a rejected fetch, allowing the next call to retry', async () => {
50
+ const store = useCacheStore();
51
+ const callback = vi.fn()
52
+ .mockRejectedValueOnce(new Error('boom'))
53
+ .mockResolvedValueOnce('value');
54
+
55
+ await expect(store.getData('key', callback)).rejects.toThrow('boom');
56
+ // Let the internal `.catch(() => invalidate(key))` microtask run
57
+ await Promise.resolve();
58
+ await Promise.resolve();
59
+
60
+ const result = await store.getData('key', callback);
61
+ expect(result).toBe('value');
62
+ expect(callback).toHaveBeenCalledTimes(2);
63
+ });
64
+
65
+ describe('expiration', () => {
66
+ beforeEach(() => {
67
+ vi.useFakeTimers();
68
+ });
69
+
70
+ afterEach(() => {
71
+ vi.useRealTimers();
72
+ });
73
+
74
+ it('does not refetch before the 30 minute expiration', async () => {
75
+ const store = useCacheStore();
76
+ const callback = vi.fn().mockResolvedValue('value');
77
+ await store.getData('key', callback);
78
+ vi.advanceTimersByTime(29 * 60 * 1000);
79
+ await store.getData('key', callback);
80
+ expect(callback).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it('refetches after the 30 minute expiration', async () => {
84
+ const store = useCacheStore();
85
+ const callback = vi.fn().mockResolvedValue('value');
86
+ await store.getData('key', callback);
87
+ vi.advanceTimersByTime(31 * 60 * 1000);
88
+ await store.getData('key', callback);
89
+ expect(callback).toHaveBeenCalledTimes(2);
90
+ });
91
+ });
92
+ });
93
+
94
+ describe('invalidate', () => {
95
+ it('forces the next call to refetch', async () => {
96
+ const store = useCacheStore();
97
+ const callback = vi.fn().mockResolvedValue('value');
98
+ await store.getData('key', callback);
99
+ store.invalidate('key');
100
+ await store.getData('key', callback);
101
+ expect(callback).toHaveBeenCalledTimes(2);
102
+ });
103
+
104
+ it('does nothing for an unknown key', () => {
105
+ const store = useCacheStore();
106
+ expect(() => store.invalidate('unknown')).not.toThrow();
107
+ });
108
+ });
109
+ });