"""Roda o JavaScript da estação de fotos num DOM falso (sem canvas, IndexedDB, câmera nem Image) e exercita telas e ações."""
import json
import re
import sys

import quickjs

html = open(sys.argv[1], encoding='utf-8').read()
script = re.findall(r'<script>(.*?)</script>', html, re.S)[0]
fim = script.rfind('})();')
exporta = ('\nglobalThis.__t = { get estado() { return estado; }, ACOES, render, rota, REGISTROS, PONTOS, PACIENTES, PAC, SESSOES_FOTO, '
           'INTERVALOS, ACUMULADO, HOJE, avaliarLuz, avaliarFoco, alinhamento, semPromessa, PALAVRAS_PROIBIDAS, feitosDe, pulouTrico, fotosDe, fotoDe, '
           'sessoesComFoto, dataSessao, urlVoltaAna, URL_VOLTA_SUELLEN, URL_VOLTA_FER, textoMensagemFer, MENSAGEM_FER, copiarTexto, '
           'dadosExportacao, etiqueta, compararDe, capturaDe, filtrarPacientes, definirModo, get modo() { return modo; }, Armazem, ITENS, '
           'exportacaoDe, envioDe, pedidoDe, autorizacaoDe, podeCanvas, temaEfetivo, densidadeDe, registroAnterior, diasEntre, '
           'prepararDownloads, salvarPeloVisualizador, compartilhar, dataURLParaBlob, get DOWNLOADS() { return DOWNLOADS; }, '
           'get DOWNLOADS_ESTADO() { return DOWNLOADS_ESTADO; }, EM_IFRAME, politicaDaCamera, ligarCamera, desligarCamera, Camera, '
           'MSG_CAMERA_VISUALIZADOR, MSG_CAMERA_NEGADA, BLOBS, listaSessoes, ddmmaaaa, montagemDe };\n')
script = script[:fim] + exporta + script[fim:]

SHIM = r'''
var __DataReal = globalThis.Date;
var __AGORA = new __DataReal(2026, 9, 6, 9, 20, 0).getTime();
globalThis.Date = function Date(a, b, c, d, e, f, g) {
  if (!(this instanceof Date)) return new __DataReal(__AGORA).toString();
  if (arguments.length === 0) return new __DataReal(__AGORA);
  if (arguments.length === 1) return new __DataReal(a);
  return new __DataReal(a, b, c === undefined ? 1 : c, d || 0, e || 0, f || 0, g || 0);
};
globalThis.Date.now = function () { return __AGORA; };
globalThis.Date.UTC = __DataReal.UTC;
globalThis.Date.parse = __DataReal.parse;
globalThis.Date.prototype = __DataReal.prototype;
var __handlers = {};
var __timers = [];
function setTimeout(fn) { __timers.push(fn); return __timers.length; }
function clearTimeout() {}
function __flush() { var n = 0; while (__timers.length && n < 200) { __timers.shift()(); n++; } }
function ListaClasses() { this.s = new Set(); }
ListaClasses.prototype.add = function (c) { this.s.add(c); };
ListaClasses.prototype.remove = function (c) { this.s.delete(c); };
ListaClasses.prototype.toggle = function (c, f) { if (f === undefined) f = !this.s.has(c); if (f) this.s.add(c); else this.s.delete(c); return f; };
ListaClasses.prototype.contains = function (c) { return this.s.has(c); };
function El(id) { this.id = id; this.classList = new ListaClasses(); this.innerHTML = ''; this.textContent = ''; this.hidden = false; this.dataset = {}; this.style = {}; this.attrs = {}; this.scrollTop = 0; this.scrollHeight = 0; this.value = ''; }
Object.defineProperty(El.prototype, 'className', { get: function () { return Array.from(this.classList.s).join(' '); }, set: function (v) { this.classList.s = new Set(String(v).split(/\s+/).filter(Boolean)); } });
Object.defineProperty(El.prototype, 'lastElementChild', { get: function () { return new El('ultimo'); } });
El.prototype.setAttribute = function (k, v) { this.attrs[k] = String(v); };
El.prototype.getAttribute = function (k) { return k in this.attrs ? this.attrs[k] : null; };
El.prototype.removeAttribute = function (k) { delete this.attrs[k]; };
El.prototype.hasAttribute = function (k) { return k in this.attrs; };
El.prototype.querySelector = function () { return null; };
El.prototype.querySelectorAll = function () { return []; };
El.prototype.closest = function () { return null; };
El.prototype.focus = function () {};
El.prototype.scrollIntoView = function () {};
El.prototype.insertAdjacentHTML = function (pos, h) { this.innerHTML += h; };
El.prototype.getBoundingClientRect = function () { return { top: 0, left: 0, width: 0, height: 0 }; };
El.prototype.appendChild = function (c) { return c; };
El.prototype.remove = function () {};
El.prototype.setSelectionRange = function () {};
var __els = {};
function getEl(id) { if (!__els[id]) __els[id] = new El(id); return __els[id]; }
var CONHECIDOS = ['tela', 'horaTopo', 'horaAparelho', 'horaMenu', 'painel', 'veu', 'toast', 'botaoTema', 'telaApp', 'textoPrevia'];
['painel', 'veu', 'toast'].forEach(function (id) { getEl(id).hidden = true; });
var document = {
  documentElement: new El('html'),
  body: new El('body'),
  activeElement: null,
  querySelector: function (s) {
    var m = /^#([\w-]+)$/.exec(s);
    if (!m) return null;
    var id = m[1];
    if (CONHECIDOS.indexOf(id) >= 0) return getEl(id);
    var p = __els.painel;
    if (id === 'chatDigitando') {
      var cm = __els.chatMensagens;
      if (!cm || cm.innerHTML.indexOf('id="chatDigitando"') < 0) return null;
      var d = new El('chatDigitando');
      d.remove = function () { cm.innerHTML = cm.innerHTML.replace(/<div class="msg msg--ia" id="chatDigitando">[\s\S]*?<\/div><\/div>/, ''); };
      return d;
    }
    if (!p.hidden && p.innerHTML.indexOf('id="' + id + '"') >= 0) return getEl(id);
    if (__els.tela && __els.tela.innerHTML.indexOf('id="' + id + '"') >= 0) return getEl(id);
    return null;
  },
  querySelectorAll: function () { return []; },
  addEventListener: function (t, fn) { (__handlers[t] = __handlers[t] || []).push(fn); },
  contains: function () { return true; },
  createElement: function () { return new El('novo'); },
};
var window = globalThis;
window.innerWidth = 1440;
window.innerHeight = 900;
window.matchMedia = function (q) { return { matches: /min-width: (1024|760)px|pointer: fine/.test(q) }; };
window.scrollTo = function () {};
window.addEventListener = function (t, fn) { (__handlers[t] = __handlers[t] || []).push(fn); };
var location = { _h: '', get hash() { return this._h; }, set hash(v) { v = String(v); this._h = v ? (v[0] === '#' ? v : '#' + v) : ''; (__handlers.hashchange || []).forEach(function (fn) { fn(); }); } };
var localStorage = { getItem: function () { return null; }, setItem: function () {} };
var navigator = { clipboard: { writeText: function () { return Promise.resolve(); } } };
function Blob(partes, opcoes) { this.partes = partes || []; this.type = (opcoes && opcoes.type) || ''; this.size = this.partes.reduce(function (n, p) { return n + (p.byteLength || p.length || 0); }, 0); }
function atob(s) { var T = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; var out = ''; var i = 0; function v(ch) { return ch ? T.indexOf(ch) : -1; } s = String(s).replace(/[^A-Za-z0-9+\/]/g, ''); while (i < s.length) { var a = v(s.charAt(i++)); var b = v(s.charAt(i++)); var c = v(s.charAt(i++)); var d = v(s.charAt(i++)); var n = (a << 18) | (b << 12) | ((c < 0 ? 0 : c) << 6) | (d < 0 ? 0 : d); out += String.fromCharCode((n >> 16) & 255); if (c >= 0) out += String.fromCharCode((n >> 8) & 255); if (d >= 0) out += String.fromCharCode(n & 255); } return out; }
'''

TESTES = r'''
var __out = [];
var T = globalThis.__t;
function run(nome, fn) { try { fn(); __flush(); __out.push('ok    ' + nome); } catch (err) { __out.push('FALHA ' + nome + ': ' + err + ' | ' + String((err && err.stack) || '').split('\n').slice(0, 3).join(' / ')); } }
function tem(trecho, onde) { var h = onde === undefined ? __els.tela.innerHTML : onde; if (h.indexOf(trecho) < 0) throw new Error('não achei: ' + trecho); }
function naoTem(trecho, onde) { var h = onde === undefined ? __els.tela.innerHTML : onde; if (h.indexOf(trecho) >= 0) throw new Error('não devia aparecer: ' + trecho); }
function painel() { return __els.painel.innerHTML; }
function aviso() { return __els.toast.innerHTML; }
function igual(a, b, msg) { if (a !== b) throw new Error((msg || 'valor') + ': esperava ' + b + ', veio ' + a); }
function entre(v, a, b, msg) { if (v < a || v > b) throw new Error((msg || 'valor') + ': ' + v + ' fora de ' + a + '..' + b); }
function campo(v) { var e = new El('campo'); e.value = v; return e; }
function botao(acao, dados) { var e = new El('botao'); e.dataset = Object.assign({ acao: acao }, dados || {}); return e; }
function ir(h) { location.hash = h; }
function tecla(k, dentroDeCampo) { (__handlers.keydown || []).forEach(function (fn) { fn({ key: k, target: { closest: function () { return dentroDeCampo ? new El('campo') : null; } }, preventDefault: function () {} }); }); }
function preparo() { [0, 1, 2].forEach(function (i) { T.ACOES['prep-toque'](botao('prep-toque', { i: String(i) })); }); }
function capturaEAceita() { T.ACOES.capturar(); T.ACOES.aceitar(); }
function rgba(v, n) { var a = []; for (var i = 0; i < n; i += 1) { a.push(v, v, v, 255); } return a; }
function contagem(html, trecho) { return html.split(trecho).length - 1; }
var PROIBIDAS = ['garant', 'promet', 'resultado', 'cresce', 'cura'];
function semProibidas(texto, onde) { var t = texto.toLowerCase(); PROIBIDAS.forEach(function (p) { if (t.indexOf(p) >= 0) throw new Error((onde || 'texto') + ' tem a palavra proibida "' + p + '": ' + texto); }); }
var NOMES = T.PACIENTES.map(function (p) { return p.nome; });
function semNome(texto, onde) { NOMES.forEach(function (n) { n.split(' ').forEach(function (parte) { if (texto.indexOf(parte) >= 0) throw new Error((onde || 'texto') + ' leva nome de paciente: ' + parte); }); }); }

// A estação de fotos (entrega 2, CLAUDE.md seção 9): 6 registros sempre na mesma ordem, a tricoscopia em 4 pontos fixos.
run('a ordem fixa dos 6 registros e dos 4 pontos da tricoscopia', function () {
  igual(T.REGISTROS.map(function (r) { return r.id; }).join(','), 'topo,coroa,frontal,lat-dir,lat-esq,trico', 'registros');
  igual(T.REGISTROS.map(function (r) { return r.nome; }).join(' · '), 'Topo · Coroa · Linha frontal · Lateral direita · Lateral esquerda · Tricoscopia em 4 pontos', 'nomes');
  igual(T.PONTOS.map(function (p) { return p.id; }).join(','), 'frontal,medio,vertex,occipital', 'pontos');
  igual(T.PONTOS[3].controle, true, 'o occipital é o controle');
  igual(T.SESSOES_FOTO.join(','), '1,3,6', 'sessões com foto (fictício)');
  igual(T.INTERVALOS.join(','), '20,30,40,45,45', 'padrão do ciclo');
  igual(T.ACUMULADO.join(','), '0,20,50,90,135,180', 'dias desde a 1ª sessão');
});
run('abre no claro, pronta, com o armazenamento em memória e o histórico fictício gerado na primeira abertura', function () {
  if (!document.documentElement.classList.contains('dia')) throw new Error('não abriu no claro');
  igual(T.Armazem.modo, 'memoria', 'sem IndexedDB no DOM falso');
  igual(T.estado.pronto, true, 'pronta');
  igual(T.podeCanvas(), false, 'sem canvas no DOM falso');
  igual(T.fotosDe('caio').length, 9, 'Caio: a 1ª sessão (5 fotos + 4 pontos)');
  igual(T.fotosDe('otavio').length, 0, 'Otávio: nenhuma');
  igual(T.fotosDe('roberto').length, 14, 'Roberto: 1ª (com a tricoscopia) e 3ª (sem)');
  igual(T.fotosDe('jorge').length, 23, 'Jorge: 1ª, 3ª e 6ª, a tricoscopia só na 1ª e na 6ª');
  igual(T.fotosDe('tatiane').length, 23, 'Tatiane: 1ª, 3ª e 6ª, a tricoscopia só na 1ª e na 6ª');
  igual(T.pulouTrico('jorge', 3), true, 'na 3ª, a tricoscopia pulada'); igual(T.feitosDe('jorge', 3), 6, 'pulada conta como resolvida');
  igual(T.sessoesComFoto('roberto').join(','), '1,3', 'sessões do Roberto');
  igual(T.feitosDe('caio', 1), 6, 'Caio tem os 6 registros da 1ª sessão');
  igual(T.feitosDe('caio', 3), 0, 'e nenhum da 3ª, que é hoje');
  if (!T.autorizacaoDe(T.PAC('tatiane'))) throw new Error('Tatiane tem a autorização de publicação');
  if (T.autorizacaoDe(T.PAC('jorge'))) throw new Error('Jorge não tem autorização');
  T.fotosDe('jorge').forEach(function (f) { if (f.quem !== 'Ana Carolina' || !f.quando || !f.checks) throw new Error('foto sem quem, quando ou conferência: ' + f.id); });
  tem('Histórico fictício preparado: 69 fotos', T.estado.log[0][1]);
});
run('página inicial: os pacientes de hoje com n de 6, os botões certos e o Como funciona', function () {
  ir('');
  tem('Fotos de hoje.'); tem('Caio Ferraz'); tem('Otávio Reis'); tem('Roberto Albuquerque'); tem('Jorge Almeida'); tem('Tatiane Moura');
  tem('0 de 6 registros'); tem('href="#estacao/caio/3"'); tem('href="#estacao/otavio/1"'); tem('href="#estacao/roberto/6"');
  tem('já tem as fotos da 1ª sessão'); tem('já tem as fotos da 1ª e da 3ª sessão'); tem('primeira foto: o guia é só a silhueta');
  tem('href="#comparar/jorge"'); tem('Ver a evolução'); tem('href="#enviar/jorge"'); tem('href="#linha/jorge"');
  tem('autorização de publicação assinada'); tem('href="#montagem/tatiane"'); naoTem('href="#montagem/jorge"');
  tem('3 pacientes'); tem('0 de 18 registros feitos'); tem('2 reavaliações');
  tem('Como funciona'); tem('Posição fixa'); tem('Luz controlada'); tem('Fundo neutro'); tem('Mesmo preparo'); tem('A estação só registra.'); tem('Quem avalia é a Dra. Suellen');
  tem('Tricoscopia em 4 pontos'); tem('ninguém digita nome');
});
run('busca por nome, sem acento, para os outros pacientes', function () {
  igual(T.filtrarPacientes('ric').map(function (p) { return p.id; }).join(','), 'ricardo', 'começo do nome');
  igual(T.filtrarPacientes('SATO').map(function (p) { return p.id; }).join(','), 'ricardo', 'sobrenome, maiúsculas');
  igual(T.filtrarPacientes('sergio').map(function (p) { return p.id; }).join(','), 'sergio', 'sem acento');
  igual(T.filtrarPacientes('xyz').length, 0, 'nada');
  igual(T.filtrarPacientes('').length, 0, 'vazio não lista todo mundo');
  T.ACOES.busca(campo('gab'));
  tem('Gabriela Siqueira', __els['busca-resultados'].innerHTML); tem('Ainda sem fotos na estação', __els['busca-resultados'].innerHTML);
  T.ACOES.busca(campo('')); igual(__els['busca-resultados'].innerHTML, '', 'limpa');
});
run('#estacao/otavio/1: o preparo com três toques; Começar só com os três; depois, a câmera simulada no registro 1', function () {
  ir('estacao/otavio/1');
  tem('Confira o preparo, com três toques.'); tem('Cabelo seco e penteado como da última vez'); tem('Luz da sala ligada e janela fechada'); tem('Paciente na marca do chão e da maca');
  tem('data-acao="comecar" disabled'); tem('Primeira foto deste paciente: o guia é só a silhueta.');
  tem('Continuar depois'); tem(T.urlVoltaAna('otavio', 0));
  T.ACOES.comecar(); igual(T.estado.captura.etapa, 'preparo', 'sem os três, não começa'); tem('Confirme os três itens', aviso());
  preparo(); naoTem('data-acao="comecar" disabled');
  T.ACOES.comecar();
  igual(T.estado.captura.etapa, 'registro', 'começou'); igual(T.estado.captura.idx, 0, 'no registro 1');
  tem('Registro 1 de 6 · Topo'); tem('0 de 6 feitos');
  igual(T.estado.camera.modo, 'simulada', 'sem getUserMedia, câmera simulada');
  tem('Câmera simulada'); tem('Este navegador não dá acesso à câmera.');
  tem('class="visor-guia"'); tem('class="visor-grade"'); tem('data-acao="capturar"'); naoTem('id="anterior"'); naoTem('id="opacidade"');
  tem('<span class="cap-tecla">Espaço</span>');
});
run('capturar e aceitar: o progresso n de 6 bate com o que foi aceito, e o roteiro avança', function () {
  T.ACOES.aceitar(); igual(T.feitosDe('otavio', 1), 0, 'sem captura, nada aceito'); tem('Capture antes de aceitar.', aviso());
  T.ACOES.capturar();
  if (!T.estado.captura.atual) throw new Error('sem foto capturada');
  igual(T.estado.captura.atual.origem, 'simulada', 'origem'); igual(T.estado.captura.atual.registro, 'topo', 'registro');
  tem('Capturada · aceite ou repita'); tem('data-acao="aceitar"'); tem('data-acao="repetir"'); tem('Sem conferência neste navegador'); tem('Primeira foto deste registro');
  T.ACOES.repetir(); if (T.estado.captura.atual) throw new Error('repetir devia descartar'); tem('data-acao="capturar"');
  capturaEAceita();
  igual(T.feitosDe('otavio', 1), 1, '1 de 6'); igual(T.fotosDe('otavio').length, 1, 'uma foto guardada');
  igual(T.estado.captura.idx, 1, 'foi para o registro 2'); tem('Registro 2 de 6 · Coroa'); tem('1 de 6 feitos'); tem(T.urlVoltaAna('otavio', 1));
  var f = T.fotoDe('otavio', 1, 'topo');
  igual(f.quem, 'Ana Carolina', 'quem'); igual(f.origem, 'simulada', 'origem'); if (!f.checks || !f.checks.luz) throw new Error('sem conferência registrada');
});
run('sair no meio ("Continuar depois") e voltar não perde o que foi feito: retoma no registro 2', function () {
  ir(''); tem('Continuar · 1 de 6'); tem('1 de 6 registros');
  ir('estacao/otavio/1'); tem('Registro 2 de 6 · Coroa'); naoTem('Confira o preparo');
  T.estado.captura = null; ir('estacao/otavio/1');
  igual(T.estado.captura.etapa, 'registro', 'sem repetir o preparo'); igual(T.estado.captura.idx, 1, 'retoma onde parou'); tem('Registro 2 de 6 · Coroa');
});
run('os 6 registros, com os 4 pontos da tricoscopia cada um com o seu ponto, levam ao resumo e ao link de volta com :6', function () {
  ['Registro 2 de 6 · Coroa', 'Registro 3 de 6 · Linha frontal', 'Registro 4 de 6 · Lateral direita', 'Registro 5 de 6 · Lateral esquerda'].forEach(function (t) { tem(t); capturaEAceita(); });
  igual(T.feitosDe('otavio', 1), 5, '5 de 6'); igual(T.estado.captura.idx, 5, 'na tricoscopia');
  tem('Registro 6 de 6 · Tricoscopia em 4 pontos'); tem('Ponto 1 de 4 · Frontal'); tem('id="arquivoTrico"'); tem('print do app do tricoscópio');
  capturaEAceita(); tem('Ponto 2 de 4 · Médio'); igual(T.fotoDe('otavio', 1, 'trico', 'frontal').ponto, 'frontal', 'o ponto fica na foto');
  igual(T.feitosDe('otavio', 1), 5, 'a tricoscopia só conta com os 4 pontos');
  capturaEAceita(); tem('Ponto 3 de 4 · Vértex');
  capturaEAceita(); tem('Ponto 4 de 4 · Occipital (controle)');
  capturaEAceita();
  igual(T.estado.captura.etapa, 'resumo', 'resumo'); igual(T.feitosDe('otavio', 1), 6, '6 de 6'); igual(T.fotosDe('otavio').length, 9, '9 fotos');
  tem('Pronto: os 6 registros desta sessão.'); tem('Pronto, voltar para a sessão'); tem(T.urlVoltaAna('otavio', 6)); tem('Fotos de hoje 6/6');
  tem('Elas são o ponto de partida do antes e agora.');
  if (!T.estado.log.some(function (l) { return l[1].indexOf('os 6 registros da 1ª sessão') >= 0; })) throw new Error('sem registro no log');
  ir(''); tem('Ver as fotos de hoje'); tem('6 de 6 registros'); tem('6 de 18 registros feitos');
});
run('refazer um registro a partir do resumo substitui a foto, sem duplicar', function () {
  ir('estacao/otavio/1'); igual(T.estado.captura.etapa, 'resumo', 'abre no resumo quando os 6 estão feitos');
  T.ACOES.refazer(botao('refazer', { reg: '1', ponto: '0' })); tem('Registro 2 de 6 · Coroa');
  var antes = T.fotoDe('otavio', 1, 'coroa').quando;
  capturaEAceita();
  igual(T.fotosDe('otavio').length, 9, 'continua com 9'); igual(T.estado.captura.etapa, 'resumo', 'volta ao resumo');
  if (T.fotoDe('otavio', 1, 'coroa').quando < antes) throw new Error('a foto nova devia substituir');
});
run('Caio, 3ª sessão: a foto anterior da 1ª aparece por cima, com transparência, e o alinhamento é só uma dica', function () {
  ir('estacao/caio/3'); tem('A foto anterior de cada registro aparece por cima da câmera');
  preparo(); T.ACOES.comecar();
  tem('Registro 1 de 6 · Topo'); tem('Foto anterior por cima (sessão 1)'); tem('id="opacidade"'); tem('Alinhe pela foto anterior');
  igual(T.registroAnterior(T.estado.captura).sessao, 1, 'a anterior é da 1ª sessão');
  T.ACOES.opacidade(campo('70')); igual(T.estado.captura.opacidade, 70, 'transparência ajustável');
  T.ACOES.capturar(); tem('Sem a conferência de alinhamento neste navegador.'); tem('<strong>Alinhamento.</strong>');
  T.ACOES.aceitar(); igual(T.feitosDe('caio', 3), 1, 'aceita'); tem(T.urlVoltaAna('caio', 1));
});
run('teclas no notebook: Espaço captura, Enter aceita, R repete; dentro de um campo, nada', function () {
  tem('Registro 2 de 6 · Coroa');
  tecla(' '); if (!T.estado.captura.atual) throw new Error('Espaço devia capturar');
  tecla('r'); if (T.estado.captura.atual) throw new Error('R devia repetir');
  tecla(' ', true); if (T.estado.captura.atual) throw new Error('com o foco num campo, Espaço não captura');
  tecla(' '); tecla('Enter'); igual(T.feitosDe('caio', 3), 2, 'Enter aceitou'); tem('Registro 3 de 6 · Linha frontal');
});
run('luz: claro demais e escuro demais são vermelho; média é verde; um pouco escura ou clara é amarelo', function () {
  var r = T.avaliarLuz(rgba(255, 64), 8, 8); igual(r.nivel, 'vermelho', 'estourada'); tem('forte demais', r.frase); tem('feche a janela', r.frase);
  r = T.avaliarLuz(rgba(5, 64), 8, 8); igual(r.nivel, 'vermelho', 'escura'); tem('insuficiente', r.frase); tem('acenda a luz', r.frase);
  r = T.avaliarLuz(rgba(128, 64), 8, 8); igual(r.nivel, 'verde', 'boa'); igual(r.media, 128, 'média'); igual(r.estourados, 0, 'estourados');
  r = T.avaliarLuz(rgba(60, 64), 8, 8); igual(r.nivel, 'amarelo', 'um pouco escura'); tem('Um pouco escura', r.frase);
  r = T.avaliarLuz(rgba(200, 64), 8, 8); igual(r.nivel, 'amarelo', 'um pouco clara'); tem('Um pouco clara', r.frase);
  r = T.avaliarLuz([128, 128, 128, 128], 2, 2); igual(r.nivel, 'verde', 'aceita array já em cinza');
  r = T.avaliarLuz([], 0, 0); igual(r.nivel, 'cinza', 'sem amostra');
});
run('foco: xadrez nítido é verde, gradiente borrado é vermelho, e a nitidez é maior no nítido', function () {
  var n = 32; var xadrez = []; var gradiente = [];
  for (var y = 0; y < n; y += 1) for (var x = 0; x < n; x += 1) { xadrez.push(((Math.floor(x / 8) + Math.floor(y / 8)) % 2) * 255); gradiente.push(x * 8); }
  var a = T.avaliarFoco(xadrez, n, n); var b = T.avaliarFoco(gradiente, n, n);
  igual(a.nivel, 'verde', 'nítido'); tem('nítido', a.frase);
  igual(b.nivel, 'vermelho', 'borrado'); tem('borrada', b.frase); tem('repita', b.frase);
  if (!(a.nitidez > b.nitidez)) throw new Error('nitidez: ' + a.nitidez + ' <= ' + b.nitidez);
  igual(T.avaliarFoco([1, 2, 3, 4], 2, 2).nivel, 'cinza', 'amostra pequena');
});
run('alinhamento: iguais dão 100%, invertidas dão 0%, e a frase é só uma dica', function () {
  var n = 16; var A = []; var inv = [];
  for (var i = 0; i < n * n; i += 1) { var v = (i * 37) % 256; A.push(v); inv.push(255 - v); }
  var r = T.alinhamento(A, A, n, n); igual(r.percentual, 100, 'iguais'); igual(r.nivel, 'verde', 'verde');
  r = T.alinhamento(A, inv, n, n); igual(r.percentual, 0, 'invertidas'); igual(r.nivel, 'vermelho', 'vermelho'); tem('só uma dica', r.frase);
  naoTem('bloque', r.frase);
  r = T.alinhamento([], [], 0, 0); igual(r.percentual, null, 'sem anterior'); igual(r.nivel, 'cinza', 'cinza');
});
run('rotas por hash: #linha, #comparar, #enviar e #montagem abrem as suas telas; paciente desconhecido volta ao início', function () {
  ir('linha/jorge');
  tem('Linha do tempo'); tem('Jorge Almeida'); tem('3 sessões fotografadas'); tem('Risca central'); tem('Coroa'); tem('Entradas'); tem('Lateral direita'); tem('Lateral esquerda'); tem('Tricoscopia'); tem('Occipital (controle)');
  igual(contagem(__els.tela.innerHTML, 'data-acao="ver-foto"'), 23, '23 fotos na linha do tempo'); tem('Pulada na sessão 3'); tem('Sessão 1'); tem('Sessão 6');
  igual(T.estado.pid, 'jorge', 'o paciente aberto');
  ir('comparar/jorge');
  tem('Antes e agora'); igual(T.estado.comparar.de, 1, 'por padrão a primeira'); igual(T.estado.comparar.ate, 6, 'e a última'); igual(T.estado.comparar.registro, 'coroa', 'a área foco do Jorge');
  tem('Antes · '); tem('· sessão 1'); tem('Agora · '); tem('· sessão 6'); tem('Para a Dra. Suellen:'); tem('mostrar e explicar na reavaliação'); tem(T.URL_VOLTA_SUELLEN);
  tem('data-lado="antes"'); tem('data-lado="agora"'); tem('Lado a lado'); tem('Cortina'); tem('Sobreposição'); tem('1080 × 1080'); tem('1080 × 1350'); tem('Exportar a imagem');
  ir('enviar/jorge');
  tem(T.MENSAGEM_FER); naoTem('wa.me'); tem(T.URL_VOLTA_FER); tem('nada é automático'); tem('A imagem vai sem o nome'); tem('data-acao="copiar-msg" data-alvo="texto-fer"'); naoTem('Copiar o texto');
  ir('montagem/tatiane');
  tem('4:5 · feed'); tem('9:16 · stories'); tem('Autorização de publicação assinada em'); tem('A legenda não é escrita aqui'); tem('Montar o post'); tem('sem nome e sem promessa');
  ir('montagem/jorge');
  tem('Sem a autorização de publicação, o post não é montado.'); tem('Pedir a autorização pela Fer'); naoTem('Montar o post');
  ir('linha/ninguem'); tem('Fotos de hoje.'); tem('Paciente não encontrado', aviso());
  ir('xyz'); tem('Fotos de hoje.');
  igual(T.rota('#estacao/caio').sessao, 3, 'sem sessão no hash, a de hoje'); igual(T.rota('#estacao/caio/9').sessao, 3, 'sessão inválida, a de hoje'); igual(T.rota('#estacao/jorge').sessao, 1, 'sem sessão hoje, a 1ª');
  igual(T.rota('#comparar/jorge').tela, 'comparar', 'rota'); igual(T.rota('').tela, 'inicio', 'inicial');
});
run('as URLs de volta: Ana com #fotos-feitas=<id>:<n>, a doutora no Hoje e a Fer no copiloto', function () {
  igual(T.urlVoltaAna('caio', 2), 'https://claude.ai/artifact/5ALQWFCULbiGE8K4wzqStq#fotos-feitas=caio:2', 'Ana');
  igual(T.URL_VOLTA_SUELLEN, 'https://claude.ai/artifact/WQrtJvbCDxEW6sN29fMDCU#hoje', 'doutora');
  igual(T.URL_VOLTA_FER, 'https://claude.ai/artifact/4KRhF2mT8bVaEGBo5Y5puJ#copiloto', 'Fer');
  ir('estacao/roberto/6'); tem(T.urlVoltaAna('roberto', 0)); tem('já tem as fotos da 1ª e da 3ª sessão', '' + __els.tela.innerHTML + (function () { ir(''); return __els.tela.innerHTML; })());
});
run('a mensagem da Fer e as etiquetas da exportação não levam nome de paciente nem palavra de promessa', function () {
  semProibidas(T.MENSAGEM_FER, 'mensagem'); semNome(T.MENSAGEM_FER, 'mensagem'); igual(T.textoMensagemFer(), T.MENSAGEM_FER, 'texto fixo');
  tem('Como combinado com a Dra. Suellen na reavaliação de hoje', T.MENSAGEM_FER);
  var d = T.dadosExportacao(T.compararDe('jorge'));
  if (!/^Antes · \d{2}\/\d{2}\/\d{4} · sessão 1$/.test(d.etiquetaAntes)) throw new Error('etiqueta Antes: ' + d.etiquetaAntes);
  if (!/^Agora · \d{2}\/\d{2}\/\d{4} · sessão 6$/.test(d.etiquetaAgora)) throw new Error('etiqueta Agora: ' + d.etiquetaAgora);
  [d.titulo, d.etiquetaAntes, d.etiquetaAgora, d.rodape, d.assinatura, d.assinatura2].forEach(function (t) { semProibidas(t, 'exportação'); semNome(t, 'exportação'); });
  igual(d.assinatura + ' · ' + d.assinatura2, 'Dra. Suellen Ferreira · Terapeuta Capilar e Tricologista', 'assinatura da clínica');
  tem('sem identificação', d.rodape);
  igual(T.semPromessa('Resultado garantido em 3 meses').join(','), 'garant,resultado', 'a lista pega');
  igual(T.semPromessa(T.MENSAGEM_FER).length, 0, 'a mensagem passa');
  if (T.linkWhats !== undefined) throw new Error('o link do WhatsApp devia ter saído');
});
run('exportar sem canvas não quebra e avisa; sem exportação, Enviei fica travado', function () {
  ir('comparar/jorge'); T.ACOES.exportar();
  tem('sem canvas, não dá para exportar', aviso()); if (T.exportacaoDe('jorge')) throw new Error('não devia exportar');
  ir('enviar/jorge'); tem('A comparação ainda não foi exportada.'); tem('data-acao="enviei" disabled');
  T.ACOES.enviei(); tem('Exporte a imagem antes.', aviso()); if (T.envioDe('jorge')) throw new Error('não devia registrar o envio');
});
run('com a imagem exportada, a Fer manda à mão e registra "Enviei"', function () {
  T.ITENS.set('exportacao:jorge', { id: 'exportacao:jorge', tipo: 'exportacao', pid: 'jorge', dataURL: 'data:,x', formato: '1080', titulo: 'Coroa', etiquetaAntes: 'Antes · 01/01/2026 · sessão 1', etiquetaAgora: 'Agora · 02/02/2026 · sessão 6', hora: '09:20', arquivo: 'antes-e-agora.png' });
  ir('enviar/jorge');
  tem('data-acao="enviei"'); naoTem('data-acao="enviei" disabled'); tem('Baixar a imagem'); tem('Copiar a imagem'); naoTem('Abrir o WhatsApp'); naoTem('target="_blank"'); tem('copia a imagem e o texto daqui e cola no WhatsApp da clínica'); tem('Depois, toque em "Enviei"');
  T.ACOES.enviei();
  if (!T.envioDe('jorge')) throw new Error('sem registro do envio'); tem('Enviado às'); naoTem('data-acao="enviei"');
  if (!T.estado.log.some(function (l) { return l[1].indexOf('mandou o antes e agora de Jorge Almeida pelo WhatsApp da clínica, sem o nome') >= 0; })) throw new Error('log sem o envio');
  ir('comparar/jorge'); tem('id="exportada"'); tem('Levar para o envio da Fer'); tem('Copiar para mandar'); tem('data-acao="compartilhar"');
});
run('antes e agora: os três modos, o registro, as datas, o zoom salvo por foto, igualar e centralizar', function () {
  ir('comparar/jorge');
  T.ACOES['cmp-modo'](botao('cmp-modo', { modo: 'cortina' })); igual(T.estado.comparar.modo, 'cortina', 'cortina'); tem('class="cortina-alca"'); tem('id="cortina"'); tem('data-quadro="cortina"');
  T.ACOES['cmp-cortina'](campo('30')); igual(T.estado.comparar.cortina, 30, 'cortina em 30%');
  T.ACOES['cmp-modo'](botao('cmp-modo', { modo: 'sobre' })); igual(T.estado.comparar.modo, 'sobre', 'sobreposição'); tem('id="transparencia"');
  T.ACOES['cmp-transparencia'](campo('80')); igual(T.estado.comparar.transparencia, 80, 'transparência');
  T.ACOES['cmp-modo'](botao('cmp-modo', { modo: 'lado' })); tem('class="quadros"'); tem('data-lado="antes"'); tem('data-lado="agora"'); naoTem('id="cortina"');
  T.ACOES['cmp-registro'](botao('cmp-registro', { reg: 'trico', ponto: 'occipital' })); igual(T.estado.comparar.registro, 'trico', 'registro'); tem('Tricoscopia · Occipital');
  T.ACOES['cmp-de'](campo('1')); igual(T.estado.comparar.de, 1, 'antes na 1ª'); tem('· sessão 1');
  T.ACOES['cmp-zoom'](campo('150')); igual(T.fotoDe('jorge', 6, 'trico', 'occipital').ajustes.zoom, 1.5, 'zoom do Agora salvo na foto');
  igual(T.fotoDe('jorge', 1, 'trico', 'occipital').ajustes.zoom, 1, 'o Antes não muda');
  T.ACOES['cmp-igualar'](); igual(T.fotoDe('jorge', 1, 'trico', 'occipital').ajustes.zoom, 1.5, 'igualar copia para o outro');
  T.ACOES['cmp-zoom-passo'](botao('cmp-zoom-passo', { passo: '1' })); entre(T.fotoDe('jorge', 6, 'trico', 'occipital').ajustes.zoom, 1.7, 1.75, 'passo de zoom');
  T.ACOES['cmp-centralizar'](); igual(T.fotoDe('jorge', 6, 'trico', 'occipital').ajustes.zoom, 1, 'centralizar zera');
  T.ACOES['cmp-ajustar'](botao('cmp-ajustar', { lado: 'antes' })); igual(T.estado.comparar.ajustando, 'antes', 'ajusta o Antes');
  T.ACOES['cmp-grade'](); igual(T.estado.comparar.grade, true, 'grade'); tem('class="grade"');
  T.ACOES['cmp-formato'](botao('cmp-formato', { formato: '1350' })); igual(T.estado.comparar.formato, '1350', 'formato');
  T.ACOES['cmp-registro'](botao('cmp-registro', { reg: 'topo' })); tem('Risca central'); T.ACOES['cmp-de'](campo('1'));
});
run('montagem: recusa quem não autorizou e registra o pedido pela Fer; Tatiane escolhe o formato (sem canvas, avisa)', function () {
  ir('montagem/jorge');
  T.ACOES.montar(); tem('Sem a autorização de publicação', aviso());
  T.ACOES['pedir-autorizacao'](); if (!T.pedidoDe('jorge')) throw new Error('sem pedido'); tem('Pedido registrado às'); naoTem('data-acao="pedir-autorizacao"');
  ir('montagem/tatiane');
  T.ACOES['mont-formato'](botao('mont-formato', { formato: '9:16' })); igual(T.estado.montagem.formato, '9:16', 'formato'); tem('data-formato="9:16" aria-pressed="true"');
  T.ACOES.montar(); tem('sem canvas, não dá', aviso());
});
run('linha do tempo: tocar numa foto abre grande, com quem, quando e a conferência; "Usar como" leva ao antes e agora; Esc fecha', function () {
  ir('linha/jorge');
  T.ACOES['ver-foto'](botao('ver-foto', { fid: 'foto:jorge:1:coroa' }));
  tem('Coroa · sessão 1', painel()); tem('Ana Carolina', painel()); tem('histórico fictício', painel()); tem('Usar como Antes', painel()); tem('Usar como Agora', painel()); tem('<strong>Luz.</strong>', painel());
  tecla('Escape'); if (!__els.painel.hidden) throw new Error('Esc não fechou');
  T.ACOES['ver-foto'](botao('ver-foto', { fid: 'foto:jorge:1:trico:vertex' })); tem('Tricoscopia · Vértex · sessão 1', painel());
  T.ACOES['usar-como'](botao('usar-como', { fid: 'foto:jorge:1:trico:vertex', lado: 'antes' }));
  igual(location.hash, '#comparar/jorge', 'foi ao antes e agora'); igual(T.estado.comparar.de, 1, 'antes na 1ª'); igual(T.estado.comparar.registro, 'trico', 'registro'); igual(T.estado.comparar.ponto, 'vertex', 'ponto');
  if (!__els.painel.hidden) throw new Error('o painel devia fechar');
});
run('a tricoscopia é opcional: pular leva ao resumo com 5 registros, e "Fazer a tricoscopia" volta aos 4 pontos', function () {
  ir('estacao/caio/3'); if (T.estado.captura.etapa === 'preparo') { preparo(); T.ACOES.comecar(); }
  while (T.estado.captura.etapa === 'registro' && T.estado.captura.idx < 5) capturaEAceita();
  igual(T.estado.captura.idx, 5, 'na tricoscopia'); tem('Pular a tricoscopia'); tem('normalmente é feita só na primeira e na última sessão'); tem('pode pular');
  T.ACOES['pular-trico']();
  igual(T.estado.captura.etapa, 'resumo', 'resumo'); igual(T.pulouTrico('caio', 3), true, 'pulo registrado'); igual(T.feitosDe('caio', 3), 6, 'conta como resolvida');
  tem('Pronto: os 5 registros desta sessão, sem a tricoscopia.'); tem('Pulada nesta sessão (opcional)'); tem('Fazer a tricoscopia'); tem('Fotos de hoje 6/6');
  if (!T.estado.log.some(function (l) { return l[1].indexOf('tricoscopia pulada') >= 0; })) throw new Error('sem registro do pulo no log');
  T.ACOES['fazer-trico'](botao('fazer-trico', { reg: '5' }));
  igual(T.pulouTrico('caio', 3), false, 'pulo desfeito'); igual(T.estado.captura.etapa, 'registro', 'de volta ao registro'); igual(T.estado.captura.idx, 5, 'na tricoscopia'); igual(T.feitosDe('caio', 3), 5, '5 de 6');
  tem('Ponto 1 de 4 · Frontal');
  ir('estacao/roberto/6'); if (T.estado.captura.etapa === 'preparo') { preparo(); T.ACOES.comecar(); }
  while (T.estado.captura.etapa === 'registro' && T.estado.captura.idx < 5) capturaEAceita();
  tem('normalmente é feita na primeira e na última sessão, e hoje é a 6ª'); tem('Pular a tricoscopia');
});
run('as datas seguem o padrão do ciclo (20, 30, 40, 45 e 45 dias) e caem em dia útil', function () {
  var J = T.PAC('jorge'); var R = T.PAC('roberto'); var C = T.PAC('caio');
  entre(T.diasEntre(T.dataSessao(J, 1), T.dataSessao(J, 3)), 48, 52, 'Jorge: 1ª → 3ª');
  entre(T.diasEntre(T.dataSessao(J, 3), T.dataSessao(J, 6)), 128, 132, 'Jorge: 3ª → 6ª');
  entre(T.diasEntre(T.dataSessao(J, 6), T.HOJE), 9, 11, 'Jorge: a 6ª foi há 9 dias, como na agenda da doutora');
  igual(T.diasEntre(T.dataSessao(R, 6), T.HOJE), 0, 'Roberto: a 6ª é hoje');
  entre(T.diasEntre(T.dataSessao(R, 1), T.HOJE), 178, 182, 'Roberto: a 1ª há 180 dias');
  igual(T.diasEntre(T.dataSessao(C, 3), T.HOJE), 0, 'Caio: a 3ª é hoje'); entre(T.diasEntre(T.dataSessao(C, 1), T.HOJE), 48, 52, 'Caio: a 1ª há 50 dias');
  T.PACIENTES.forEach(function (p) { [1, 3, 6].forEach(function (k) { var d = T.dataSessao(p, k).getDay(); if (d === 0 || d === 6) throw new Error(p.nome + ': sessão ' + k + ' no fim de semana'); }); });
  T.fotosDe('jorge').forEach(function (f) { var d = new Date(f.quando).getDay(); if (d === 0 || d === 6) throw new Error('foto no fim de semana'); });
});
run('a densidade fictícia cresce de sessão para sessão e varia por área; o occipital é controle', function () {
  var J = T.PAC('jorge');
  if (!(T.densidadeDe(J, 1, 'coroa') < T.densidadeDe(J, 3, 'coroa') && T.densidadeDe(J, 3, 'coroa') < T.densidadeDe(J, 6, 'coroa'))) throw new Error('a coroa do Jorge devia crescer');
  if (!(T.densidadeDe(J, 6, 'coroa') - T.densidadeDe(J, 1, 'coroa') > T.densidadeDe(J, 6, 'topo') - T.densidadeDe(J, 1, 'topo'))) throw new Error('a área foco muda mais');
  igual(T.densidadeDe(J, 1, 'trico', 'occipital'), T.densidadeDe(J, 6, 'trico', 'occipital'), 'o controle não muda');
  if (!(T.densidadeDe(J, 1, 'lat-dir') > 0.8)) throw new Error('as laterais são cheias');
});
run('nenhuma tela mostra promessa de resultado nem orientação clínica', function () {
  var telas = [];
  ['', 'linha/jorge', 'comparar/jorge', 'enviar/jorge', 'montagem/tatiane', 'montagem/jorge', 'estacao/roberto/6'].forEach(function (h) { ir(h); telas.push(__els.tela.innerHTML); });
  var re = /garant|promet|milagr|\bcura\b|vai crescer|melhorou|piorou e|recomendo|diagn[óo]stico|use o rem[ée]dio/i;
  telas.forEach(function (t) { var m = re.exec(t.replace(/não diz se melhorou ou piorou/g, '')); if (m) throw new Error('texto proibido: ' + m[0]); });
});
run('prévia de iPhone e iPad, e o tema: abre no claro e o botão liga o modo noturno', function () {
  T.definirModo('iphone');
  if (!document.documentElement.classList.contains('modo-iphone') || !document.documentElement.classList.contains('preview')) throw new Error('sem a prévia de iPhone');
  T.definirModo('tela'); if (document.documentElement.classList.contains('preview')) throw new Error('ficou em prévia');
  ir('ipad'); igual(T.modo, 'ipad', 'hash de prévia'); igual(location.hash, '', 'volta ao início'); T.definirModo('tela');
  igual(T.temaEfetivo(), 'dia', 'claro');
  T.ACOES.tema(); if (!document.documentElement.classList.contains('noite')) throw new Error('não foi para a noite');
  T.ACOES.tema(); if (!document.documentElement.classList.contains('dia')) throw new Error('não voltou para o dia');
});
run('o registro do que a estação fez abre no painel', function () {
  ir(''); T.ACOES['ver-log'](botao('ver-log'));
  tem('O que a estação registrou', painel()); tem('Histórico fictício preparado', painel()); tem('os 6 registros da 1ª sessão', painel());
  T.ACOES.fechar();
});
run('Recomeçar apaga o que a demonstração fez e regenera o histórico fictício', function () {
  igual(T.fotosDe('otavio').length, 9, 'antes: as fotos do Otávio'); if (!T.envioDe('jorge')) throw new Error('antes: o envio do Jorge');
  T.ACOES.reiniciar();
  igual(T.estado.pronto, true, 'pronta de novo'); igual(T.fotosDe('otavio').length, 0, 'Otávio zerado'); igual(T.fotosDe('jorge').length, 23, 'Jorge regenerado'); igual(T.pulouTrico('jorge', 3), true, 'o pulo da 3ª regenerado'); igual(T.fotosDe('caio').length, 9, 'Caio só com a 1ª');
  if (T.envioDe('jorge') || T.pedidoDe('jorge')) throw new Error('envio e pedido deviam sumir');
  igual(T.estado.log.length, 1, 'o log recomeça'); tem('Demonstração recomeçada', aviso());
  if (T.estado.captura !== null || T.estado.comparar !== null) throw new Error('o estado devia zerar');
  ir(''); tem('0 de 18 registros feitos');
});

// Revisão de 14/10/2026: o roteiro, a câmera, o armazenamento e o visualizador de artefatos do claude.ai.
run('Recomeçar volta à página inicial, e o histórico guardado leva o dia (é refeito quando o dia muda)', function () {
  var meta = T.ITENS.get('meta:versao'); igual(meta.dia, T.ddmmaaaa(T.HOJE), 'o dia do histórico');
  ir('comparar/jorge'); T.ACOES.reiniciar();
  igual(location.hash, '', 'volta ao início'); tem('Fotos de hoje.'); igual(T.estado.pronto, true, 'pronta');
  igual(T.Armazem.falha, null, 'sem falha de gravação'); tem('não deixou guardar as fotos');
});
run('textos: a lista das sessões em português, o plural da busca, o ponto da tricoscopia e a frase do Enviar fora da reavaliação', function () {
  igual(T.listaSessoes([1]), 'da 1ª sessão', 'uma'); igual(T.listaSessoes([1, 3]), 'da 1ª e da 3ª sessão', 'duas'); igual(T.listaSessoes([1, 3, 6]), 'da 1ª, da 3ª e da 6ª sessão', 'três');
  ir(''); tem('fotos da 1ª, da 3ª e da 6ª sessão'); tem('já tem as fotos da 1ª e da 3ª sessão'); tem('já tem as fotos da 1ª sessão'); naoTem('1ª e 3ª sessão');
  T.ACOES.busca(campo('jor')); tem('3 sessões fotografadas', __els['busca-resultados'].innerHTML);
  T.ACOES.busca(campo('caio')); tem('1 sessão fotografada', __els['busca-resultados'].innerHTML); naoTem('1 sessões', __els['busca-resultados'].innerHTML);
  T.ACOES.busca(campo(''));
  ir('enviar/otavio'); tem('A Dra. Suellen mostra o antes e agora na reavaliação, e o envio é no mesmo dia.'); naoTem('mostrou o antes e agora na reavaliação de hoje');
  ir('enviar/jorge'); tem('mostrou o antes e agora na reavaliação de hoje, às 10:00.');
});
run('antes e agora com uma sessão só avisa, e não exporta a mesma sessão dos dois lados', function () {
  ir('comparar/caio'); tem('Só uma sessão fotografada até agora'); igual(T.estado.comparar.de, T.estado.comparar.ate, 'a mesma sessão dos dois lados');
  T.ACOES.exportar(); tem('Escolha duas sessões diferentes', aviso()); if (T.exportacaoDe('caio')) throw new Error('não devia exportar');
  ir('comparar/jorge'); naoTem('Só uma sessão fotografada');
});
run('sessão fora da agenda de hoje: a saída não manda "Fotos de hoje n/6" ao copiloto da Ana Carolina', function () {
  ir('estacao/ricardo/1'); tem('Confira o preparo'); naoTem('fotos-feitas='); tem('href="#" data-sair="ana"'); tem('Continuar depois');
  preparo(); T.ACOES.comecar();
  for (var i = 0; i < 9; i += 1) capturaEAceita();
  igual(T.estado.captura.etapa, 'resumo', 'resumo'); igual(T.feitosDe('ricardo', 1), 6, '6 de 6');
  naoTem('fotos-feitas='); tem('Pronto, ver a linha do tempo'); tem('href="#linha/ricardo"'); tem('Sessão fora da agenda de hoje'); naoTem('O copiloto da Ana Carolina recebe');
  ir('estacao/caio/3'); tem(T.urlVoltaAna('caio', 0));
});
run('refazer a tricoscopia a partir do resumo refaz os 4 pontos em sequência; refazer um registro marca só ele como atual', function () {
  ir('estacao/otavio/1'); preparo(); T.ACOES.comecar();
  for (var i = 0; i < 9; i += 1) capturaEAceita();
  igual(T.estado.captura.etapa, 'resumo', 'resumo'); tem('Refazer os 4 pontos');
  T.ACOES.refazer(botao('refazer', { reg: '5', ponto: '0' }));
  tem('Ponto 1 de 4 · Frontal'); tem('A 2 dedos da linha do cabelo, no centro.'); igual(T.estado.captura.fila.join(','), '1,2,3', 'a fila dos outros pontos');
  igual(contagem(__els.tela.innerHTML, 'class="atual"'), 2, 'o passo 6 e o ponto 1 são os atuais');
  capturaEAceita(); tem('Ponto 2 de 4 · Médio'); igual(T.estado.captura.etapa, 'registro', 'continua na tricoscopia');
  capturaEAceita(); tem('Ponto 3 de 4 · Vértex');
  capturaEAceita(); tem('Ponto 4 de 4 · Occipital (controle)'); tem('Ponto de controle, na nuca');
  capturaEAceita(); igual(T.estado.captura.etapa, 'resumo', 'volta ao resumo depois dos 4'); igual(T.fotosDe('otavio').length, 9, 'sem duplicar');
  igual(T.estado.log.filter(function (l) { return l[1].indexOf('Otávio Reis') >= 0 && l[1].indexOf('ponto Occipital aceita') >= 0; }).length, 2, 'o occipital do Otávio foi aceito duas vezes');
  T.ACOES.refazer(botao('refazer', { reg: '1', ponto: '0' })); tem('Registro 2 de 6 · Coroa');
  igual(contagem(__els.tela.innerHTML, 'class="feito"></span>'), 5, 'cinco passos feitos'); igual(contagem(__els.tela.innerHTML, 'class="atual"></span>'), 1, 'um passo atual');
  capturaEAceita(); igual(T.estado.captura.etapa, 'resumo', 'de volta ao resumo'); igual(T.fotosDe('otavio').length, 9, 'ainda 9');
});
run('teclado: com um botão em foco, Enter e Espaço acionam o próprio botão, não o atalho; R continua repetindo', function () {
  function teclaNoBotao(k) { (__handlers.keydown || []).forEach(function (fn) { fn({ key: k, target: { closest: function (sel) { return /button/.test(sel) ? new El('botao') : null; } }, preventDefault: function () {} }); }); }
  ir('estacao/caio/3'); if (T.estado.captura.etapa === 'preparo') { preparo(); T.ACOES.comecar(); }
  igual(T.estado.captura.etapa, 'registro', 'no registro'); igual(T.feitosDe('caio', 3), 0, 'nada aceito');
  T.ACOES.capturar(); teclaNoBotao('Enter'); igual(T.feitosDe('caio', 3), 0, 'Enter no botão não aceita pelo atalho'); if (!T.estado.captura.atual) throw new Error('a captura devia continuar');
  teclaNoBotao('r'); if (T.estado.captura.atual) throw new Error('R devia repetir mesmo com o botão em foco');
  teclaNoBotao(' '); if (T.estado.captura.atual) throw new Error('Espaço no botão não captura pelo atalho');
  tecla(' '); tecla('Enter'); igual(T.feitosDe('caio', 3), 1, 'sem foco num botão, os atalhos valem');
});
run('câmera: a frase da permissão negada é outra da frase do visualizador; um stream que chega tarde assume, e um que chega depois de sair é desligado', function () {
  ir('estacao/caio/3'); igual(T.estado.captura.etapa, 'registro', 'no registro'); igual(T.estado.camera.modo, 'simulada', 'sem getUserMedia, simulada');
  tem('Este navegador não dá acesso à câmera.'); igual(T.EM_IFRAME, false, 'o DOM falso não é um iframe'); igual(T.politicaDaCamera(), 'desconhecida', 'sem a consulta da política');
  var pedidoOk = null; var pedidoErro = null; var parado = 0;
  var stream = { getTracks: function () { return [{ stop: function () { parado += 1; } }]; } };
  navigator.mediaDevices = { getUserMedia: function () { return { then: function (ok, erro) { pedidoOk = ok; pedidoErro = erro; } }; } };
  T.ACOES['ligar-camera']();
  igual(T.estado.camera.modo, 'pedindo', 'pedindo'); tem('Ligando a câmera'); tem('data-acao="capturar" disabled');
  pedidoErro({ name: 'NotAllowedError' });
  igual(T.estado.camera.modo, 'simulada', 'simulada'); igual(T.estado.camera.motivo, T.MSG_CAMERA_NEGADA, 'negada pela pessoa'); tem('Permissão da câmera negada: libere nas configurações do navegador ou use a câmera simulada.'); tem('data-acao="ligar-camera"');
  document.permissionsPolicy = { allowsFeature: function () { return false; } };
  igual(T.politicaDaCamera(), 'barrada', 'barrada pela política');
  T.ACOES['ligar-camera'](); igual(T.estado.camera.motivo, T.MSG_CAMERA_VISUALIZADOR, 'a frase do visualizador'); tem('Neste visualizador a câmera pode estar bloqueada; no sistema de verdade, a estação abre direto no navegador do aparelho.');
  delete document.permissionsPolicy;
  // a pessoa demora a permitir: o prazo vence e a simulada entra; quando o stream chega, a câmera ao vivo assume
  T.ACOES['ligar-camera'](); __flush(); igual(T.estado.camera.modo, 'simulada', 'prazo vencido'); tem('demorou a responder');
  pedidoOk(stream); igual(T.estado.camera.modo, 'real', 'o stream tardio assume'); igual(parado, 0, 'sem desligar'); tem('id="video"'); tem('Câmera ao vivo');
  // trocar desliga o anterior; sair da captura desliga; um stream que chega depois de sair é desligado na hora
  T.ACOES['ligar-camera'](); igual(parado, 1, 'o stream anterior foi desligado'); igual(T.estado.camera.modo, 'pedindo', 'pedindo de novo');
  ir(''); igual(T.estado.camera.modo, null, 'saiu da captura');
  pedidoOk(stream); igual(parado, 2, 'o stream que chegou depois de sair foi desligado'); if (T.Camera.stream) throw new Error('não devia guardar o stream');
  delete navigator.mediaDevices;
});
run('sem window.claude: Baixar é o link <a download>, e Compartilhar e Copiar avisam em vez de falhar em silêncio', function () {
  if (typeof window.claude !== 'undefined') throw new Error('o DOM falso não tem window.claude');
  igual(T.DOWNLOADS, null, 'sem namespace'); igual(T.DOWNLOADS_ESTADO, 'sem', 'estado inicial');
  T.ITENS.set('exportacao:jorge', { id: 'exportacao:jorge', tipo: 'exportacao', pid: 'jorge', dataURL: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==', formato: '1080', titulo: 'Coroa', etiquetaAntes: 'Antes · 01/01/2026 · sessão 1', etiquetaAgora: 'Agora · 02/02/2026 · sessão 6', hora: '09:20', arquivo: 'antes-e-agora-coroa-1080x1080.png' });
  ir('enviar/jorge'); tem('download="antes-e-agora-coroa-1080x1080.png"'); naoTem('data-acao="baixar"'); naoTem('folha de compartilhar'); tem('data-acao="compartilhar"');
  ir('comparar/jorge'); tem('download="antes-e-agora-coroa-1080x1080.png"');
  T.ACOES.compartilhar(); tem('Compartilhar não está disponível neste navegador', aviso());
  T.ACOES['copiar-imagem'](); tem('Copiar imagem não funciona neste navegador', aviso());
  var b = null; T.dataURLParaBlob('data:image/png;base64,iVBORw0KGgo=', function (x) { b = x; });
  if (!b || b.type !== 'image/png' || b.size !== 8) throw new Error('o dataURL devia virar um Blob PNG de 8 bytes, sem rede: ' + (b && b.size));
});
run('no visualizador do claude.ai: "downloads" troca Baixar pelo save, com nome .png sem nome de paciente; declined é silêncio, unavailable desabilita', function () {
  var chamadas = []; var falha = null;
  var ns = { save: function (pedido) { chamadas.push(pedido); return { then: function (ok, erro) { if (falha) erro(falha); else ok({ status: 'saved' }); } }; } };
  var usos = [];
  window.claude = { use: function (nome) { usos.push(nome); return { then: function (ok) { ok(ns); } }; } };
  T.prepararDownloads();
  igual(usos.join(','), 'downloads', 'pediu a capacidade downloads'); igual(T.DOWNLOADS, ns, 'namespace guardado'); igual(T.DOWNLOADS_ESTADO, 'pronto', 'pronto');
  igual(chamadas.length, 0, 'nada salvo sem clique');
  ir('enviar/jorge'); tem('data-acao="baixar" data-item="exportacao:jorge"'); naoTem('download="'); tem('folha de compartilhar do aparelho');
  igual(chamadas.length, 0, 'abrir a tela não chama o save');
  T.ACOES.baixar(botao('baixar', { item: 'exportacao:jorge' }));
  igual(chamadas.length, 1, 'save chamado uma vez'); if (!/\.png$/.test(chamadas[0].filename)) throw new Error('nome: ' + chamadas[0].filename); semNome(chamadas[0].filename, 'nome do arquivo');
  if (!chamadas[0].data || chamadas[0].data.type !== 'image/png') throw new Error('o data devia ser o Blob PNG'); tem('Imagem salva', aviso());
  ir('comparar/jorge'); tem('data-acao="baixar" data-item="exportacao:jorge"'); T.ACOES.compartilhar(); igual(chamadas.length, 2, 'no visualizador, Compartilhar cai no mesmo save');
  __els.toast.innerHTML = ''; falha = { code: 'declined', message: 'A pessoa recusou' };
  T.ACOES.baixar(botao('baixar', { item: 'exportacao:jorge' })); igual(aviso(), '', 'declined: silêncio'); igual(chamadas.length, 3, 'chamou o save');
  falha = { code: 'rate_limited', message: 'x' }; T.ACOES.baixar(botao('baixar', { item: 'exportacao:jorge' })); tem('Aguarde um instante', aviso());
  falha = { code: 'unavailable', message: 'x' }; T.ACOES.baixar(botao('baixar', { item: 'exportacao:jorge' })); tem('não está liberado', aviso());
  igual(T.DOWNLOADS_ESTADO, 'negado', 'negado'); tem('data-acao="baixar" data-item="exportacao:jorge" disabled'); tem('não está liberado neste visualizador');
  T.ACOES.baixar(botao('baixar', { item: 'exportacao:jorge' })); igual(chamadas.length, 5, 'desabilitado: não chama mais o save');
  T.ITENS.set('montagem:tatiane', { id: 'montagem:tatiane', tipo: 'montagem', pid: 'tatiane', dataURL: 'data:image/png;base64,iVBORw0KGgo=', formato: '4:5', titulo: 'Risca central', hora: '09:20', arquivo: 'post-risca-central-1080x1350.png' });
  ir('montagem/tatiane'); tem('data-acao="baixar" data-item="montagem:tatiane" disabled'); tem('data-acao="compartilhar-montagem"'); naoTem('download="');
  semNome(T.montagemDe('tatiane').arquivo, 'nome do arquivo do post');
  delete window.claude;
});
// Sem integração com o WhatsApp (Luiz, 15/10/2026): a Fer copia a imagem e o texto e cola no WhatsApp; só o "Enviei" registra.
function sinc(v) { return { then: function (a) { return sinc(a ? a(v) : v); } }; }
function negado() { return { then: function (a, b) { return sinc(b ? b() : undefined); } }; }
run('Luiz, 15/10 · sem wa.me nem "Abrir o WhatsApp" na estação, e o Copiar na mensagem da Fer', function () {
  naoTem('wa.me', __FONTE); naoTem('Abrir o WhatsApp', __FONTE); naoTem('Abrir no WhatsApp', __FONTE); naoTem('abrir-whats', __FONTE); naoTem('O link abre a conversa', __FONTE);
  ir('enviar/jorge');
  tem('<p id="texto-fer">' + T.MENSAGEM_FER + '</p>'); tem('<span>Copiar</span>'); tem('Não há integração com o WhatsApp');
  if (T.ACOES['abrir-whats'] || T.ACOES['copiar-texto']) throw new Error('ações antigas do WhatsApp');
});
run('Luiz, 15/10 · Copiar copia o texto exato, vira Copiado por 2 segundos e não marca como enviado; só o Enviei marca', function () {
  T.ITENS.set('exportacao:jorge', { id: 'exportacao:jorge', tipo: 'exportacao', pid: 'jorge', dataURL: 'data:,x', formato: '1080', titulo: 'Coroa', etiquetaAntes: 'Antes · 01/01/2026 · sessão 1', etiquetaAgora: 'Agora · 02/02/2026 · sessão 6', hora: '09:20', arquivo: 'antes-e-agora.png' });
  T.ITENS.delete('envio:jorge');
  ir('enviar/jorge');
  var original = navigator.clipboard.writeText, copiado = null;
  navigator.clipboard.writeText = function (x) { copiado = x; return sinc(); };
  try {
    getEl('texto-fer').textContent = T.MENSAGEM_FER;
    var nLog = T.estado.log.length;
    var b = botao('copiar-msg', { alvo: 'texto-fer' }); b.innerHTML = '<span>Copiar</span>';
    T.ACOES['copiar-msg'](b);
    igual(copiado, T.MENSAGEM_FER, 'copiou exatamente a mensagem');
    tem('Copiado', b.innerHTML); if (!b.classList.contains('copiado')) throw new Error('sem o estado copiado');
    tem('Copiado: cole no WhatsApp.', aviso());
    if (T.envioDe('jorge')) throw new Error('copiar não pode marcar como enviado');
    igual(T.estado.log.length, nLog, 'copiar não registra nada');
    tem('data-acao="enviei"'); naoTem('Enviado às');
    __flush();
    igual(b.innerHTML, '<span>Copiar</span>', 'volta a Copiar depois de 2 segundos');
    navigator.clipboard.writeText = function () { return negado(); };
    var b2 = botao('copiar-msg', { alvo: 'texto-fer' }); b2.innerHTML = '<span>Copiar</span>';
    T.ACOES['copiar-msg'](b2);
    tem('Não deu para copiar sozinho: o texto está selecionado, use Ctrl+C', aviso()); naoTem('Copiado', b2.innerHTML);
    navigator.clipboard.writeText = function () { throw new Error('bloqueado'); };
    T.ACOES['copiar-msg'](b2); tem('use Ctrl+C', aviso());
    T.ACOES.enviei();
    if (!T.envioDe('jorge')) throw new Error('o Enviei registra'); tem('Enviado às');
  } finally { navigator.clipboard.writeText = original; }
});

'''

ctx = quickjs.Context()
ctx.eval(SHIM)
ctx.eval(script)
ctx.eval('globalThis.__FONTE = ' + json.dumps(html) + ';')
ctx.eval(TESTES)
while ctx.execute_pending_job():
    pass
saida = ctx.eval('__out.join("\\n")')
print(saida)
falhas = saida.count('FALHA')
print(f'{saida.count("ok    ")} ok, {falhas} falha(s)')
sys.exit(1 if falhas else 0)
