alessandro trinca tornidor
commited on
Commit
·
7edcc96
1
Parent(s):
52cfa0b
test: add data-test-id and aria-labels to improve the playwright tests
Browse files- static/e2e/{samgis-be.spec.ts → samgis-be-driverjs.spec.ts} +8 -1
- static/src/components/NavBar/MobileNavBar.vue +2 -2
- static/src/components/NavBar/NavBar.vue +2 -2
- static/src/components/NavBar/TabComponent.vue +2 -0
- static/src/components/PageFooter.vue +2 -1
- static/src/components/PageFooterHyperlink.vue +1 -0
- static/src/components/PageLayout.vue +2 -2
- static/src/components/PagePredictionMap.vue +25 -23
- static/src/components/StatsGrid.vue +1 -1
- static/src/components/TableGenericComponent.vue +1 -1
- static/src/components/buttons/ButtonMapSendRequest.vue +4 -0
- static/tests/MobileNavBar.test.ts +5 -2
- static/tests/NavBar.test.ts +7 -5
static/e2e/{samgis-be.spec.ts → samgis-be-driverjs.spec.ts}
RENAMED
|
@@ -37,12 +37,19 @@ test('test the driver.js tour on the localhost SamGIS-be page', async ({ page })
|
|
| 37 |
await expect(navigationMapLock).toBeVisible()
|
| 38 |
await expect(navigationMapLock).not.toBeChecked()
|
| 39 |
|
| 40 |
-
const mapLocator = page.
|
| 41 |
await expect(mapLocator).toBeVisible()
|
| 42 |
await expect(mapLocator).toMatchAriaSnapshot({ name: 'mapLocatorTestDriverJS.aria.yaml'})
|
| 43 |
|
| 44 |
const sendButton = page.getByRole('button', { name: 'Empty prompt (disabled)' })
|
| 45 |
await expect(sendButton).toBeVisible()
|
| 46 |
await expect(sendButton).toBeDisabled()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
await page.close();
|
| 48 |
});
|
|
|
|
|
|
| 37 |
await expect(navigationMapLock).toBeVisible()
|
| 38 |
await expect(navigationMapLock).not.toBeChecked()
|
| 39 |
|
| 40 |
+
const mapLocator = page.getByTestId("map-container")
|
| 41 |
await expect(mapLocator).toBeVisible()
|
| 42 |
await expect(mapLocator).toMatchAriaSnapshot({ name: 'mapLocatorTestDriverJS.aria.yaml'})
|
| 43 |
|
| 44 |
const sendButton = page.getByRole('button', { name: 'Empty prompt (disabled)' })
|
| 45 |
await expect(sendButton).toBeVisible()
|
| 46 |
await expect(sendButton).toBeDisabled()
|
| 47 |
+
|
| 48 |
+
const footerMsg = page.getByText('Trouble on scrolling this page? Open the direct URL space as a new tab. SamGIS')
|
| 49 |
+
await expect(footerMsg).toBeVisible();
|
| 50 |
+
await page.getByRole('button', { name: 'Close' }).click();
|
| 51 |
+
await expect(footerMsg).not.toBeVisible();
|
| 52 |
+
|
| 53 |
await page.close();
|
| 54 |
});
|
| 55 |
+
|
static/src/components/NavBar/MobileNavBar.vue
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
<template>
|
| 2 |
-
<
|
| 3 |
<TabComponent description="About SamGIS" href="https://trinca.tornidor.com/projects/samgis-segment-anything-applied-to-GIS" />
|
| 4 |
<TabComponent description="My blog" href="https://trinca.tornidor.com/" />
|
| 5 |
<TabComponent description="SamGIS docs" href="https://docs.ml-trinca.tornidor.com/" />
|
| 6 |
-
</
|
| 7 |
</template>
|
| 8 |
|
| 9 |
<script setup lang="ts">
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<nav class="bg-gray-200 items-center h-8" data-testid="mobile-navbar" aria-label="Mobile navigation">
|
| 3 |
<TabComponent description="About SamGIS" href="https://trinca.tornidor.com/projects/samgis-segment-anything-applied-to-GIS" />
|
| 4 |
<TabComponent description="My blog" href="https://trinca.tornidor.com/" />
|
| 5 |
<TabComponent description="SamGIS docs" href="https://docs.ml-trinca.tornidor.com/" />
|
| 6 |
+
</nav>
|
| 7 |
</template>
|
| 8 |
|
| 9 |
<script setup lang="ts">
|
static/src/components/NavBar/NavBar.vue
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
<template>
|
| 2 |
-
<
|
| 3 |
<TabComponent description="About SamGIS" href="https://trinca.tornidor.com/projects/samgis-segment-anything-applied-to-GIS" />
|
| 4 |
<TabComponent description="My blog" href="https://trinca.tornidor.com/" />
|
| 5 |
<TabComponent description="SamGIS docs" href="https://docs.ml-trinca.tornidor.com/" />
|
| 6 |
-
</
|
| 7 |
</template>
|
| 8 |
|
| 9 |
<script setup lang="ts">
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<nav class="fixed top-2 right-5 mr-2 items-center" data-testid="navbar" aria-label="Main navigation">
|
| 3 |
<TabComponent description="About SamGIS" href="https://trinca.tornidor.com/projects/samgis-segment-anything-applied-to-GIS" />
|
| 4 |
<TabComponent description="My blog" href="https://trinca.tornidor.com/" />
|
| 5 |
<TabComponent description="SamGIS docs" href="https://docs.ml-trinca.tornidor.com/" />
|
| 6 |
+
</nav>
|
| 7 |
</template>
|
| 8 |
|
| 9 |
<script setup lang="ts">
|
static/src/components/NavBar/TabComponent.vue
CHANGED
|
@@ -3,6 +3,8 @@
|
|
| 3 |
<a :href="props.href"
|
| 4 |
class="bg-white border-2 no-underline pl-2 pr-2 p-1
|
| 5 |
landscape:border-gray-300 landscape:font-semibold landscape:text-lg"
|
|
|
|
|
|
|
| 6 |
>{{ props.description }}</a>
|
| 7 |
</h2>
|
| 8 |
</template>
|
|
|
|
| 3 |
<a :href="props.href"
|
| 4 |
class="bg-white border-2 no-underline pl-2 pr-2 p-1
|
| 5 |
landscape:border-gray-300 landscape:font-semibold landscape:text-lg"
|
| 6 |
+
:data-testid="`tab-${props.description.toLowerCase().replace(/\s+/g, '-')}`"
|
| 7 |
+
:aria-label="props.description"
|
| 8 |
>{{ props.description }}</a>
|
| 9 |
</h2>
|
| 10 |
</template>
|
static/src/components/PageFooter.vue
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
<template>
|
| 2 |
<!-- style 'z-index: 1001' here is needed to avoid override from leafletjs css -->
|
| 3 |
-
<footer class="fixed bottom-0 w-full pl-4 font-light text-xs" style="z-index: 9999;" v-if="showFooterRef">
|
| 4 |
<div class="relative flex items-center bg-gray-200 h-6">
|
| 5 |
<div class="pl-1 w-full">
|
| 6 |
<p class="">
|
|
@@ -17,6 +17,7 @@
|
|
| 17 |
<div class="pr-2">
|
| 18 |
<button
|
| 19 |
aria-label="Close"
|
|
|
|
| 20 |
class="shrink-0 rounded-lg bg-black/10 p-1 transition hover:bg-black/20"
|
| 21 |
@click="showFooterRef = !showFooterRef"
|
| 22 |
>Close</button>
|
|
|
|
| 1 |
<template>
|
| 2 |
<!-- style 'z-index: 1001' here is needed to avoid override from leafletjs css -->
|
| 3 |
+
<footer class="fixed bottom-0 w-full pl-4 font-light text-xs" style="z-index: 9999;" v-if="showFooterRef" data-testid="page-footer">
|
| 4 |
<div class="relative flex items-center bg-gray-200 h-6">
|
| 5 |
<div class="pl-1 w-full">
|
| 6 |
<p class="">
|
|
|
|
| 17 |
<div class="pr-2">
|
| 18 |
<button
|
| 19 |
aria-label="Close"
|
| 20 |
+
data-testid="footer-close-button"
|
| 21 |
class="shrink-0 rounded-lg bg-black/10 p-1 transition hover:bg-black/20"
|
| 22 |
@click="showFooterRef = !showFooterRef"
|
| 23 |
>Close</button>
|
static/src/components/PageFooterHyperlink.vue
CHANGED
|
@@ -4,6 +4,7 @@
|
|
| 4 |
class="underline"
|
| 5 |
target="_blank"
|
| 6 |
rel="noopener noreferrer"
|
|
|
|
| 7 |
>
|
| 8 |
<slot />
|
| 9 |
</a>
|
|
|
|
| 4 |
class="underline"
|
| 5 |
target="_blank"
|
| 6 |
rel="noopener noreferrer"
|
| 7 |
+
:data-testid="`footer-link-${props.path.replace(/https?:\/\//, '').replace(/[^a-z0-9]+/gi, '-').replace(/-$/, '').toLowerCase()}`"
|
| 8 |
>
|
| 9 |
<slot />
|
| 10 |
</a>
|
static/src/components/PageLayout.vue
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
<template>
|
| 2 |
-
<div class="relative min-h-screen lg:flex">
|
| 3 |
<!-- Menubar -->
|
| 4 |
<NavBar class="hidden portrait:sd:hidden portrait:md:flex landscape:flex" style="z-index: 9999;"/>
|
| 5 |
<MobileNavBar class="flex portrait:sd:flex portrait:md:hidden landscape:hidden" style="z-index: 9999;"/>
|
| 6 |
|
| 7 |
<main id="content" class="flex-1 z-1 lg:ml-0 mr-4 overflow-y-auto md:pl-1 lg:h-screen">
|
| 8 |
-
<header class="hidden items-center justify-between h-10 ml-2 landscape:md:flex portrait:sd:flex portrait:md:h-12 bg-gray-200 border-b">
|
| 9 |
<h2 class="hidden sd:text-sm ml-2 md:block md:text-2xl">{{ props.pageTitle }}</h2>
|
| 10 |
</header>
|
| 11 |
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<div class="relative min-h-screen lg:flex" data-testid="page-layout">
|
| 3 |
<!-- Menubar -->
|
| 4 |
<NavBar class="hidden portrait:sd:hidden portrait:md:flex landscape:flex" style="z-index: 9999;"/>
|
| 5 |
<MobileNavBar class="flex portrait:sd:flex portrait:md:hidden landscape:hidden" style="z-index: 9999;"/>
|
| 6 |
|
| 7 |
<main id="content" class="flex-1 z-1 lg:ml-0 mr-4 overflow-y-auto md:pl-1 lg:h-screen">
|
| 8 |
+
<header class="hidden items-center justify-between h-10 ml-2 landscape:md:flex portrait:sd:flex portrait:md:h-12 bg-gray-200 border-b" aria-label="Page header">
|
| 9 |
<h2 class="hidden sd:text-sm ml-2 md:block md:text-2xl">{{ props.pageTitle }}</h2>
|
| 10 |
</header>
|
| 11 |
|
static/src/components/PagePredictionMap.vue
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
<template>
|
| 2 |
-
<div class="h-auto" id="id-prediction-map-container">
|
| 3 |
|
| 4 |
<div class="grid grid-cols-1 2xl:grid-cols-5 lg:gap-1 lg:border-r ml-2 mt-2 md:ml-4 md:mr-4">
|
| 5 |
|
| 6 |
<div class="lg:border-r lg:col-span-3">
|
| 7 |
-
<div id="id-map-cont" class="">
|
| 8 |
<p class="hidden lg:block">{{ description }}</p>
|
| 9 |
<div class="w-full md:pt-1 md:pb-1">
|
| 10 |
<ButtonMapSendRequest
|
|
@@ -18,19 +18,19 @@
|
|
| 18 |
:waiting-string="waitingString"
|
| 19 |
/>
|
| 20 |
<span class="ml-2">
|
| 21 |
-
<input type="checkbox" id="checkboxMapNavigationLocked" v-model="mapNavigationLocked" />
|
| 22 |
<span class="ml-2">
|
| 23 |
<label class="text-red-600" for="checkboxMapNavigationLocked" v-if="mapNavigationLocked">locked map navigation!</label>
|
| 24 |
<label class="text-blue-600" for="checkboxMapNavigationLocked" v-else>map navigation unlocked</label>
|
| 25 |
</span>
|
| 26 |
</span>
|
| 27 |
</div>
|
| 28 |
-
<div id="map" class="map-predictions" />
|
| 29 |
</div>
|
| 30 |
</div>
|
| 31 |
|
| 32 |
<div class="lg:col-span-2">
|
| 33 |
-
<div class="lg:pl-2 lg:pr-2 lg:border-l lg:border-3" id="id-map-info">
|
| 34 |
|
| 35 |
<h1>Map Info</h1>
|
| 36 |
<div class="grid grid-cols-1 md:grid-cols-3">
|
|
@@ -53,25 +53,27 @@
|
|
| 53 |
</div>
|
| 54 |
</div>
|
| 55 |
|
| 56 |
-
<h1 id="id-ml-request-prompt">ML request prompt</h1>
|
| 57 |
<p>Exclude points: label 0, include points: label 1.</p>
|
| 58 |
-
<div
|
| 59 |
-
<
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
<
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
</div>
|
| 76 |
</div>
|
| 77 |
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<div class="h-auto" id="id-prediction-map-container" data-testid="prediction-map-container">
|
| 3 |
|
| 4 |
<div class="grid grid-cols-1 2xl:grid-cols-5 lg:gap-1 lg:border-r ml-2 mt-2 md:ml-4 md:mr-4">
|
| 5 |
|
| 6 |
<div class="lg:border-r lg:col-span-3">
|
| 7 |
+
<div id="id-map-cont" class="" data-testid="map-section">
|
| 8 |
<p class="hidden lg:block">{{ description }}</p>
|
| 9 |
<div class="w-full md:pt-1 md:pb-1">
|
| 10 |
<ButtonMapSendRequest
|
|
|
|
| 18 |
:waiting-string="waitingString"
|
| 19 |
/>
|
| 20 |
<span class="ml-2">
|
| 21 |
+
<input type="checkbox" id="checkboxMapNavigationLocked" v-model="mapNavigationLocked" data-testid="map-navigation-checkbox" />
|
| 22 |
<span class="ml-2">
|
| 23 |
<label class="text-red-600" for="checkboxMapNavigationLocked" v-if="mapNavigationLocked">locked map navigation!</label>
|
| 24 |
<label class="text-blue-600" for="checkboxMapNavigationLocked" v-else>map navigation unlocked</label>
|
| 25 |
</span>
|
| 26 |
</span>
|
| 27 |
</div>
|
| 28 |
+
<div id="map" class="map-predictions" data-testid="map-container" />
|
| 29 |
</div>
|
| 30 |
</div>
|
| 31 |
|
| 32 |
<div class="lg:col-span-2">
|
| 33 |
+
<div class="lg:pl-2 lg:pr-2 lg:border-l lg:border-3" id="id-map-info" data-testid="map-info">
|
| 34 |
|
| 35 |
<h1>Map Info</h1>
|
| 36 |
<div class="grid grid-cols-1 md:grid-cols-3">
|
|
|
|
| 53 |
</div>
|
| 54 |
</div>
|
| 55 |
|
| 56 |
+
<h1 id="id-ml-request-prompt" data-testid="ml-request-prompt">ML request prompt</h1>
|
| 57 |
<p>Exclude points: label 0, include points: label 1.</p>
|
| 58 |
+
<div aria-label="Table container for the positions of map markers and rectangles">
|
| 59 |
+
<div v-if="promptsArrayRef.filter(el => {return el.type === 'point'}).length > 0">
|
| 60 |
+
<TableGenericComponent
|
| 61 |
+
:header="['id', 'data', 'label']"
|
| 62 |
+
:rows="applyFnToObjectWithinArray(promptsArrayRef.filter(el => {return el.type === 'point'}))"
|
| 63 |
+
title="Table with the position for the map markers"
|
| 64 |
+
row-key="id"
|
| 65 |
+
/>
|
| 66 |
+
</div>
|
| 67 |
+
<br />
|
| 68 |
+
<div v-if="promptsArrayRef.filter(el => {return el.type === 'rectangle'}).length > 0">
|
| 69 |
+
<TableGenericComponent
|
| 70 |
+
:header="['id', 'data_ne', 'data_sw']"
|
| 71 |
+
:rows="applyFnToObjectWithinArray(promptsArrayRef.filter(el => {return el.type === 'rectangle'}))"
|
| 72 |
+
title="Table with the position for the map rectangles"
|
| 73 |
+
row-key="id"
|
| 74 |
+
class="2md:min-h-[100px]"
|
| 75 |
+
/>
|
| 76 |
+
</div>
|
| 77 |
</div>
|
| 78 |
</div>
|
| 79 |
|
static/src/components/StatsGrid.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
<template>
|
| 2 |
-
<dl class="grid md:pt-1 md:pb-1" v-for="item in props.statsArray" v-bind:key="item.statName">
|
| 3 |
<div class="flex flex-col bg-blue-100 text-center p-1 md:p-2">
|
| 4 |
<dt class="order-last font-medium">{{ item.statName }}</dt>
|
| 5 |
<dd class="text-lg font-extrabold text-blue-600">{{ item.statValue }}</dd>
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<dl class="grid md:pt-1 md:pb-1" v-for="item in props.statsArray" v-bind:key="item.statName" data-testid="stats-grid">
|
| 3 |
<div class="flex flex-col bg-blue-100 text-center p-1 md:p-2">
|
| 4 |
<dt class="order-last font-medium">{{ item.statName }}</dt>
|
| 5 |
<dd class="text-lg font-extrabold text-blue-600">{{ item.statValue }}</dd>
|
static/src/components/TableGenericComponent.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
<template>
|
| 2 |
-
<table class="min-w-full divide-y-2 divide-gray-200 border border-gray-200 bg-white text-left p-4">
|
| 3 |
<caption class="text-2xl bg-blue-100">{{ props.title }}</caption>
|
| 4 |
<thead class="text-left">
|
| 5 |
<tr class="text-left">
|
|
|
|
| 1 |
<template>
|
| 2 |
+
<table class="min-w-full divide-y-2 divide-gray-200 border border-gray-200 bg-white text-left p-4" data-testid="table-component" :aria-label="props.title">
|
| 3 |
<caption class="text-2xl bg-blue-100">{{ props.title }}</caption>
|
| 4 |
<thead class="text-left">
|
| 5 |
<tr class="text-left">
|
static/src/components/buttons/ButtonMapSendRequest.vue
CHANGED
|
@@ -3,12 +3,16 @@
|
|
| 3 |
:class="`${props.class} bg-gray-200 bg-opacity-50`"
|
| 4 |
:disabled="promptsArray.length === 0 || responseMessage === waitingString"
|
| 5 |
v-if="promptsArray.length === 0 || responseMessage === waitingString"
|
|
|
|
|
|
|
| 6 |
>{{ responseMessage === waitingString ? responseMessage : '🚫 Empty prompt (disabled)' }}
|
| 7 |
</button>
|
| 8 |
<button
|
| 9 |
:class="`${props.class} bg-blue-300 whitespace-no-wrap overflow-hidden truncate`"
|
| 10 |
@click="sendMLRequest(map, promptsArray, currentBaseMapName)"
|
| 11 |
v-else
|
|
|
|
|
|
|
| 12 |
>
|
| 13 |
<span v-if="responseMessage && responseMessage !== '-'">{{ responseMessage }}</span>
|
| 14 |
<span v-else>🔍 send ML request</span>
|
|
|
|
| 3 |
:class="`${props.class} bg-gray-200 bg-opacity-50`"
|
| 4 |
:disabled="promptsArray.length === 0 || responseMessage === waitingString"
|
| 5 |
v-if="promptsArray.length === 0 || responseMessage === waitingString"
|
| 6 |
+
data-testid="submit-button-disabled"
|
| 7 |
+
aria-disabled="true"
|
| 8 |
>{{ responseMessage === waitingString ? responseMessage : '🚫 Empty prompt (disabled)' }}
|
| 9 |
</button>
|
| 10 |
<button
|
| 11 |
:class="`${props.class} bg-blue-300 whitespace-no-wrap overflow-hidden truncate`"
|
| 12 |
@click="sendMLRequest(map, promptsArray, currentBaseMapName)"
|
| 13 |
v-else
|
| 14 |
+
data-testid="submit-button"
|
| 15 |
+
aria-label="Send ML request"
|
| 16 |
>
|
| 17 |
<span v-if="responseMessage && responseMessage !== '-'">{{ responseMessage }}</span>
|
| 18 |
<span v-else>🔍 send ML request</span>
|
static/tests/MobileNavBar.test.ts
CHANGED
|
@@ -3,9 +3,12 @@ import { shallowMount } from '@vue/test-utils'
|
|
| 3 |
import MobileNavBar from '@/components/NavBar/MobileNavBar.vue'
|
| 4 |
|
| 5 |
describe('MobileNavBar', () => {
|
| 6 |
-
it('renders a container
|
| 7 |
const wrapper = shallowMount(MobileNavBar)
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
| 9 |
})
|
| 10 |
|
| 11 |
it('renders exactly three TabComponent instances', () => {
|
|
|
|
| 3 |
import MobileNavBar from '@/components/NavBar/MobileNavBar.vue'
|
| 4 |
|
| 5 |
describe('MobileNavBar', () => {
|
| 6 |
+
it('renders a container nav with bg-gray-200 class', () => {
|
| 7 |
const wrapper = shallowMount(MobileNavBar)
|
| 8 |
+
const nav = wrapper.find('nav.bg-gray-200')
|
| 9 |
+
expect(nav.exists()).toBe(true)
|
| 10 |
+
expect(nav.attributes('data-testid')).toBe('mobile-navbar')
|
| 11 |
+
expect(nav.attributes('aria-label')).toBe('Mobile navigation')
|
| 12 |
})
|
| 13 |
|
| 14 |
it('renders exactly three TabComponent instances', () => {
|
static/tests/NavBar.test.ts
CHANGED
|
@@ -3,12 +3,14 @@ import { shallowMount } from '@vue/test-utils'
|
|
| 3 |
import NavBar from '@/components/NavBar/NavBar.vue'
|
| 4 |
|
| 5 |
describe('NavBar', () => {
|
| 6 |
-
it('renders a fixed-position container
|
| 7 |
const wrapper = shallowMount(NavBar)
|
| 8 |
-
const
|
| 9 |
-
expect(
|
| 10 |
-
expect(
|
| 11 |
-
expect(
|
|
|
|
|
|
|
| 12 |
})
|
| 13 |
|
| 14 |
it('renders exactly three TabComponent instances', () => {
|
|
|
|
| 3 |
import NavBar from '@/components/NavBar/NavBar.vue'
|
| 4 |
|
| 5 |
describe('NavBar', () => {
|
| 6 |
+
it('renders a fixed-position container nav', () => {
|
| 7 |
const wrapper = shallowMount(NavBar)
|
| 8 |
+
const nav = wrapper.find('nav.fixed')
|
| 9 |
+
expect(nav.exists()).toBe(true)
|
| 10 |
+
expect(nav.classes()).toContain('top-2')
|
| 11 |
+
expect(nav.classes()).toContain('right-5')
|
| 12 |
+
expect(nav.attributes('data-testid')).toBe('navbar')
|
| 13 |
+
expect(nav.attributes('aria-label')).toBe('Main navigation')
|
| 14 |
})
|
| 15 |
|
| 16 |
it('renders exactly three TabComponent instances', () => {
|