import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' import { defineComponent, h, ref } from 'vue' // ── Auto-imports Nuxt stubbes globalement ─────────────────────────────────── // La page ne les importe pas (auto-import) : on les expose en globals pour le // runtime de test (happy-dom). Meme philosophie que les specs M1→M4. const mockPush = vi.hoisted(() => vi.fn()) const mockApiGet = vi.hoisted(() => vi.fn()) const mockCan = vi.hoisted(() => vi.fn()) const mockFetch = vi.hoisted(() => vi.fn()) const mockReset = vi.hoisted(() => vi.fn()) const mockToastError = vi.hoisted(() => vi.fn()) vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) vi.stubGlobal('useHead', () => undefined) vi.stubGlobal('useApi', () => ({ get: mockApiGet })) vi.stubGlobal('useRouter', () => ({ push: mockPush })) vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() })) vi.stubGlobal('usePermissions', () => ({ can: mockCan })) // Site courant (switcher global) : ref pilotable pour simuler un changement de site. const currentSiteRef = ref<{ id: number } | null>(null) vi.stubGlobal('useCurrentSite', () => ({ currentSite: currentSiteRef })) // Le repository est lui aussi un auto-import : on controle les items renvoyes. // Contrepartie CLIENT (RG-5.03) → supplier / otherLabel absents (skip_null_values). vi.stubGlobal('useWeighingTicketsRepository', () => ({ items: ref([ { id: 9, number: '86-TP-0001', client: { id: 629, companyName: 'NÉGOCE MÉTAUX ATLANTIQUE' }, supplier: null, otherLabel: null, displayDate: '2026-06-17T09:12:00+02:00', netWeight: 7150, }, ]), totalItems: ref(1), currentPage: ref(1), itemsPerPage: ref(10), itemsPerPageOptions: ref([10, 25, 50]), fetch: mockFetch, goToPage: vi.fn(), setItemsPerPage: vi.fn(), setFilters: vi.fn(), reset: mockReset, })) // happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques // sur la classe URL existante (sans la remplacer — sinon `new URL()` casse). globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake') globalThis.URL.revokeObjectURL = vi.fn() // Import APRES les stubs (la page resout les auto-imports au top-level du module). const WeighingTicketsIndex = (await import('../weighing-tickets/index.vue')).default // ── Stubs de composants ────────────────────────────────────────────────────── const ButtonStub = defineComponent({ props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } }, emits: ['click'], setup(props, { emit }) { return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label) }, }) // Capture les `items` (rows) passes par la page : on rend chaque ligne avec ses // cellules formatees (date / poids) pour pouvoir asserter le mapping des colonnes. const capturedRows = ref>>([]) const DataTableStub = defineComponent({ props: { items: { type: Array, default: () => [] } }, emits: ['row-click', 'update:page', 'update:per-page'], setup(props, { emit }) { return () => { capturedRows.value = props.items as Array> return h('div', { 'data-testid': 'datatable' }, (props.items as Array>).map(it => h('tr', { 'data-row-id': it.id as number, onClick: () => emit('row-click', it) }, [ h('td', { 'data-cell': 'displayDate' }, it.displayDate as string), h('td', { 'data-cell': 'netWeight' }, it.netWeight as string), h('td', { 'data-cell': 'client' }, it.client as string), h('td', { 'data-cell': 'supplier' }, it.supplier as string), ]), ), ) } }, }) const PageHeaderStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) }, }) // Suivi des wrappers montés pour les démonter entre tests : sans cela, les // watchers sur la ref module-level `currentSiteRef` (site courant) fuiteraient // d'un test à l'autre et se déclencheraient en double. const mountedWrappers: VueWrapper[] = [] function mountPage() { const wrapper = mount(WeighingTicketsIndex, { global: { stubs: { PageHeader: PageHeaderStub, MalioButton: ButtonStub, MalioDataTable: DataTableStub, }, }, }) mountedWrappers.push(wrapper) return wrapper } describe('Liste des tickets de pesée (page /weighing-tickets)', () => { beforeEach(() => { mockPush.mockReset() mockApiGet.mockReset().mockResolvedValue(new Blob()) mockCan.mockReset().mockReturnValue(true) mockFetch.mockReset() mockReset.mockReset() mockToastError.mockReset() capturedRows.value = [] currentSiteRef.value = null }) afterEach(() => { // Démonte les composants montés → libère leurs watchers (site courant). while (mountedWrappers.length > 0) { mountedWrappers.pop()?.unmount() } }) it('charge la liste au montage', async () => { mountPage() await flushPromises() expect(mockFetch).toHaveBeenCalled() }) it('recharge la liste (page 1) quand le site courant change', async () => { mountPage() await flushPromises() expect(mockReset).not.toHaveBeenCalled() // Simule un switch de site via le switcher global. currentSiteRef.value = { id: 2 } await flushPromises() expect(mockReset).toHaveBeenCalledTimes(1) }) it('formate la date au format JJ-MM-AAAA', async () => { const wrapper = mountPage() await flushPromises() expect(wrapper.find('[data-cell="displayDate"]').text()).toBe('17-06-2026') }) it('formate le poids net en kg avec separateur de milliers', async () => { const wrapper = mountPage() await flushPromises() expect(wrapper.find('[data-cell="netWeight"]').text()).toBe('7 150 Kg') }) it('mappe la contrepartie Client (supplier vide car contrepartie ≠ Fournisseur)', async () => { const wrapper = mountPage() await flushPromises() expect(wrapper.find('[data-cell="client"]').text()).toBe('NÉGOCE MÉTAUX ATLANTIQUE') expect(wrapper.find('[data-cell="supplier"]').text()).toBe('') }) it('affiche « + Ajouter » uniquement avec la permission manage', async () => { mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.manage') const wrapper = mountPage() await flushPromises() expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(true) }) it('masque « + Ajouter » sans la permission manage (view seul)', async () => { mockCan.mockImplementation((perm: string) => perm === 'logistique.weighing_tickets.view') const wrapper = mountPage() await flushPromises() expect(wrapper.find('[data-label="logistique.weighingTickets.add"]').exists()).toBe(false) }) it('navigue vers la modification au clic sur une ligne', async () => { const wrapper = mountPage() await flushPromises() await wrapper.find('tr[data-row-id="9"]').trigger('click') expect(mockPush).toHaveBeenCalledWith('/weighing-tickets/9/edit') }) it('appelle l\'export XLSX sur /weighing_tickets/export.xlsx en blob', async () => { const wrapper = mountPage() await flushPromises() await wrapper.find('[data-label="logistique.weighingTickets.export"]').trigger('click') await flushPromises() expect(mockApiGet).toHaveBeenCalledWith( '/weighing_tickets/export.xlsx', expect.any(Object), expect.objectContaining({ responseType: 'blob', toast: false }), ) }) })