Add LibreTranslate auto-translation, improve test coverage, fix code duplication

Translation system:
- Add LibreTranslate container (dev + prod), CPU-only, no port exposed, FR/EN/ES/DE/IT
- Create app:translate command: reads *.fr.yaml, translates incrementally, preserves placeholders
- Makefile: make trans / make trans_prod (stops container after translation)
- Ansible: start libretranslate -> translate -> stop during deploy
- Prod container restart: "no" (only runs during deploy)
- .gitignore: ignore generated *.en/es/de/it.yaml files
- 11 tests for TranslateCommand (API unreachable, empty, incremental, obsolete keys, placeholders, fallback)

Test coverage improvements:
- OrderController: event ended (400), invalid cart JSON, invalid email, stock zero (4 new tests)
- AccountController: finance stats all statuses (paid/pending/refunded/cancelled), soldCounts (2 new tests)
- JS cart: checkout without error elements, hide error on retry, stock polling edge cases (singular, no label, qty zero, unknown billet) (8 new tests)
- JS editor: comment node sanitization (1 new test)
- JS tabs: missing panel, generated id, parent null, click no-panel (5 new tests)

Code duplication fixes:
- MeilisearchConsistencyCommand: extract diffAndReport() method (was duplicated 3x)
- Email templates: extract _order_items_table.html.twig partial (shared by notification + cancelled)
- SonarQube: exclude src/Entity/** from CPD (getters/setters duplication)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 11:44:13 +01:00
parent 04927ec988
commit 42d06dd49f
16 changed files with 1087 additions and 138 deletions

View File

@@ -0,0 +1,340 @@
<?php
namespace App\Tests\Command;
use App\Command\TranslateCommand;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
class TranslateCommandTest extends TestCase
{
private string $tmpDir;
private string $translationsDir;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir().'/translate_test_'.uniqid();
$this->translationsDir = $this->tmpDir.'/translations';
mkdir($this->translationsDir, 0o755, true);
}
protected function tearDown(): void
{
$files = glob($this->translationsDir.'/*') ?: [];
foreach ($files as $f) {
unlink($f);
}
rmdir($this->translationsDir);
rmdir($this->tmpDir);
}
private function createTester(HttpClientInterface $httpClient): CommandTester
{
$command = new TranslateCommand($httpClient, $this->tmpDir);
$app = new Application();
$app->addCommand($command);
return new CommandTester($app->find('app:translate'));
}
private function mockHttpClient(array $translateResponses = [], bool $apiAvailable = true): HttpClientInterface
{
$httpClient = $this->createMock(HttpClientInterface::class);
$callIndex = 0;
$httpClient->method('request')->willReturnCallback(
function (string $method, string $url) use (&$callIndex, $translateResponses, $apiAvailable) {
$response = $this->createMock(ResponseInterface::class);
if ('GET' === $method && str_contains($url, '/languages')) {
if (!$apiAvailable) {
throw new \RuntimeException('Connection refused');
}
$response->method('getStatusCode')->willReturn(200);
return $response;
}
if ('POST' === $method && str_contains($url, '/translate')) {
$translated = $translateResponses[$callIndex] ?? 'Translated text';
++$callIndex;
$response->method('toArray')->willReturn(['translatedText' => $translated]);
return $response;
}
return $response;
}
);
return $httpClient;
}
public function testApiUnreachable(): void
{
$httpClient = $this->mockHttpClient([], false);
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(1, $tester->getStatusCode());
self::assertStringContainsString('not reachable', $tester->getDisplay());
}
public function testNoFrenchFiles(): void
{
$httpClient = $this->mockHttpClient();
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('No French translation files', $tester->getDisplay());
}
public function testEmptyFrenchFile(): void
{
file_put_contents($this->translationsDir.'/messages.fr.yaml', '');
$httpClient = $this->mockHttpClient();
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Skipped', $tester->getDisplay());
}
public function testTranslatesNewKeys(): void
{
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
'hello' => 'Bonjour',
'goodbye' => 'Au revoir',
]));
// 2 keys × 4 languages = 8 translations
$responses = array_fill(0, 8, null);
$responses[0] = 'Hello';
$responses[1] = 'Goodbye';
$responses[2] = 'Hola';
$responses[3] = 'Adios';
$responses[4] = 'Hallo';
$responses[5] = 'Auf Wiedersehen';
$responses[6] = 'Ciao';
$responses[7] = 'Arrivederci';
$httpClient = $this->mockHttpClient($responses);
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Translated 2 new keys', $tester->getDisplay());
// Check EN file
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
self::assertSame('Hello', $enData['hello']);
self::assertSame('Goodbye', $enData['goodbye']);
// Check ES file
$esData = Yaml::parseFile($this->translationsDir.'/messages.es.yaml');
self::assertSame('Hola', $esData['hello']);
// Check DE file
self::assertFileExists($this->translationsDir.'/messages.de.yaml');
// Check IT file
self::assertFileExists($this->translationsDir.'/messages.it.yaml');
}
public function testSkipsExistingKeys(): void
{
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
'hello' => 'Bonjour',
'new_key' => 'Nouveau',
]));
file_put_contents($this->translationsDir.'/messages.en.yaml', Yaml::dump([
'hello' => 'Hello (manual)',
]));
// Only new_key needs translating for EN, then 2 keys for ES/DE/IT = 7
$responses = [];
$responses[0] = 'New'; // new_key → EN
$responses[1] = 'Hola'; // hello → ES
$responses[2] = 'Nuevo'; // new_key → ES
$responses[3] = 'Hallo'; // hello → DE
$responses[4] = 'Neu'; // new_key → DE
$responses[5] = 'Ciao'; // hello → IT
$responses[6] = 'Nuovo'; // new_key → IT
$httpClient = $this->mockHttpClient($responses);
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(0, $tester->getStatusCode());
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
self::assertSame('Hello (manual)', $enData['hello']);
self::assertSame('New', $enData['new_key']);
}
public function testRemovesObsoleteKeys(): void
{
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
'hello' => 'Bonjour',
]));
file_put_contents($this->translationsDir.'/messages.en.yaml', Yaml::dump([
'hello' => 'Hello',
'removed_key' => 'This was removed from FR',
]));
$httpClient = $this->mockHttpClient();
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(0, $tester->getStatusCode());
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
self::assertArrayHasKey('hello', $enData);
self::assertArrayNotHasKey('removed_key', $enData);
self::assertStringContainsString('Removed 1 obsolete', $tester->getDisplay());
}
public function testAlreadyUpToDate(): void
{
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
'hello' => 'Bonjour',
]));
foreach (['en', 'es', 'de', 'it'] as $lang) {
file_put_contents($this->translationsDir.'/messages.'.$lang.'.yaml', Yaml::dump([
'hello' => 'Translated',
]));
}
$httpClient = $this->mockHttpClient();
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Already up to date', $tester->getDisplay());
}
public function testPreservesPlaceholders(): void
{
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
'retry' => 'Reessayez dans %minutes% minutes.',
]));
// LibreTranslate might mangle the placeholder
$responses = [
'Try again in % minutes % minutes.', // EN - mangled
'Inténtelo de nuevo en %minutes% minutos.', // ES - kept
'Versuchen Sie es in % minuten % Minuten erneut.', // DE - mangled
'Riprova tra %minutes% minuti.', // IT - kept
];
$httpClient = $this->mockHttpClient($responses);
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(0, $tester->getStatusCode());
$esData = Yaml::parseFile($this->translationsDir.'/messages.es.yaml');
self::assertStringContainsString('%minutes%', $esData['retry']);
}
public function testTranslationApiFailureFallbacksToOriginal(): void
{
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
'error' => 'Une erreur est survenue.',
]));
$httpClient = $this->createMock(HttpClientInterface::class);
$callCount = 0;
$httpClient->method('request')->willReturnCallback(
function (string $method, string $url) use (&$callCount) {
$response = $this->createMock(ResponseInterface::class);
if ('GET' === $method && str_contains($url, '/languages')) {
$response->method('getStatusCode')->willReturn(200);
return $response;
}
if ('POST' === $method && str_contains($url, '/translate')) {
++$callCount;
throw new \RuntimeException('API error');
}
return $response;
}
);
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(0, $tester->getStatusCode());
self::assertStringContainsString('Failed to translate', $tester->getDisplay());
// Fallback: original FR text used
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
self::assertSame('Une erreur est survenue.', $enData['error']);
}
public function testMultipleDomainFiles(): void
{
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
'hello' => 'Bonjour',
]));
file_put_contents($this->translationsDir.'/security.fr.yaml', Yaml::dump([
'login' => 'Connexion',
]));
$httpClient = $this->mockHttpClient(array_fill(0, 8, 'Translated'));
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
self::assertSame(0, $tester->getStatusCode());
self::assertFileExists($this->translationsDir.'/messages.en.yaml');
self::assertFileExists($this->translationsDir.'/security.en.yaml');
self::assertFileExists($this->translationsDir.'/messages.it.yaml');
self::assertFileExists($this->translationsDir.'/security.it.yaml');
}
public function testKeysOrderMatchesFrench(): void
{
file_put_contents($this->translationsDir.'/messages.fr.yaml', Yaml::dump([
'aaa' => 'Premier',
'zzz' => 'Dernier',
'mmm' => 'Milieu',
]));
$responses = ['First', 'Last', 'Middle'];
// Repeat for 4 languages
$allResponses = array_merge($responses, $responses, $responses, $responses);
$httpClient = $this->mockHttpClient($allResponses);
$tester = $this->createTester($httpClient);
$tester->execute(['--url' => 'http://fake:5000']);
$enData = Yaml::parseFile($this->translationsDir.'/messages.en.yaml');
$keys = array_keys($enData);
self::assertSame(['aaa', 'zzz', 'mmm'], $keys);
}
}

View File

@@ -1954,6 +1954,94 @@ class AccountControllerTest extends WebTestCase
return $category;
}
public function testOrganizerFinanceStatsWithAllStatuses(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$user->setStripeAccountId('acct_finance_'.uniqid());
$user->setStripeChargesEnabled(true);
$user->setCommissionRate(3.0);
$em->flush();
$event = $this->createEvent($em, $user);
$event->setIsOnline(true);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$em->flush();
$statuses = [
\App\Entity\BilletBuyer::STATUS_PAID,
\App\Entity\BilletBuyer::STATUS_PENDING,
\App\Entity\BilletBuyer::STATUS_REFUNDED,
\App\Entity\BilletBuyer::STATUS_CANCELLED,
];
foreach ($statuses as $i => $status) {
$order = new \App\Entity\BilletBuyer();
$order->setEvent($event);
$order->setFirstName('F'.$i);
$order->setLastName('L'.$i);
$order->setEmail('f'.$i.'@test.fr');
$order->setOrderNumber('2026-'.random_int(10000, 99999));
$order->setTotalHT(1000);
$order->setStatus($status);
if (\App\Entity\BilletBuyer::STATUS_PAID === $status) {
$order->setPaidAt(new \DateTimeImmutable());
}
$item = new \App\Entity\BilletBuyerItem();
$item->setBillet($billet);
$item->setBilletName('Entree');
$item->setQuantity(1);
$item->setUnitPriceHT(1000);
$order->addItem($item);
$em->persist($order);
}
$em->flush();
$client->loginUser($user);
$client->request('GET', '/mon-compte?tab=payouts');
self::assertResponseIsSuccessful();
}
public function testEditEventStatsTabWithSoldCounts(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $user);
$event->setIsOnline(true);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$order = new \App\Entity\BilletBuyer();
$order->setEvent($event);
$order->setFirstName('Jean');
$order->setLastName('Sold');
$order->setEmail('sold@test.fr');
$order->setOrderNumber('2026-'.random_int(10000, 99999));
$order->setTotalHT(1000);
$order->setStatus(\App\Entity\BilletBuyer::STATUS_PAID);
$order->setPaidAt(new \DateTimeImmutable());
$item = new \App\Entity\BilletBuyerItem();
$item->setBillet($billet);
$item->setBilletName('Entree');
$item->setQuantity(3);
$item->setUnitPriceHT(1000);
$order->addItem($item);
$em->persist($order);
$em->flush();
$client->loginUser($user);
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/modifier?tab=stats');
self::assertResponseIsSuccessful();
}
/**
* @param list<string> $roles
*/

View File

@@ -530,4 +530,103 @@ class OrderControllerTest extends WebTestCase
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('redirect', $data);
}
public function testCreateOrderEventEnded(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrga($em);
$event = new Event();
$event->setAccount($user);
$event->setTitle('Past Event');
$event->setStartAt(new \DateTimeImmutable('-2 days'));
$event->setEndAt(new \DateTimeImmutable('-1 day'));
$event->setAddress('1 rue');
$event->setZipcode('75001');
$event->setCity('Paris');
$event->setIsOnline(true);
$em->persist($event);
$em->flush();
$client->request('POST', '/evenement/'.$event->getId().'/commander', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
['billetId' => 1, 'qty' => 1],
]));
self::assertResponseStatusCodeSame(400);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertStringContainsString('termine', $data['error']);
}
public function testCreateOrderInvalidCartStructure(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrga($em);
[$event] = $this->createEventWithBillet($em, $user);
$client->request('POST', '/evenement/'.$event->getId().'/commander', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
['invalid' => 'data'],
]));
self::assertResponseStatusCodeSame(400);
}
public function testGuestSubmitInvalidEmail(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrga($em);
[$event, $billet] = $this->createEventWithBillet($em, $user);
$order = $this->createOrder($em, $event, $billet);
$client->request('POST', '/commande/'.$order->getId().'/informations', [
'first_name' => 'Jean',
'last_name' => 'Dupont',
'email' => 'not-an-email',
]);
self::assertResponseRedirects('/commande/'.$order->getId().'/informations');
}
public function testCreateOrderStockZeroSkipsBillet(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createOrga($em);
$event = new Event();
$event->setAccount($user);
$event->setTitle('Stock Zero Event');
$event->setStartAt(new \DateTimeImmutable('2026-08-01 10:00'));
$event->setEndAt(new \DateTimeImmutable('2026-08-01 18:00'));
$event->setAddress('1 rue');
$event->setZipcode('75001');
$event->setCity('Paris');
$event->setIsOnline(true);
$em->persist($event);
$category = new Category();
$category->setName('Cat');
$category->setEvent($event);
$category->setStartAt(new \DateTimeImmutable('-1 day'));
$category->setEndAt(new \DateTimeImmutable('+30 days'));
$em->persist($category);
$billet = new Billet();
$billet->setName('Sold Out');
$billet->setCategory($category);
$billet->setPriceHT(1500);
$billet->setQuantity(0);
$em->persist($billet);
$em->flush();
$client->request('POST', '/evenement/'.$event->getId().'/commander', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
['billetId' => $billet->getId(), 'qty' => 1],
]));
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('redirect', $data);
}
}

View File

@@ -287,6 +287,52 @@ describe('initCart', () => {
expect(document.getElementById('cart-error-text').textContent).toContain('erreur')
})
it('handles checkout without error elements', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('fail'))
globalThis.fetch = fetchMock
document.body.innerHTML = `
<div id="billetterie">
<div data-cart-item data-billet-id="1" data-price="10" data-max="5">
<button data-cart-minus></button>
<input data-cart-qty type="number" min="0" max="5" value="0" readonly>
<button data-cart-plus></button>
<span data-cart-line-total></span>
</div>
<span id="cart-total"></span><span id="cart-count"></span>
<button id="cart-checkout" disabled data-order-url="/order"></button>
</div>
`
initCart()
document.querySelector('[data-cart-plus]').click()
document.getElementById('cart-checkout').click()
await new Promise(r => setTimeout(r, 10))
expect(document.getElementById('cart-checkout').disabled).toBe(false)
})
it('hides error on new checkout attempt', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ redirect: '/ok' }),
})
globalThis.fetch = fetchMock
globalThis.location = { href: '' }
createBilletterie([{ id: 1, price: '10.00', max: 5 }])
initCart()
const errorEl = document.getElementById('cart-error')
errorEl.classList.remove('hidden')
document.querySelector('[data-cart-plus]').click()
document.getElementById('cart-checkout').click()
expect(errorEl.classList.contains('hidden')).toBe(true)
})
it('does not post without order url', () => {
const fetchMock = vi.fn()
globalThis.fetch = fetchMock
@@ -407,4 +453,83 @@ describe('stock polling', () => {
// No crash, label unchanged
expect(document.querySelector('[data-stock-label]').innerHTML).toBe('')
})
it('skips billet not in stock response', async () => {
globalThis.fetch = mockStock({ 999: 10 })
globalThis.setInterval = (fn) => { fn(); return 1 }
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
initCart()
await new Promise(r => setTimeout(r, 20))
// Label unchanged — billet 1 not in response
expect(document.querySelector('[data-stock-label]').innerHTML).toBe('')
})
it('handles out of stock when qty already zero', async () => {
globalThis.fetch = mockStock({ 1: 0 })
globalThis.setInterval = (fn) => { fn(); return 1 }
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
initCart()
// Don't click +, qty stays at 0
await new Promise(r => setTimeout(r, 20))
expect(document.querySelector('[data-stock-label]').innerHTML).toContain('Rupture')
expect(document.querySelector('[data-cart-qty]').value).toBe('0')
})
it('shows singular place for stock of 1', async () => {
globalThis.fetch = mockStock({ 1: 1 })
globalThis.setInterval = (fn) => { fn(); return 1 }
createBilletterie([{ id: 1, price: '10.00', max: 5 }], '/stock')
initCart()
await new Promise(r => setTimeout(r, 20))
const label = document.querySelector('[data-stock-label]')
expect(label.innerHTML).toContain('Plus que 1 place !')
expect(label.innerHTML).not.toContain('places')
})
it('shows singular for stock of exactly 1 in normal range', async () => {
globalThis.fetch = mockStock({ 1: 50 })
globalThis.setInterval = (fn) => { fn(); return 1 }
createBilletterie([{ id: 1, price: '10.00', max: 100 }], '/stock')
initCart()
await new Promise(r => setTimeout(r, 20))
const label = document.querySelector('[data-stock-label]')
expect(label.innerHTML).toContain('50 places disponibles')
})
it('handles item without stock-label element', async () => {
globalThis.fetch = mockStock({ 1: 5 })
globalThis.setInterval = (fn) => { fn(); return 1 }
// Create billetterie without data-stock-label
document.body.innerHTML = `
<div id="billetterie" data-stock-url="/stock">
<div data-cart-item data-billet-id="1" data-price="10.00" data-max="20">
<button data-cart-minus></button>
<input data-cart-qty type="number" min="0" max="20" value="0" readonly>
<button data-cart-plus></button>
<span data-cart-line-total></span>
</div>
<span id="cart-total"></span><span id="cart-count"></span>
</div>
`
initCart()
await new Promise(r => setTimeout(r, 20))
// No crash, max updated
expect(document.querySelector('[data-cart-item]').dataset.max).toBe('5')
})
})

View File

@@ -76,6 +76,11 @@ describe('sanitizeHtml', () => {
expect(sanitizeHtml(html)).toBe('<p>OK</p>')
})
it('ignores comment nodes', () => {
const html = '<p>Before</p><!-- comment --><p>After</p>'
expect(sanitizeHtml(html)).toBe('<p>Before</p><p>After</p>')
})
it('strips href from anchor but keeps text', () => {
const html = '<a href="javascript:alert(1)">Click</a>'
expect(sanitizeHtml(html)).toBe('Click')

View File

@@ -56,6 +56,73 @@ describe('initTabs', () => {
describe('ARIA attributes', () => {
beforeEach(() => setup())
it('handles tabs without parent element', () => {
document.body.innerHTML = `
<button data-tab="tab-x" style="background-color:#111827;color:white;">X</button>
<div id="tab-x" style="display:block;">Content X</div>
`
initTabs()
const btn = document.querySelector('[data-tab="tab-x"]')
expect(btn.getAttribute('role')).toBe('tab')
expect(document.body.getAttribute('role')).toBe('tablist')
})
it('generates id for buttons without id', () => {
document.body.innerHTML = `
<div>
<button data-tab="tab-noid" style="background-color:#111827;color:white;">No ID</button>
</div>
<div id="tab-noid" style="display:block;">Content</div>
`
initTabs()
const btn = document.querySelector('[data-tab="tab-noid"]')
expect(btn.id).toBe('tab-btn-tab-noid')
})
it('keeps existing button id', () => {
document.body.innerHTML = `
<div>
<button id="my-custom-id" data-tab="tab-keep" style="background-color:#111827;color:white;">Keep</button>
</div>
<div id="tab-keep" style="display:block;">Content</div>
`
initTabs()
expect(document.querySelector('[data-tab="tab-keep"]').id).toBe('my-custom-id')
})
it('handles missing panel gracefully', () => {
document.body.innerHTML = `
<div>
<button data-tab="tab-exists" style="background-color:#111827;color:white;">Exists</button>
<button data-tab="tab-missing" style="background-color:white;color:#111827;">Missing</button>
</div>
<div id="tab-exists" style="display:block;">Content</div>
`
initTabs()
const btnMissing = document.querySelector('[data-tab="tab-missing"]')
expect(btnMissing.getAttribute('role')).toBe('tab')
expect(btnMissing.getAttribute('aria-selected')).toBe('false')
})
it('click on tab with missing panel does not crash', () => {
document.body.innerHTML = `
<div>
<button data-tab="tab-ok" style="background-color:#111827;color:white;">OK</button>
<button data-tab="tab-nopanel" style="background-color:white;color:#111827;">No Panel</button>
</div>
<div id="tab-ok" style="display:block;">Content</div>
`
initTabs()
const btn = document.querySelector('[data-tab="tab-nopanel"]')
expect(() => btn.click()).not.toThrow()
expect(btn.getAttribute('aria-selected')).toBe('true')
})
it('sets role=tablist on parent', () => {
expect(document.getElementById('tablist').getAttribute('role')).toBe('tablist')
})