From f836a40d87821cb38a2742078b929e6e21da9113 Mon Sep 17 00:00:00 2001 From: Matheus Nespolo Date: Tue, 6 Oct 2026 12:42:59 -0300 Subject: [PATCH] Add Playwright dashboard E2E coverage This change adds a dedicated frontend smoke suite for dashboard behavior in normal, broker-down, and low-stock scenarios. It covers rejected commands, empty-stock recovery, WebSocket reconnection, API status contracts, and the simulator's ESTOQUE_INICIAL override for isolated tests. The docs and contributor guide were updated to describe the new workflow and CI execution. --- CONTRIBUTING.md | 9 +++ docs/CHANGELOG.md | 4 + docs/CI-CD.md | 15 ++++ simulator/server.js | 7 +- test/frontend_smoke/api-status.spec.mjs | 59 +++++++++++++++ .../comandos-recusados.spec.mjs | 75 +++++++++++++++++++ test/frontend_smoke/helpers.mjs | 20 +++++ test/frontend_smoke/package.json | 2 + test/frontend_smoke/playwright.config.mjs | 31 +++++--- test/frontend_smoke/resiliencia.spec.mjs | 43 +++++++++++ test/frontend_smoke/sem-estoque.spec.mjs | 35 +++++++++ 11 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 test/frontend_smoke/api-status.spec.mjs create mode 100644 test/frontend_smoke/comandos-recusados.spec.mjs create mode 100644 test/frontend_smoke/sem-estoque.spec.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84288e8..05d815c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,15 @@ bash scripts/precommit-checks.sh .\scripts\precommit-checks.ps1 ``` +**Testes E2E do dashboard (Playwright):** +```powershell +cd test/frontend_smoke +npm ci +npx playwright install chromium +npm run test:e2e +``` +Os testes iniciam seus próprios servidores nas portas 3100–3102; não os inicie manualmente. A suíte Playwright está serializada porque alguns cenários alteram o estoque simulado. Consulte [`docs/CI-CD.md`](docs/CI-CD.md) para projetos, cobertura e execução em CI. + **Servidor Node:** ```bash cd server diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 37fcc9c..46f5025 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ Convenção de seções: `Adicionado`, `Alterado`, `Corrigido`, `Segurança`, `R ## [Não publicado] +### Adicionado + +- **Cobertura E2E do dashboard (card #27):** Playwright agora valida feedback de comandos ocupados/inválidos/limitados, estoque esgotado e recuperação por reset, reconexão WebSocket sincronizando estado, e `/api/status` nos modos servidor sem broker e simulador. O simulador aceita `ESTOQUE_INICIAL` (padrão 15) para testes isolados; execução documentada em `docs/CI-CD.md` e `CONTRIBUTING.md`. + ### Alterado - **Bancada — esteiras B e C funcionando e roteiro da Semana 8 (03/10/2026)** diff --git a/docs/CI-CD.md b/docs/CI-CD.md index 8e54bd2..d2ea1f9 100644 --- a/docs/CI-CD.md +++ b/docs/CI-CD.md @@ -106,6 +106,21 @@ npm update #### 5. Lógica do firmware do Uno (`firmware-logica`) - **O quê:** compila `arduino/data_flow_inventory/src/logica/` com g++ e roda `test/firmware_uno` (FSM, marcos, filtro, protocolo, telas e causa do reset, com tempo simulado) + +### 5. Testes E2E do dashboard (Playwright) + +Os testes vivem em `test/frontend_smoke/` e usam Playwright com Chromium. Na raiz do repositório, após instalar dependências em `server/`, `simulator/` e `test/frontend_smoke/` (`npm ci` em cada pasta): + +```powershell +cd test/frontend_smoke +npx playwright install chromium +npm run test:e2e +``` + +`npm run test:e2e:ci` usa os repórteres de lista e HTML. Os web servers Playwright iniciam automaticamente: simulador normal em `localhost:3100`, servidor real com broker propositalmente inalcançável em `localhost:3101`, e simulador isolado com `ESTOQUE_INICIAL=1` em `localhost:3102`. Os projetos são serializados (`workers: 1`) porque o simulador normal mantém estoque compartilhado durante a execução. Em CI há uma retentativa (`retries: 1`) e a execução para após três falhas (`maxFailures: 3`); localmente não há retentativas. Não há `globalTimeout` do Playwright nem `timeout-minutes` no job E2E; cada processo servidor possui timeout de inicialização de 30 s e cada teste limite de 90 s. + +A cobertura inclui comandos recusados (sistema ocupado, peça inválida e rate limit), exaustão de estoque e reset, reconexão WebSocket com estado sincronizado, e `/api/status` (503 do servidor sem broker e 404 no simulador). O contrato HTTP 200 com broker conectado é validado pelo harness de integração com broker em processo em `test/server_metrics/integracao/harness.test.mjs`. + - **Contrato:** os testes `*_igual_v2` garantem que as mensagens JSON continuam idênticas às da v2.x - **Local:** `docker run --rm -v ":/w" -w /w gcc:14 make -C test/firmware_uno test` (ou `make -C test/firmware_uno test` com g++ instalado) diff --git a/simulator/server.js b/simulator/server.js index 0347f14..db3d140 100644 --- a/simulator/server.js +++ b/simulator/server.js @@ -30,7 +30,12 @@ const PORT = parseInt(process.env.PORT, 10) || 3000; const DELAY_VERIFICACAO = 300; // ms — delay simulado da verificação const DELAY_ACIONAMENTO = 200; // ms — delay simulado do acionamento const DELAY_ENTREGA = 1500; // ms — delay simulado da entrega -const ESTOQUE_INICIAL = 15; // peças de cada tipo (igual ao firmware) +// Peças de cada tipo (igual ao firmware: 15). ESTOQUE_INICIAL permite subir o +// simulador com menos peças (ex.: os testes E2E de "sem_estoque" usam 1). +const ESTOQUE_INICIAL = Number.isInteger(parseInt(process.env.ESTOQUE_INICIAL, 10)) + && parseInt(process.env.ESTOQUE_INICIAL, 10) >= 0 + ? parseInt(process.env.ESTOQUE_INICIAL, 10) + : 15; // Intervalo mínimo (ms) entre comandos de um mesmo cliente (anti-flood). // Mesmo comportamento do servidor real (../server/server.js). diff --git a/test/frontend_smoke/api-status.spec.mjs b/test/frontend_smoke/api-status.spec.mjs new file mode 100644 index 0000000..9e59ed7 --- /dev/null +++ b/test/frontend_smoke/api-status.spec.mjs @@ -0,0 +1,59 @@ +// E2E5 (card #27): contrato de GET /api/status. +// - servidor real sem broker (porta 3101): 503 com o schema completo; +// - simulador (porta 3100): a rota não existe (404) e o dashboard tolera isso. +// O caso 200 (broker conectado) exige um broker MQTT; é coberto por +// test/server_metrics/integracao/harness.test.mjs (aedes em processo, mqtt=true → 200). +import { test, expect } from '@playwright/test'; +import { coletarErros, esperarSemErros } from './helpers.mjs'; + +test.describe('servidor real, broker inalcançável', () => { + test('E2E5 — GET /api/status responde 503 com o schema esperado', async ({ request }) => { + const r = await request.get('/api/status'); + expect(r.status()).toBe(503); + expect(r.headers()['content-type']).toMatch(/application\/json/); + const c = await r.json(); + expect(c).toMatchObject({ + server: 'online', + mqtt: false, + brokerUrl: 'mqtt://127.0.0.1', + clientesWs: expect.any(Number), + gateway: 'desconhecido', + uptime: expect.any(Number), + timestamp: expect.any(String), + metricas: { + iniciadoEm: expect.any(String), + mensagensPorTopico: expect.any(Object), + comandosPublicados: expect.any(Number), + comandosRejeitados: expect.any(Number), + }, + }); + expect(c.metricas).toHaveProperty('ultimaMensagemEm'); + expect(Number.isNaN(Date.parse(c.timestamp))).toBe(false); + expect(Number.isNaN(Date.parse(c.metricas.iniciadoEm))).toBe(false); + }); + + test('E2E5 — com o broker fora, comando do dashboard é recusado com "Broker MQTT offline"', async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => { + window.__errosComando = []; + window.dfiSocket.on('comando_erro', (e) => window.__errosComando.push(e.erro)); + window.dfiSocket.emit('solicitar_peca', { peca: 'A' }); + }); + await expect.poll(() => page.evaluate(() => window.__errosComando)).toEqual(['Broker MQTT offline']); + await expect(page.locator('#historico-lista .historico-msg', { hasText: 'Erro: Broker MQTT offline' })).toHaveCount(1); + const c = await (await page.request.get('/api/status')).json(); + expect(c.metricas.comandosRejeitados).toBeGreaterThanOrEqual(1); + }); +}); + +test.describe('simulador', () => { + test('E2E5 — o simulador não expõe /api/status (404) e o dashboard segue sem erros', async ({ page, request }) => { + const resposta = await request.get('/api/status'); + expect(resposta.status()).toBe(404); + const erros = coletarErros(page); + await page.goto('/#/arquitetura'); + await expect(page.locator('#arq-modo')).toContainText('Simulador'); + await expect(page.locator('#arq-palco')).toHaveAttribute('data-pronto', /3d|fallback/); + esperarSemErros(erros); + }); +}); diff --git a/test/frontend_smoke/comandos-recusados.spec.mjs b/test/frontend_smoke/comandos-recusados.spec.mjs new file mode 100644 index 0000000..cadc6ce --- /dev/null +++ b/test/frontend_smoke/comandos-recusados.spec.mjs @@ -0,0 +1,75 @@ +// E2E3 e E2E6 (card #27): comandos recusados mostram feedback no histórico do dashboard. +// Backend: simulador (porta 3100, estoque 15). O caso sem_estoque está em sem-estoque.spec.mjs +// (simulador dedicado com 1 peça por tipo), para não esgotar o estoque dos outros specs. +import { test, expect } from '@playwright/test'; +import { coletarErros, esperarSemErros, historicoContendo, gravarErrosComando, lerErrosComando } from './helpers.mjs'; + +test('E2E3 — pedido com o sistema ocupado é recusado e o histórico mostra o motivo', async ({ browser }) => { + // Rate limit é por socket: um segundo dashboard (outra aba) não é barrado por ele, + // então a recusa vem do estado do sistema, não da frequência. + const contexto = await browser.newContext(); + const principal = await contexto.newPage(); + const segunda = await contexto.newPage(); + const erros = coletarErros(principal); + try { + await principal.goto('/'); + await segunda.goto('/'); + await expect(principal.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + await expect(segunda.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + + await principal.locator('#btn-solicitar-a').click(); + // Aguarda o evento de pedido aceite confirmar que o simulador saiu do estado inicial. + await expect(historicoContendo(segunda, 'Peça A solicitada')).toHaveCount(1); + await expect(segunda.locator('#estado-atual')).not.toHaveText('AGUARDANDO_PEDIDO'); + + // Os botões ficam desabilitados fora de AGUARDANDO_PEDIDO; o pedido vai direto pelo socket. + await segunda.evaluate(() => window.dfiSocket.emit('solicitar_peca', { peca: 'B' })); + await expect(historicoContendo(segunda, 'Erro: Sistema ocupado')).toHaveCount(1); + + // O pedido original não é afetado: A é entregue e o sistema volta a aguardar. + await expect(principal.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + await expect(historicoContendo(principal, 'Peça A entregue')).toHaveCount(1); + await expect(principal.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + await expect(historicoContendo(principal, 'Sistema ocupado')).toHaveCount(0); + esperarSemErros(erros); + } finally { + await contexto.close(); + } +}); + +test('E2E3 — peça inválida é recusada com feedback e o sistema não muda de estado', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + await gravarErrosComando(page); + + await page.evaluate(() => window.dfiSocket.emit('solicitar_peca', { peca: 'Z' })); + await expect(historicoContendo(page, 'Erro: Peça inválida: Z')).toHaveCount(1); + expect(await lerErrosComando(page)).toEqual(['Peça inválida: Z']); + await expect(page.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); +}); + +test('E2E6 — rate limit: segundo comando em < COMANDO_INTERVALO_MS é recusado; depois do intervalo, aceito', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + await gravarErrosComando(page); + + // Dois comandos no mesmo instante (mesmo evento do navegador): o 2º cai dentro do intervalo + // de 500 ms do simulador, antes mesmo de qualquer verificação de estado. + await page.evaluate(() => { + window.dfiSocket.emit('solicitar_peca', { peca: 'A' }); + window.dfiSocket.emit('solicitar_peca', { peca: 'B' }); + }); + await expect(historicoContendo(page, 'Muitos comandos — aguarde 500ms entre envios')).toHaveCount(1); + expect(await lerErrosComando(page)).toEqual(['Muitos comandos — aguarde 500ms entre envios']); + + // O 1º pedido segue normalmente (A entregue); B nunca foi aceito. + await expect(page.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + await expect(historicoContendo(page, 'Peça A entregue').count()).resolves.toBeGreaterThanOrEqual(1); + expect(await lerErrosComando(page)).toEqual(['Muitos comandos — aguarde 500ms entre envios']); + + // Passado o intervalo, o comando volta a ser aceito (o botão usa o mesmo caminho). + const antes = await historicoContendo(page, 'Peça B entregue').count(); + await page.locator('#btn-solicitar-b').click(); + await expect(historicoContendo(page, 'Peça B entregue')).toHaveCount(antes + 1); + expect(await lerErrosComando(page)).toHaveLength(1); +}); diff --git a/test/frontend_smoke/helpers.mjs b/test/frontend_smoke/helpers.mjs index 788c77a..7fe9edf 100644 --- a/test/frontend_smoke/helpers.mjs +++ b/test/frontend_smoke/helpers.mjs @@ -33,6 +33,26 @@ function linhas(page, texto) { return page.locator('#historico-lista .historico-msg', { hasText: new RegExp(`^${texto}$`) }); } +/** Linhas do histórico que CONTÊM o trecho (texto literal, sem regex). */ +export function historicoContendo(page, trecho) { + return page.locator('#historico-lista .historico-msg', { hasText: trecho }); +} + +/** + * Registra, dentro da página, os `comando_erro` recebidos pelo socket do dashboard + * (window.__errosComando). Use lerErrosComando para ler. + */ +export async function gravarErrosComando(page) { + await page.evaluate(() => { + window.__errosComando = []; + window.dfiSocket.on('comando_erro', (e) => window.__errosComando.push(e.erro)); + }); +} + +export async function lerErrosComando(page) { + return page.evaluate(() => window.__errosComando); +} + /** * Roteiro dos botões de controle (cenário 1, spec §7.2). * Conta as linhas antes de clicar, porque o simulador reenvia os últimos diff --git a/test/frontend_smoke/package.json b/test/frontend_smoke/package.json index 4c265f3..853d2d0 100644 --- a/test/frontend_smoke/package.json +++ b/test/frontend_smoke/package.json @@ -6,6 +6,8 @@ "type": "module", "scripts": { "test": "playwright test", + "test:e2e": "playwright test", + "test:e2e:ci": "playwright test --reporter=list,html", "test:simulador": "playwright test --project=simulador" }, "devDependencies": { diff --git a/test/frontend_smoke/playwright.config.mjs b/test/frontend_smoke/playwright.config.mjs index 9499401..d5733c9 100644 --- a/test/frontend_smoke/playwright.config.mjs +++ b/test/frontend_smoke/playwright.config.mjs @@ -2,23 +2,25 @@ // DATA FLOW INVENTORY — Configuração dos testes E2E (Playwright) // ------------------------------------------------------------ // Projeto "simulador": sobe ../../simulator na porta 3100 (sem helmet). -// Projeto "servidor-sem-broker": sobe ../../server na porta 3101 com um -// broker inalcançável, para exercitar a CSP real do helmet e o 503 de -// /api/status. O dotenv não sobrescreve variáveis já definidas aqui. +// Projeto "servidor-sem-broker": sobe ../../server na porta 3101 com broker +// inalcançável; projetos api-status separam os contratos 404 (simulador) e +// 503 (servidor) sem reaproveitar o mesmo arquivo contra a URL errada. +// Projeto "simulador-sem-estoque": outro simulador (porta 3102) com +// ESTOQUE_INICIAL=1, só para o cenário sem_estoque. // ============================================================ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: '.', testMatch: /.*\.spec\.mjs/, - // O simulador é um processo único com estado (estoque = 5 por peça): - // testes em série e sem retentativas, para não esgotar o estoque. + // O simulador é stateful (estoque inicial = 15 por peça); manter testes em série. fullyParallel: false, workers: 1, - retries: 0, - // Carga da página (~9 s) + roteiro dos botões (~30 s) + navegação: margem para CI. + retries: process.env.CI ? 1 : 0, + maxFailures: 3, + // Carga da página (~9 s) + roteiros com entrega simulada: margem para CI sem globalTimeout. timeout: 90_000, - // Máquina de bancada sob carga: asserções com folga (carga ~9 s). + // Máquina de bancada/CI sob carga: asserções com folga. expect: { timeout: 20_000 }, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', use: { @@ -29,8 +31,12 @@ export default defineConfig({ launchOptions: { args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }, }, projects: [ - { name: 'simulador', testIgnore: /csp\.spec\.mjs/, use: { baseURL: 'http://localhost:3100' } }, + { name: 'simulador', testIgnore: /(csp|sem-estoque|api-status)\.spec\.mjs/, use: { baseURL: 'http://localhost:3100' } }, { name: 'servidor-sem-broker', testMatch: /csp\.spec\.mjs/, use: { baseURL: 'http://localhost:3101' } }, + { name: 'api-status-simulador', testMatch: /api-status\.spec\.mjs/, grep: /simulador não expõe/, use: { baseURL: 'http://localhost:3100' } }, + { name: 'api-status-servidor', testMatch: /api-status\.spec\.mjs/, grepInvert: /simulador não expõe/, use: { baseURL: 'http://localhost:3101' } }, + // Simulador dedicado com 1 peça por tipo: o caso sem_estoque não gasta o estoque do principal. + { name: 'simulador-sem-estoque', testMatch: /sem-estoque\.spec\.mjs/, use: { baseURL: 'http://localhost:3102' } }, ], webServer: [ { @@ -40,6 +46,13 @@ export default defineConfig({ reuseExistingServer: false, timeout: 30_000, }, + { + command: 'node ../../simulator/server.js', + url: 'http://localhost:3102', + env: { PORT: '3102', ESTOQUE_INICIAL: '1' }, + reuseExistingServer: false, + timeout: 30_000, + }, { command: 'node ../../server/server.js', url: 'http://localhost:3101', diff --git a/test/frontend_smoke/resiliencia.spec.mjs b/test/frontend_smoke/resiliencia.spec.mjs index 1d949ff..3ebfb5f 100644 --- a/test/frontend_smoke/resiliencia.spec.mjs +++ b/test/frontend_smoke/resiliencia.spec.mjs @@ -39,6 +39,49 @@ test('cenário 4c — three.module.min.js bloqueado: mímico SVG, arquitetura 2D esperarSemErros(erros); }); +test('E2E4 — queda do WebSocket: "Sem enlace", reconexão e estado restaurado (estoque atualizado enquanto offline)', async ({ browser }) => { + const contexto = await browser.newContext(); + const painel = await contexto.newPage(); + const outra = await contexto.newPage(); + try { + await painel.goto('/'); + await outra.goto('/'); + await expect(painel.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + await expect(outra.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + await expect(painel.locator('#mqtt-status')).toContainText('Conectado'); + const antes = parseInt(await painel.locator('#estoque-a').innerText(), 10); + expect(antes).toBeGreaterThan(0); + + // Queda do transporte (equivale a perder a rede), sem reconexão automática: + // assim o servidor muda de estado enquanto este painel está comprovadamente fora. + await painel.evaluate(() => { + window.dfiSocket.io.reconnection(false); + window.dfiSocket.io.engine.close(); + }); + await expect(painel.locator('#mqtt-status')).toContainText('Sem enlace'); + expect(await painel.evaluate(() => window.dfiSocket.connected)).toBe(false); + + await outra.locator('#btn-solicitar-a').click(); + await expect(outra.locator('#estoque-a')).toHaveText(String(antes - 1)); + await expect(outra.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + // O painel offline não recebeu a atualização. + await expect(painel.locator('#estoque-a')).toHaveText(String(antes)); + + // Volta a rede: o estado_inicial devolve o estoque atual e o histórico registra a reconexão. + await painel.evaluate(() => { + window.dfiSocket.io.reconnection(true); + window.dfiSocket.connect(); + }); + await expect(painel.locator('#mqtt-status')).toContainText('Conectado'); + await expect(painel.locator('#estoque-a')).toHaveText(String(antes - 1)); + await expect(painel.locator('#estado-atual')).toHaveText('AGUARDANDO_PEDIDO'); + await expect(painel.locator('#historico-lista .historico-msg', { hasText: 'Reconectado ao servidor' })).toHaveCount(1); + await expect(painel.locator('#btn-solicitar-a')).toBeEnabled(); + } finally { + await contexto.close(); + } +}); + test('cenário 4d — CDN do Socket.IO inacessível: botões funcionam (cliente servido localmente)', async ({ page }) => { await page.route('https://cdn.socket.io/**', (r) => r.abort()); const erros = coletarErros(page, /cdn\.socket\.io/); diff --git a/test/frontend_smoke/sem-estoque.spec.mjs b/test/frontend_smoke/sem-estoque.spec.mjs new file mode 100644 index 0000000..d9dd998 --- /dev/null +++ b/test/frontend_smoke/sem-estoque.spec.mjs @@ -0,0 +1,35 @@ +// E2E3 (card #27), caso sem_estoque: roda contra um simulador DEDICADO (porta 3102) que sobe +// com 1 peça de cada tipo (ESTOQUE_INICIAL=1). Assim o estoque zerado não afeta os outros specs. +import { test, expect } from '@playwright/test'; +import { historicoContendo } from './helpers.mjs'; + +test('E2E3 — peça sem estoque: ERRO sem_estoque aparece no histórico, nos botões e some após o reset', async ({ page }) => { + await page.goto('/'); + const estado = page.locator('#estado-atual'); + await expect(estado).toHaveText('AGUARDANDO_PEDIDO'); + await expect(page.locator('#estoque-a')).toHaveText('1'); + + // 1ª entrega esgota a peça A. O histórico pode trazer a mesma entrega no snapshot inicial; + // guardamos a contagem para afirmar especificamente que esta solicitação produziu uma linha nova. + const entregasAntes = await historicoContendo(page, 'Peça A entregue').count(); + await page.locator('#btn-solicitar-a').click(); + await expect(historicoContendo(page, 'Peça A entregue')).toHaveCount(entregasAntes + 1); + await expect(page.locator('#estoque-a')).toHaveText('0'); + await expect(page.locator('#estoque-badge-a')).toContainText('Sem estoque'); + await expect(estado).toHaveText('AGUARDANDO_PEDIDO'); + + // 2ª solicitação de A: o simulador entra em ERRO (sem_estoque) e os botões travam. + await page.locator('#btn-solicitar-a').click(); + await expect(estado).toHaveText('ERRO'); + await expect(historicoContendo(page, 'Erro: sem_estoque')).toHaveCount(1); + await expect(page.locator('#btn-solicitar-a')).toBeDisabled(); + await expect(page.locator('#btn-solicitar-b')).toBeDisabled(); + await expect(page.locator('#ajuda-controle')).toBeVisible(); + + // O reset recupera o sistema; as outras peças (B, C) seguem disponíveis. + await page.locator('#btn-reset').click(); + await expect(estado).toHaveText('AGUARDANDO_PEDIDO'); + await expect(page.locator('#estoque-b')).toHaveText('1'); + await page.locator('#btn-solicitar-b').click(); + await expect(historicoContendo(page, 'Peça B entregue')).toHaveCount(1); +});