@dcrackel/hematournamentui 1.0.711 → 1.0.713

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 (109) hide show
  1. package/.prettierignore +7 -7
  2. package/.storybook/main.js +3 -1
  3. package/README.md +182 -148
  4. package/dist/HemaTournamentUI-lib.es.js +4150 -4039
  5. package/dist/HemaTournamentUI-lib.umd.js +27 -27
  6. package/dist/hematournamentui-tailwind.css +11 -0
  7. package/package.json +96 -96
  8. package/prettier.config.js +16 -16
  9. package/src/main.js +1 -1
  10. package/src/mocks/getDEBouts-TableOf128.js +37 -37
  11. package/src/mocks/getDEBouts-TableOf64.js +35 -35
  12. package/src/stories/Atoms/Input/BaseInput.test.js +97 -97
  13. package/src/stories/Atoms/Input/BaseInput.vue +149 -149
  14. package/src/stories/Molecules/Indicators/MissingHemaRatingsId/MissingHemaRatingsId.stories.js +23 -23
  15. package/src/stories/Molecules/Indicators/MissingHemaRatingsId/MissingHemaRatingsId.vue +35 -35
  16. package/src/stories/Molecules/Indicators/RegistrationStatus/RegistrationStatus.vue +30 -0
  17. package/src/stories/Molecules/Menus/HoverActions/HoverActions.test.js +138 -0
  18. package/src/stories/Molecules/Menus/HoverActions/HoverActions.vue +212 -0
  19. package/src/stories/Molecules/Modals/DirectorModal/DirectorModal.stories.js +6 -0
  20. package/src/stories/Molecules/Modals/DirectorModal/DirectorModal.test.js +18 -18
  21. package/src/stories/Molecules/Modals/DirectorModal/DirectorModal.vue +1 -1
  22. package/src/stories/Organisms/Cards/BoutCard/BoutCard.vue +5 -1
  23. package/src/stories/Organisms/Cards/BoutCard/Variants/BoutCardActive/BoutCardActive.vue +19 -10
  24. package/src/stories/Organisms/Cards/BoutCard/Variants/BoutCardInactive/BoutCardInactive.vue +11 -7
  25. package/src/stories/Organisms/Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.stories.js +33 -33
  26. package/src/stories/Organisms/Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.test.js +45 -45
  27. package/src/stories/Organisms/Cards/BracketDirectorAssignmentCard/BracketDirectorAssignmentCard.vue +103 -103
  28. package/src/stories/Organisms/Cards/Director/DirectorCard.test.js +82 -0
  29. package/src/stories/Organisms/Cards/Director/DirectorCard.vue +32 -51
  30. package/src/stories/Organisms/Cards/EditBout/EditBout.test.js +51 -51
  31. package/src/stories/Organisms/Cards/EventCard/EventCard.stories.js +41 -1
  32. package/src/stories/Organisms/Cards/EventCard/EventCard.test.js +106 -0
  33. package/src/stories/Organisms/Cards/EventCard/EventCard.vue +62 -30
  34. package/src/stories/Organisms/Cards/FencerCard/FencerCard.vue +298 -298
  35. package/src/stories/Organisms/Cards/TableauBoutCard/TableauBoutCard.stories.js +77 -60
  36. package/src/stories/Organisms/Cards/TableauBoutCard/TableauBoutCard.test.js +144 -143
  37. package/src/stories/Organisms/Cards/TableauBoutCard/TableauBoutCard.vue +289 -256
  38. package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.stories.js +69 -69
  39. package/src/stories/Organisms/Cards/TableauFencerCard/TableauFencerCard.vue +80 -80
  40. package/src/stories/Organisms/Cards/WaitlistFencerCard/WaitlistFencerCard.vue +153 -153
  41. package/src/stories/Organisms/ComplexInputs/DropDown/DropDownMenu.vue +4 -0
  42. package/src/stories/Organisms/ComplexInputs/ImageCropper/ImageCropper.stories.js +31 -31
  43. package/src/stories/Organisms/ComplexInputs/ImageCropper/ImageCropper.test.js +109 -109
  44. package/src/stories/Organisms/ComplexInputs/ImageCropper/ImageCropper.vue +192 -192
  45. package/src/stories/Organisms/Containers/Tableau/Tableau.stories.js +1 -0
  46. package/src/stories/Organisms/Containers/Tableau/Tableau.test.js +247 -204
  47. package/src/stories/Organisms/Containers/Tableau/Tableau.vue +89 -15
  48. package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.test.js +29 -29
  49. package/src/stories/Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.vue +46 -46
  50. package/src/stories/Organisms/Containers/Tableau/components/TableauSvgOverlay.test.js +21 -21
  51. package/src/stories/Organisms/Containers/Tableau/components/TableauSvgOverlay.vue +39 -39
  52. package/src/stories/Organisms/Containers/Tableau/layout/TableauLayoutEngine.js +145 -145
  53. package/src/stories/Organisms/Containers/Tableau/layout/TableauLayoutEngine.test.js +64 -64
  54. package/src/stories/Organisms/Containers/Tableau/plannedAssignments.js +36 -57
  55. package/src/stories/Organisms/Containers/Tableau/plannedAssignments.test.js +101 -65
  56. package/src/stories/Organisms/Containers/TableauColumn/TableauColumn.vue +14 -1
  57. package/src/stories/Organisms/Headers/AttendanceSummary/AttendanceSummary.vue +12 -5
  58. package/src/stories/Organisms/Wizards/EditEventWizard/BasicEventInfo/BasicEventInfo.stories.js +52 -48
  59. package/src/stories/Organisms/Wizards/EditEventWizard/BasicEventInfo/BasicEventInfo.vue +256 -227
  60. package/src/stories/Organisms/Wizards/EditEventWizard/DirectElimination/DirectElimination.vue +226 -226
  61. package/src/stories/Organisms/Wizards/EditEventWizard/DirectEliminationPromotion/DirectEliminationPromotion.vue +204 -204
  62. package/src/stories/Organisms/Wizards/EditEventWizard/EditEventWizard.stories.js +137 -133
  63. package/src/stories/Organisms/Wizards/EditEventWizard/EditEventWizard.test.js +221 -191
  64. package/src/stories/Organisms/Wizards/EditEventWizard/EditEventWizard.vue +372 -366
  65. package/src/stories/Organisms/Wizards/EditEventWizard/EventCustomization/EventCustomization.vue +172 -172
  66. package/src/stories/Organisms/Wizards/EditEventWizard/PoolConfiguration/PoolConfiguration.vue +216 -216
  67. package/src/stories/Organisms/Wizards/EditEventWizard/PriceFields.test.js +79 -79
  68. package/src/stories/Organisms/Wizards/EditEventWizard/RuleSerialization.test.js +29 -0
  69. package/src/stories/Organisms/Wizards/EditEventWizard/WeaponSelection.test.js +138 -0
  70. package/src/stories/Templates/EventManagement/Bracket/Bracket.stories.js +123 -12
  71. package/src/stories/Templates/EventManagement/Bracket/Bracket.test.js +178 -166
  72. package/src/stories/Templates/EventManagement/Bracket/Bracket.vue +35 -7
  73. package/src/stories/Templates/EventManagement/Bracket/BracketDirectorAssignment.test.js +242 -0
  74. package/src/stories/Templates/EventManagement/Bracket/BracketMobile.test.js +131 -0
  75. package/src/stories/Templates/EventManagement/Bracket/BracketPlannedSuggestions.js +42 -39
  76. package/src/stories/Templates/EventManagement/Bracket/BracketStories.test.js +80 -0
  77. package/src/stories/Templates/EventManagement/Bracket/BracketStoryAssignments.js +93 -0
  78. package/src/stories/Templates/EventManagement/Bracket/BracketStoryAssignments.test.js +152 -0
  79. package/src/stories/Templates/EventManagement/Bracket/BracketStoryHarness.js +39 -39
  80. package/src/stories/Templates/EventManagement/Bracket/BracketStoryHarness.test.js +79 -79
  81. package/src/stories/Templates/EventManagement/EditEventInfo/EditEventInfo.stories.js +93 -93
  82. package/src/stories/Templates/EventManagement/EditEventInfo/EditEventInfo.vue +91 -91
  83. package/src/stories/Templates/EventManagement/EventAttendance/EventAttendance.stories.js +31 -1
  84. package/src/stories/Templates/EventManagement/PoolLive/PoolLive.test.js +121 -121
  85. package/src/stories/Templates/Menus/EditEventsTopMenu/EditEventsTopMenu.stories.js +9 -2
  86. package/src/stories/Templates/Menus/EditEventsTopMenu/EditEventsTopMenu.vue +5 -4
  87. package/src/stories/Templates/PersonManagement/Admin/Administration/Administration.stories.js +81 -81
  88. package/src/stories/Templates/PersonManagement/Admin/Administration/Administration.test.js +39 -39
  89. package/src/stories/Templates/PersonManagement/Admin/Administration/Administration.vue +101 -101
  90. package/src/stories/Templates/PersonManagement/Admin/FencerAdmin/FencerAdmin.stories.js +71 -71
  91. package/src/stories/Templates/PersonManagement/Admin/FencerAdmin/FencerAdmin.vue +86 -86
  92. package/src/stories/Templates/PersonManagement/Admin/MergeAccounts/MergeAccounts.stories.js +86 -86
  93. package/src/stories/Templates/PersonManagement/Admin/MergeAccounts/MergeAccounts.vue +247 -247
  94. package/src/stories/Templates/PersonManagement/Basic/BasicEdit.vue +170 -170
  95. package/src/stories/Templates/PersonManagement/PersonManagement.stories.js +156 -156
  96. package/src/stories/Templates/PersonManagement/PersonManagement.test.js +329 -329
  97. package/src/stories/Templates/PersonManagement/PersonManagement.vue +329 -329
  98. package/src/stories/Templates/PersonManagement/PersonManagementDraft.js +42 -42
  99. package/src/stories/Templates/TournamentManagement/ListEvents/ListEvents.stories.js +58 -45
  100. package/src/stories/Templates/TournamentManagement/TournamentManagement.stories.js +84 -84
  101. package/src/stories/Templates/TournamentManagement/TournamentManagement.test.js +114 -114
  102. package/src/stories/Templates/TournamentManagement/TournamentManagement.vue +363 -363
  103. package/src/stories/Templates/TournamentManagement/TournamentPageOne/TournamentPageOne.stories.js +116 -116
  104. package/src/stories/Templates/TournamentManagement/TournamentPageOne/TournamentPageOne.vue +307 -307
  105. package/src/stories/Util/Validations/PersonManagementValidations.js +45 -45
  106. package/src/stories/Util/Validations/PriceValidation.js +6 -6
  107. package/src/stories/Util/boutDirectorAssignment.js +46 -0
  108. package/src/stories/Util/registrationStatus.js +11 -0
  109. package/src/stories/Util/tabs.js +19 -47
@@ -0,0 +1,242 @@
1
+ import { DOMWrapper, mount, shallowMount } from '@vue/test-utils';
2
+ import { nextTick } from 'vue';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import Tableau from '../../../Organisms/Containers/Tableau/Tableau.vue';
5
+ import Bracket from './Bracket.vue';
6
+
7
+ const createBout = (overrides = {}) => ({
8
+ BoutId: 42,
9
+ DEBoutId: 7,
10
+ RoundLabel: 'Semifinal',
11
+ Status: 'Scheduled',
12
+ Person1: { PersonId: 1, DisplayName: 'Fencer One' },
13
+ Person2: { PersonId: 2, DisplayName: 'Fencer Two' },
14
+ ...overrides
15
+ });
16
+
17
+ const bracketProps = bout => ({
18
+ bouts: [bout],
19
+ eventRules: [],
20
+ hostingClubColors: {},
21
+ connectedToServer: true,
22
+ showBoutDirectorAssignment: true
23
+ });
24
+
25
+ describe('Bracket assignment request contract', () => {
26
+ it.each(['Tableau', 'List View'])('renders %s without the removed assignment icon', async view => {
27
+ const wrapper = mount(Bracket, {
28
+ props: bracketProps(createBout()),
29
+ global: { stubs: { DirectorModal: true, EditBoutModal: true } }
30
+ });
31
+ try {
32
+ wrapper.vm.handleViewChange(view);
33
+ await wrapper.vm.$nextTick();
34
+ expect(wrapper.find('[data-testid="assign-bout-director"]').exists()).toBe(false);
35
+ expect(wrapper.find('.fa-user-plus').exists()).toBe(false);
36
+ } finally {
37
+ wrapper.unmount();
38
+ }
39
+ });
40
+
41
+ it('keeps forwarded requests detached and leaves bout state unchanged', () => {
42
+ const bout = createBout();
43
+ const wrapper = shallowMount(Bracket, { props: bracketProps(bout) });
44
+ try {
45
+ wrapper.findComponent(Tableau).vm.$emit('request:bout-director', bout);
46
+ expect(wrapper.emitted('request:bout-director')).toEqual([[bout]]);
47
+ expect(wrapper.emitted('update:directing-bout')).toBeUndefined();
48
+ expect(wrapper.emitted('update:bout')).toBeUndefined();
49
+ wrapper.emitted('request:bout-director')[0][0].Person1.DisplayName = 'Changed';
50
+ expect(bout.Person1.DisplayName).toBe('Fencer One');
51
+ expect(bout.Status).toBe('Scheduled');
52
+ } finally {
53
+ wrapper.unmount();
54
+ }
55
+ });
56
+
57
+ it.each(['Active', 'Completed'])('rejects forwarded requests after parent status becomes %s', async status => {
58
+ const bout = createBout();
59
+ const wrapper = shallowMount(Bracket, { props: bracketProps(bout) });
60
+ try {
61
+ await wrapper.setProps({ bouts: [{ ...bout, Status: status }] });
62
+ wrapper.findComponent(Tableau).vm.$emit('request:bout-director', bout);
63
+ expect(wrapper.emitted('request:bout-director')).toBeUndefined();
64
+ } finally {
65
+ wrapper.unmount();
66
+ }
67
+ });
68
+ });
69
+
70
+ const mountedBrackets = [];
71
+ const mountHoverBracket = async (view, bout, overrides = {}) => {
72
+ const wrapper = mount(Bracket, {
73
+ attachTo: document.body,
74
+ props: { ...bracketProps(bout), ...overrides },
75
+ global: { stubs: { DirectorModal: true, EditBoutModal: true } }
76
+ });
77
+ mountedBrackets.push(wrapper);
78
+ wrapper.vm.handleViewChange(view);
79
+ await nextTick();
80
+ return wrapper;
81
+ };
82
+ const revealBoutActions = async wrapper => {
83
+ await wrapper.get('[data-testid="bout-actions-trigger"]').trigger('mouseenter');
84
+ vi.advanceTimersByTime(200);
85
+ await nextTick();
86
+ await nextTick();
87
+ return new DOMWrapper(document.querySelector('[data-testid="bout-actions-menu"]'));
88
+ };
89
+
90
+ describe.each(['Tableau', 'List View'])('%s hover bout actions', view => {
91
+ beforeEach(() => vi.useFakeTimers());
92
+ afterEach(() => {
93
+ mountedBrackets.splice(0).forEach(wrapper => wrapper.unmount());
94
+ vi.useRealTimers();
95
+ });
96
+
97
+ it('shows the assigned director above Assign Director and reconciles reassignment while open', async () => {
98
+ const bout = createBout({ RefName: 'Unassigned' });
99
+ const station = {
100
+ RingId: 7,
101
+ RingName: 'Ring 7',
102
+ Color: 'red',
103
+ Director: { PersonId: 99, DisplayName: 'Dave Smith' }
104
+ };
105
+ const wrapper = await mountHoverBracket(view, bout, {
106
+ plannedDirectingStations: [station],
107
+ plannedBoutAssignments: [{ DEBoutId: bout.DEBoutId, RingId: 7 }]
108
+ });
109
+ const panel = await revealBoutActions(wrapper);
110
+ const action = panel.get('[data-action="assign-director"]');
111
+ const description = panel.get('[data-testid="action-description"]');
112
+ expect(description.text()).toBe('Dave Smith');
113
+ expect(description.element.style.color).toBe('red');
114
+ expect(description.element.closest('button')).toBeNull();
115
+ expect(action.text()).toBe('Assign Director');
116
+ await description.trigger('click');
117
+ expect(wrapper.emitted('request:bout-director')).toBeUndefined();
118
+ expect(panel.isVisible()).toBe(true);
119
+ await wrapper.setProps({
120
+ plannedDirectingStations: [
121
+ { ...station, Color: 'green', Director: { PersonId: 100, DisplayName: 'Alex Morgan' } }
122
+ ]
123
+ });
124
+ expect(panel.get('[data-testid="action-description"]').text()).toBe('Alex Morgan');
125
+ expect(panel.get('[data-testid="action-description"]').element.style.color).toBe('green');
126
+ await panel.get('[data-action="assign-director"]').trigger('click');
127
+ expect(wrapper.emitted('request:bout-director')).toEqual([[bout]]);
128
+ expect(wrapper.emitted('update:bout')).toBeUndefined();
129
+ expect(bout.RefName).toBe('Unassigned');
130
+ });
131
+
132
+ it('omits the name for an unassigned bout', async () => {
133
+ const wrapper = await mountHoverBracket(view, createBout({ RefName: ' Unassigned ' }));
134
+ const panel = await revealBoutActions(wrapper);
135
+ expect(panel.find('[data-testid="action-description"]').exists()).toBe(false);
136
+ expect(panel.get('[data-action="assign-director"]').text()).toBe('Assign Director');
137
+ });
138
+
139
+ it('shows the saved director for an active bout instead of an earlier planned director', async () => {
140
+ const bout = createBout({ Status: 'Active', RefereeId: 99, RefName: 'Dave Smith' });
141
+ const wrapper = await mountHoverBracket(view, bout, {
142
+ plannedDirectingStations: [{ RingId: 7, Color: 'red', Director: { PersonId: 100, DisplayName: 'Alex Morgan' } }],
143
+ plannedBoutAssignments: [{ DEBoutId: bout.DEBoutId, RingId: 7 }]
144
+ });
145
+ const panel = await revealBoutActions(wrapper);
146
+ expect(panel.get('[data-testid="action-description"]').text()).toBe('Dave Smith');
147
+ expect(panel.text()).toContain('Resume bout');
148
+ expect(panel.text()).not.toContain('Alex Morgan');
149
+ expect(panel.get('[data-testid="action-description"]').element.style.color).toBe('');
150
+ expect(panel.find('[data-action="assign-director"]').exists()).toBe(false);
151
+ });
152
+
153
+ it.each(['Final', 'Grand Final', 'Table of 2'])(
154
+ 'keeps %s claimable without offering director assignment',
155
+ async RoundLabel => {
156
+ const bout = createBout({ RoundLabel });
157
+ const wrapper = await mountHoverBracket(view, bout);
158
+ const panel = await revealBoutActions(wrapper);
159
+ expect(panel.find('[data-action="assign-director"]').exists()).toBe(false);
160
+ expect(panel.get('[data-action="open"]').text()).toBe('Open bout');
161
+ wrapper.findComponent(Tableau).vm.$emit('request:bout-director', bout);
162
+ expect(wrapper.emitted('request:bout-director')).toBeUndefined();
163
+ await panel.get('[data-action="open"]').trigger('click');
164
+ expect(wrapper.emitted('update:directing-bout')).toEqual([[bout]]);
165
+ expect(wrapper.vm.showDirectorModal).toBe(false);
166
+ }
167
+ );
168
+
169
+ it('requests assignment without opening the bout or mutating scores or status', async () => {
170
+ const bout = createBout();
171
+ const wrapper = await mountHoverBracket(view, bout);
172
+ expect(document.querySelector('[data-testid="bout-actions-menu"]')).toBeNull();
173
+ const panel = await revealBoutActions(wrapper);
174
+ expect(panel.text()).toContain('Open bout');
175
+ await panel.get('[data-action="assign-director"]').trigger('click');
176
+ expect(wrapper.emitted('request:bout-director')).toEqual([[bout]]);
177
+ expect(wrapper.emitted('update:directing-bout')).toBeUndefined();
178
+ expect(wrapper.emitted('update:bout')).toBeUndefined();
179
+ expect(wrapper.vm.showDirectorModal).toBe(false);
180
+ expect(bout.Status).toBe('Scheduled');
181
+ expect(bout.RefereeId).toBeUndefined();
182
+ });
183
+
184
+ it('opens the scheduled bout through the normal directing contract', async () => {
185
+ const bout = createBout();
186
+ const wrapper = await mountHoverBracket(view, bout);
187
+ const panel = await revealBoutActions(wrapper);
188
+ await panel.get('[data-action="open"]').trigger('click');
189
+ expect(wrapper.emitted('update:directing-bout')).toEqual([[bout]]);
190
+ expect(wrapper.emitted('request:bout-director')).toBeUndefined();
191
+ expect(wrapper.vm.showDirectorModal).toBe(false);
192
+ await wrapper.setProps({ bouts: [{ ...bout, Status: 'Active' }] });
193
+ expect(wrapper.vm.showDirectorModal).toBe(true);
194
+ });
195
+
196
+ it('allows future pairing assignment while withholding Open bout', async () => {
197
+ const bout = createBout({ Person1: { PersonId: 0, DisplayName: 'BYE' }, Person2: null });
198
+ const wrapper = await mountHoverBracket(view, bout);
199
+ const panel = await revealBoutActions(wrapper);
200
+ expect(panel.find('[data-action="open"]').exists()).toBe(false);
201
+ await panel.get('[data-action="assign-director"]').trigger('click');
202
+ expect(wrapper.emitted('request:bout-director')).toEqual([[bout]]);
203
+ expect(wrapper.emitted('update:directing-bout')).toBeUndefined();
204
+ });
205
+
206
+ it('keeps assignment unavailable unless enabled by the parent', async () => {
207
+ const wrapper = await mountHoverBracket(view, createBout(), { showBoutDirectorAssignment: false });
208
+ const panel = await revealBoutActions(wrapper);
209
+ expect(panel.find('[data-action="assign-director"]').exists()).toBe(false);
210
+ expect(panel.get('[data-action="open"]').text()).toBe('Open bout');
211
+ });
212
+
213
+ it('changes the menu to Resume bout when the parent starts a bout', async () => {
214
+ const bout = createBout();
215
+ const wrapper = await mountHoverBracket(view, bout);
216
+ await revealBoutActions(wrapper);
217
+ await wrapper.setProps({ bouts: [{ ...bout, Status: 'Active' }] });
218
+ const panel = new DOMWrapper(document.querySelector('[data-testid="bout-actions-menu"]'));
219
+ expect(panel.get('[data-action="open"]').text()).toBe('Resume bout');
220
+ expect(panel.find('[data-action="assign-director"]').exists()).toBe(false);
221
+ await panel.get('[data-action="open"]').trigger('click');
222
+ expect(wrapper.emitted('update:directing-bout')[0][0].Status).toBe('Active');
223
+ });
224
+ });
225
+
226
+ describe('Completed tableau bout hover actions', () => {
227
+ it('offers Edit bout and opens the existing edit modal', async () => {
228
+ vi.useFakeTimers();
229
+ try {
230
+ const wrapper = await mountHoverBracket('Tableau', createBout({ Status: 'Completed' }));
231
+ const panel = await revealBoutActions(wrapper);
232
+ expect(panel.get('[data-action="open"]').text()).toBe('Edit bout');
233
+ expect(panel.find('[data-action="assign-director"]').exists()).toBe(false);
234
+ await panel.get('[data-action="open"]').trigger('click');
235
+ expect(wrapper.vm.showEditBoutModal).toBe(true);
236
+ expect(wrapper.emitted('update:directing-bout')).toBeUndefined();
237
+ } finally {
238
+ mountedBrackets.splice(0).forEach(wrapper => wrapper.unmount());
239
+ vi.useRealTimers();
240
+ }
241
+ });
242
+ });
@@ -0,0 +1,131 @@
1
+ import { shallowMount } from '@vue/test-utils';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import DirectorModal from '../../../Molecules/Modals/DirectorModal/DirectorModal.vue';
4
+ import EditBoutModal from '../../../Molecules/Modals/EditBoutModal/EditBoutModal.vue';
5
+ import Tableau from '../../../Organisms/Containers/Tableau/Tableau.vue';
6
+ import BracketDirectorAssignmentPanel from '../../../Organisms/Containers/Tableau/components/BracketDirectorAssignmentPanel.vue';
7
+ import ToggleHeader from '../../../Organisms/Headers/ToggleHeader/ToggleHeader.vue';
8
+ import EditEventsTopMenu from '../../Menus/EditEventsTopMenu/EditEventsTopMenu.vue';
9
+ import Bracket from './Bracket.vue';
10
+
11
+ const bout = {
12
+ BoutId: 42,
13
+ DEBoutId: 1,
14
+ RoundLabel: 'Final',
15
+ Status: 'Scheduled',
16
+ Score1: 4,
17
+ Score2: 3,
18
+ Person1: { PersonId: 1, DisplayName: 'Fencer One' },
19
+ Person2: { PersonId: 2, DisplayName: 'Fencer Two' }
20
+ };
21
+ const stations = [{ RingId: 7, RingName: 'Ring 7', Director: { PersonId: 99, DisplayName: 'Director' } }];
22
+ const wrappers = [];
23
+ const mountBracket = (width = 390) => {
24
+ vi.stubGlobal('innerWidth', width);
25
+ const wrapper = shallowMount(Bracket, {
26
+ props: {
27
+ bouts: [bout],
28
+ eventRules: [],
29
+ hostingClubColors: {},
30
+ connectedToServer: true,
31
+ role: 'Admin',
32
+ showBoutDirectorAssignment: true,
33
+ showPlannedDirectorAssignments: true,
34
+ plannedDirectingStations: stations,
35
+ plannedBoutAssignments: [{ DEBoutId: 1, RingId: 7 }],
36
+ viewerPersonId: 99
37
+ }
38
+ });
39
+ wrappers.push(wrapper);
40
+ return wrapper;
41
+ };
42
+ const resize = async (wrapper, width) => {
43
+ window.innerWidth = width;
44
+ window.dispatchEvent(new Event('resize'));
45
+ await wrapper.vm.$nextTick();
46
+ };
47
+
48
+ afterEach(() => {
49
+ wrappers.splice(0).forEach(wrapper => wrapper.unmount());
50
+ vi.unstubAllGlobals();
51
+ });
52
+
53
+ describe('Bracket phone directing view', () => {
54
+ it.each([320, 390, 767])('shows a bout list without organising controls at %ipx', width => {
55
+ const wrapper = mountBracket(width);
56
+ expect(wrapper.findComponent(Tableau).props()).toMatchObject({
57
+ listView: true,
58
+ showBoutDirectorAssignment: false,
59
+ plannedDirectingStations: stations,
60
+ viewerPersonId: 99
61
+ });
62
+ expect(wrapper.findComponent(BracketDirectorAssignmentPanel).exists()).toBe(false);
63
+ expect(
64
+ wrapper
65
+ .findComponent(EditEventsTopMenu)
66
+ .props('tabs')
67
+ .map(tab => tab.id)
68
+ ).toEqual(['Pools', 'Pool Results', 'Bracket', 'Final', 'Penalties']);
69
+ wrapper.findComponent(Tableau).vm.$emit('request:bout-director', bout);
70
+ expect(wrapper.emitted('request:bout-director')).toBeUndefined();
71
+ });
72
+
73
+ it('preserves the desktop view choice and full navigation across phone resizing', async () => {
74
+ const wrapper = mountBracket(1280);
75
+ expect(wrapper.findComponent(Tableau).props('listView')).toBe(false);
76
+ wrapper.findComponent(ToggleHeader).vm.$emit('update:selection', 'List View');
77
+ await wrapper.vm.$nextTick();
78
+ expect(
79
+ wrapper
80
+ .findComponent(EditEventsTopMenu)
81
+ .props('tabs')
82
+ .map(tab => tab.id)
83
+ ).toContain('Staff');
84
+ expect(wrapper.findComponent(BracketDirectorAssignmentPanel).exists()).toBe(true);
85
+ await resize(wrapper, 390);
86
+ expect(wrapper.findComponent(BracketDirectorAssignmentPanel).exists()).toBe(false);
87
+ await resize(wrapper, 768);
88
+ expect(wrapper.findComponent(Tableau).props('listView')).toBe(true);
89
+ expect(wrapper.findComponent(ToggleHeader).props('currentSelection')).toBe('List View');
90
+ expect(wrapper.findComponent(Tableau).props('showBoutDirectorAssignment')).toBe(true);
91
+ wrapper.findComponent(ToggleHeader).vm.$emit('update:selection', 'Tableau');
92
+ await wrapper.vm.$nextTick();
93
+ await resize(wrapper, 390);
94
+ expect(wrapper.findComponent(Tableau).props('listView')).toBe(true);
95
+ await resize(wrapper, 1280);
96
+ expect(wrapper.findComponent(Tableau).props('listView')).toBe(false);
97
+ expect(wrapper.findComponent(ToggleHeader).props('currentSelection')).toBe('Tableau');
98
+ });
99
+
100
+ it('keeps scheduled start, active resume, and modal close parent-owned on phones', async () => {
101
+ const wrapper = mountBracket();
102
+ wrapper.findComponent(Tableau).vm.$emit('action:directingBout', bout);
103
+ await wrapper.vm.$nextTick();
104
+ expect(wrapper.emitted('update:directing-bout')).toEqual([[bout]]);
105
+ expect(wrapper.findComponent(DirectorModal).props('show')).toBe(false);
106
+ const activeBout = { ...bout, Status: 'Active' };
107
+ await wrapper.setProps({ bouts: [activeBout] });
108
+ expect(wrapper.findComponent(DirectorModal).props('show')).toBe(true);
109
+ wrapper.findComponent(DirectorModal).vm.$emit('update:closeModal');
110
+ await wrapper.vm.$nextTick();
111
+ expect(wrapper.emitted('close:directing-bout')).toEqual([[activeBout]]);
112
+ expect(wrapper.emitted('update:bout')).toBeUndefined();
113
+ expect(wrapper.vm.boutsData[0]).toEqual(activeBout);
114
+ wrapper.findComponent(Tableau).vm.$emit('action:directingBout', activeBout);
115
+ await wrapper.vm.$nextTick();
116
+ expect(wrapper.findComponent(DirectorModal).props('show')).toBe(true);
117
+ });
118
+
119
+ it('retains completed editing and rejects incomplete bouts on phones', async () => {
120
+ const wrapper = mountBracket();
121
+ const completedBout = { ...bout, Status: 'Completed' };
122
+ await wrapper.setProps({ bouts: [completedBout] });
123
+ wrapper.findComponent(Tableau).vm.$emit('action:editBout', completedBout);
124
+ await wrapper.vm.$nextTick();
125
+ expect(wrapper.findComponent(EditBoutModal).props('show')).toBe(true);
126
+ expect(wrapper.emitted('update:directing-bout')).toBeUndefined();
127
+ await wrapper.setProps({ bouts: [{ ...bout, Person2: null }] });
128
+ wrapper.findComponent(Tableau).vm.$emit('action:directingBout', { ...bout, Person2: null });
129
+ expect(wrapper.emitted('update:directing-bout')).toBeUndefined();
130
+ });
131
+ });
@@ -1,39 +1,42 @@
1
- import portraitUrl from '../../../../assets/portrait1.png';
2
-
3
- const createBoutAssignments = (ringId, boutIds) => boutIds.map(DEBoutId => ({ DEBoutId, RingId: ringId }));
4
-
5
- export const plannedDirectingStations = [
6
- {
7
- RingId: 1,
8
- RingName: 'Ring 1',
9
- Color: '#2563eb',
10
- Director: { PersonId: 1001, DisplayName: 'Alex Morgan', PortraitUrl: portraitUrl }
11
- },
12
- {
13
- RingId: 2,
14
- RingName: 'Ring 2',
15
- Color: '#dc2626',
16
- Director: { PersonId: 1002, DisplayName: 'Jordan Lee', PortraitUrl: portraitUrl }
17
- },
18
- {
19
- RingId: 3,
20
- RingName: 'Ring 3',
21
- Color: '#059669',
22
- Director: { PersonId: 1003, DisplayName: 'Sam Rivera', PortraitUrl: portraitUrl }
23
- },
24
- {
25
- RingId: 4,
26
- RingName: 'Ring 4',
27
- Color: '#d97706',
28
- Director: { PersonId: 1004, DisplayName: 'Taylor Quinn', PortraitUrl: portraitUrl }
29
- }
30
- ];
31
-
32
- export const plannedBoutAssignments = [
33
- ...createBoutAssignments(1, [65, 66, 67, 68, 69, 70, 71, 72, 97, 98, 99, 100, 113, 114, 121, 125]),
34
- ...createBoutAssignments(2, [73, 74, 75, 76, 77, 78, 79, 80, 101, 102, 103, 104, 115, 116, 122]),
35
- ...createBoutAssignments(3, [81, 82, 83, 84, 85, 86, 87, 88, 105, 106, 107, 108, 117, 118, 123, 126]),
36
- ...createBoutAssignments(4, [89, 90, 91, 92, 93, 94, 95, 96, 109, 110, 111, 112, 119, 120, 124, 127])
37
- ];
38
-
39
- export const plannedSuggestionViewerId = plannedDirectingStations[0].Director.PersonId;
1
+ import portraitUrl from '../../../../assets/portrait1.png';
2
+
3
+ export const plannedDirectingStations = [
4
+ {
5
+ RingId: 1,
6
+ RingName: 'Ring 1',
7
+ Color: '#2563eb',
8
+ Director: { PersonId: 1001, DisplayName: 'Alex Morgan', PortraitUrl: portraitUrl }
9
+ },
10
+ {
11
+ RingId: 2,
12
+ RingName: 'Ring 2',
13
+ Color: '#dc2626',
14
+ Director: { PersonId: 1002, DisplayName: 'Jordan Lee', PortraitUrl: portraitUrl }
15
+ },
16
+ {
17
+ RingId: 3,
18
+ RingName: 'Ring 3',
19
+ Color: '#059669',
20
+ Director: { PersonId: 1003, DisplayName: 'Sam Rivera', PortraitUrl: portraitUrl }
21
+ },
22
+ {
23
+ RingId: 4,
24
+ RingName: 'Ring 4',
25
+ Color: '#d97706',
26
+ Director: { PersonId: 1004, DisplayName: 'Taylor Quinn', PortraitUrl: portraitUrl }
27
+ },
28
+ {
29
+ RingId: 5,
30
+ RingName: 'Ring 5',
31
+ Color: '#9333ea',
32
+ Director: { PersonId: 1005, DisplayName: 'Casey Brooks', PortraitUrl: portraitUrl }
33
+ },
34
+ {
35
+ RingId: 6,
36
+ RingName: 'Ring 6',
37
+ Color: '#0891b2',
38
+ Director: { PersonId: 1006, DisplayName: 'Drew Parker', PortraitUrl: portraitUrl }
39
+ }
40
+ ];
41
+
42
+ export const plannedSuggestionViewerId = plannedDirectingStations[0].Director.PersonId;
@@ -0,0 +1,80 @@
1
+ import { shallowMount } from '@vue/test-utils';
2
+ import { nextTick, reactive } from 'vue';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+ import Bracket from './Bracket.vue';
5
+ import meta, * as stories from './Bracket.stories.js';
6
+
7
+ const wrappers = [];
8
+ const mountStory = story => {
9
+ const args = reactive({ ...meta.args, ...story.args });
10
+ const wrapper = shallowMount(meta.render(args));
11
+ wrappers.push(wrapper);
12
+ return { wrapper, args, bracket: () => wrapper.findComponent(Bracket) };
13
+ };
14
+ const getRing = (bracket, bout) =>
15
+ bracket.props('plannedBoutAssignments').find(item => String(item.DEBoutId) === String(bout.DEBoutId))?.RingId;
16
+
17
+ afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()));
18
+
19
+ describe('Bracket story assignment simulation', () => {
20
+ it('enables assignment and its panel in every bracket story', () => {
21
+ for (const story of Object.values(stories).filter(value => value !== meta)) {
22
+ const args = { ...meta.args, ...story.args };
23
+ expect(args.showBoutDirectorAssignment).toBe(true);
24
+ expect(args.showPlannedDirectorAssignments).toBe(true);
25
+ expect(args.plannedDirectingStations.length).toBeGreaterThan(0);
26
+ }
27
+ });
28
+
29
+ it('starts with two actual stations and recalculates scheduled coverage when directors are added', async () => {
30
+ const { bracket } = mountStory(stories.PlannedDirectorSuggestions);
31
+ expect(bracket().props('plannedDirectingStations')).toHaveLength(2);
32
+ const bouts = bracket().props('bouts');
33
+ const active = { ...bouts.find(bout => bout.Person1.PersonId > 0 && bout.Person2.PersonId > 0), Status: 'Active' };
34
+ const originalRing = getRing(bracket(), active);
35
+ bracket().vm.$emit('update:bout', active);
36
+ await nextTick();
37
+ for (const count of [3, 4, 5, 6]) {
38
+ bracket().vm.$emit('request:add-planned-director');
39
+ await nextTick();
40
+ const stationIds = bracket()
41
+ .props('plannedDirectingStations')
42
+ .map(station => station.RingId);
43
+ expect(stationIds).toHaveLength(count);
44
+ expect(
45
+ bracket()
46
+ .props('plannedBoutAssignments')
47
+ .every(item => stationIds.includes(item.RingId))
48
+ ).toBe(true);
49
+ expect(getRing(bracket(), active)).toBe(originalRing);
50
+ expect(
51
+ bracket()
52
+ .props('bouts')
53
+ .find(bout => bout.BoutId === active.BoutId).Status
54
+ ).toBe('Active');
55
+ }
56
+ expect(bracket().props('showAddPlannedDirectorCard')).toBe(false);
57
+ });
58
+
59
+ it('recalculates from station controls and assigns semifinals and keeps finals shared for every director count', async () => {
60
+ for (const story of [
61
+ stories.PlannedDirectorSuggestions,
62
+ stories.ThreeDirectors,
63
+ stories.FourDirectors,
64
+ stories.FiveDirectors,
65
+ stories.SixDirectors
66
+ ]) {
67
+ const { bracket, args } = mountStory(story);
68
+ const bouts = bracket().props('bouts');
69
+ const shared = bouts.filter(bout => bout.RoundLabel === 'Final');
70
+ const semifinals = bouts.filter(bout => bout.RoundLabel === 'Semifinal');
71
+ expect(semifinals.every(bout => getRing(bracket(), bout) !== undefined)).toBe(true);
72
+ expect(shared.every(bout => getRing(bracket(), bout) === undefined)).toBe(true);
73
+ args.plannedDirectingStations = stories.SixDirectors.args.plannedDirectingStations;
74
+ await nextTick();
75
+ expect(bracket().props('plannedDirectingStations')).toHaveLength(6);
76
+ expect(semifinals.every(bout => getRing(bracket(), bout) !== undefined)).toBe(true);
77
+ expect(shared.every(bout => getRing(bracket(), bout) === undefined)).toBe(true);
78
+ }
79
+ });
80
+ });
@@ -0,0 +1,93 @@
1
+ const getBoutId = bout => bout.DEBoutId ?? bout.BoutId;
2
+ const getStatus = bout => String(bout.Status || '').toLowerCase();
3
+ const hasFencer = person => Number(person?.PersonId) > 0 && String(person.DisplayName || '').toUpperCase() !== 'BYE';
4
+ const isScheduledBout = bout => getStatus(bout) === 'scheduled';
5
+ const isFinishedOrActiveBout = bout => ['active', 'completed'].includes(getStatus(bout));
6
+ const isPowerOfTwo = value => value > 0 && Number.isInteger(Math.log2(value));
7
+ const sortBouts = (first, second) => Number(getBoutId(first)) - Number(getBoutId(second));
8
+
9
+ const getPlannedRoundSize = bout => {
10
+ const label = String(bout.RoundLabel || '');
11
+ if (/^Quarterfinals?$/i.test(label)) return 8;
12
+ if (/^Semifinals?$/i.test(label)) return 4;
13
+ const size = Number(label.match(/^Table of (\d+)$/i)?.[1] || 0);
14
+ return size >= 4 ? size : 0;
15
+ };
16
+
17
+ const groupPlannedRounds = bouts => {
18
+ const rounds = new Map();
19
+ for (const bout of bouts) {
20
+ const size = getPlannedRoundSize(bout);
21
+ if (!size || getBoutId(bout) == null) continue;
22
+ if (!rounds.has(size)) rounds.set(size, []);
23
+ rounds.get(size).push(bout);
24
+ }
25
+ return [...rounds].sort(([first], [second]) => second - first);
26
+ };
27
+
28
+ const isOpeningBye = (bout, isOpeningRound) => {
29
+ if (getStatus(bout) === 'bye') return true;
30
+ return isOpeningRound && ![bout.Person1, bout.Person2].every(hasFencer);
31
+ };
32
+
33
+ const createStationQuotas = (boutCount, stationLoads, keepBracketSections) => {
34
+ const stationCount = stationLoads.length;
35
+ const groupSize = Math.floor(boutCount / stationCount);
36
+ const largerGroupCount = boutCount % stationCount;
37
+ const stationOrder = stationLoads.map((load, index) => ({ load, index }));
38
+ if (!keepBracketSections)
39
+ stationOrder.sort((first, second) => first.load - second.load || first.index - second.index);
40
+ const largerGroups = new Set(stationOrder.slice(0, largerGroupCount).map(station => station.index));
41
+ return stationLoads.map((_, index) => groupSize + Number(largerGroups.has(index)));
42
+ };
43
+
44
+ const getStationIndex = (boutIndex, quotas) => {
45
+ let groupEnd = 0;
46
+ return quotas.findIndex(quota => {
47
+ groupEnd += quota;
48
+ return boutIndex < groupEnd;
49
+ });
50
+ };
51
+
52
+ const createRoundAssignments = ({ bouts, stations, stationLoads, isOpeningRound }) => {
53
+ const roundBouts = [...bouts].sort(sortBouts);
54
+ const keepBracketSections = isPowerOfTwo(stations.length);
55
+ const partitionBouts = keepBracketSections
56
+ ? roundBouts
57
+ : roundBouts.filter(bout => !isOpeningBye(bout, isOpeningRound));
58
+ const quotas = createStationQuotas(partitionBouts.length, stationLoads, keepBracketSections);
59
+ const nextStationLoads = [...stationLoads];
60
+ const assignments = partitionBouts.flatMap((bout, index) => {
61
+ if (isOpeningBye(bout, isOpeningRound)) return [];
62
+ const stationIndex = getStationIndex(index, quotas);
63
+ nextStationLoads[stationIndex]++;
64
+ if (!isScheduledBout(bout)) return [];
65
+ return [{ DEBoutId: getBoutId(bout), RingId: stations[stationIndex].RingId }];
66
+ });
67
+ return { assignments, stationLoads: nextStationLoads };
68
+ };
69
+
70
+ // Story parent simulation: coverage includes semifinals; the final stays shared.
71
+ export const distributeStoryAssignments = ({ bouts = [], stations = [], previousAssignments = [] } = {}) => {
72
+ const protectedBoutIds = new Set(bouts.filter(isFinishedOrActiveBout).map(bout => String(getBoutId(bout))));
73
+ const protectedAssignments = previousAssignments.filter(assignment =>
74
+ protectedBoutIds.has(String(assignment.DEBoutId))
75
+ );
76
+ const availableStations = stations.filter(station => station?.RingId != null && station.Director?.PersonId != null);
77
+ if (!availableStations.length) return protectedAssignments.map(assignment => ({ ...assignment }));
78
+ const rounds = groupPlannedRounds(bouts);
79
+ const openingRoundSize = rounds[0]?.[0];
80
+ let stationLoads = availableStations.map(() => 0);
81
+ const assignments = [];
82
+ for (const [size, roundBouts] of rounds) {
83
+ const coverage = createRoundAssignments({
84
+ bouts: roundBouts,
85
+ stations: availableStations,
86
+ stationLoads,
87
+ isOpeningRound: size === openingRoundSize
88
+ });
89
+ assignments.push(...coverage.assignments);
90
+ stationLoads = coverage.stationLoads;
91
+ }
92
+ return [...assignments, ...protectedAssignments.map(assignment => ({ ...assignment }))];
93
+ };