Files
agenciapsilmno/src/features/agenda/composables/__tests__/useAgendaEventComposer.spec.js
T
Leonardo e95ed9b585 agenda: Fase 5 (status change/edit cobrada) + indicadores visuais + UX convenio
DB
- drop agenda_excecoes (substituida por financial_exceptions + lock-edit
  baseado em financial_records)
- financial_records.payment_link (Asaas + link compartilhavel)
- financial_exceptions.consume_on_miss (rotular nao-show consome ou nao)
- billing_contracts.charging_style (upfront/saldo/per_session)

Payment refactor
- paymentSettlement -> paymentMethod (string) + markPaidNow (bool).
  Handler aplica payment_method sempre; status='paid'+paid_at apenas
  quando markPaidNow=true && method != 'link'. Asaas (link) sempre
  liquida via webhook, nunca nasce paid.
- create_financial_record_for_session com pos-RPC patch pra payment_method
  e (opcional) status='paid' quando user marca "ja recebi".

Indicadores visuais (3 canais distintos por estado)
- Paid: barra esquerda emerald-500 4px na agenda (MelissaAgenda),
  pi-check-circle no popover/Resumo.
- Pending: badge \$ amber canto direito (mantido); linha amber no popover/
  Resumo "A receber R\$ X (cobranca pendente)".
- Neutro: sem badge nem barra (compromisso pessoal, bloqueio, ou
  ocorrencia virtual de pacote upfront/saldo).
- Bulk-load de paymentState em _reloadRange etapa 4 (1 query unica em
  financial_records mapeada por agenda_evento_id).
- AgendaEventDialog Resumo lateral ganha linha entre pi-clock e
  pi-map-marker via novo sessionPaymentRecord (sem guard de
  occurrenceMode, contrario ao occFinancialRecord que continua so pra
  Rail/Clinica). 5 estados: paid+paid_at, overdue+venceu, pending+vence,
  sem cobranca c/ valor, sem cobranca s/ valor.

UX de convenio
- InsurancePlanServiceQuickCreateDialog novo: cadastra procedimento
  POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona
  novo procedimento so quando nada estava selecionado antes.
- Caixa cinza "Cadastrar procedimento" sempre visivel quando convenio
  selecionado, com copy variavel (0 procedimentos: chamada urgente;
  1+: "se quiser adicionar mais").
- "+ Novo convenio" toolbar em ConfiguracoesConveniosPage (botao
  estava faltando, empty state mandava clicar em botao inexistente).
- Hint contextual abaixo do card Sessao/Honorarios: convenio = "N da
  guia eh opcional", gratuito = "sem cobranca", particular = sem hint.
  Label "N da Guia" tambem ganhou "(opcional)" no service-picker dialog.

Bug fixes
- pickDbFields whitelist faltando 'modalidade' (useMelissaAgenda.js:74)
  — sessoes avulsas eram salvas como presencial independente da
  escolha visual. Adicionado.
- goToConveniosConfig removida — fazia router.push("/therapist/
  configuracoes/convenios") mas /configuracoes/* eh rota raiz, nao
  filha. Substituida pelo quick-create inline (#1).
- bloqueioCobrindo + dialogBlockOverlap passados via deps em
  _buildHandlers (refs do useMelissaAgenda nao sao acessiveis no
  escopo de _buildHandlers).

Fase 5 (status change + edit cobrada)
- AgendaStatusChangeConfirmDialog: confirm dialog quando user muda
  status pra realizada/faltou/cancelado, com opcoes de markPaid ou
  gerar cobranca conforme o caso.
- useAgendaBloqueios novo composable: extrai logica de bloqueios
  cinza (background events) do MelissaAgenda.

Doc viva
- src/docs/agenda-compromisso-financeiro-cenarios.html: 13 cenarios
  de teste manual. C1-C4 ja validados. Cada teste validado vira parte
  da doc final pra area de ajuda (pos-Fase 9).

Wiki/handoff
- agenda-compromisso-fluxo e agenda-billing-pesquisa-mercado (decisoes
  arquiteturais sobre billing).
- HANDOFF.md atualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:31:18 -03:00

703 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* useAgendaEventComposer.spec.js — A66 sub-sessão 1B
*
* Cobre o composable factory extraído do AgendaEventDialog.
* Foco do contrato: refs reativos + computeds derivados são consistentes
* com o comportamento original do .vue (matriz de inputs, edge cases).
*
* Não cobre: watchers e handlers (1C — fica no .vue ainda).
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref, reactive } from 'vue';
import { useAgendaEventComposer } from '../useAgendaEventComposer';
// Mock de getPatientAgendaPermissions — o composable importa direto
vi.mock('@/composables/usePatientLifecycle', () => ({
getPatientAgendaPermissions: (status) => {
const norm = String(status || '').toLowerCase();
if (norm === 'inativo' || norm === 'arquivado') {
return { canCreateSession: false, canCreateRecurrence: false };
}
return { canCreateSession: true, canCreateRecurrence: true };
}
}));
// Helper: monta props default + emit fake
function setup(overrides = {}, extras = {}) {
const props = reactive({
modelValue: false,
eventRow: null,
initialStartISO: '',
initialEndISO: '',
ownerId: 'owner-1',
planOwnerId: '',
allowOwnerEdit: false,
ownerOptions: [],
tenantId: 'tenant-1',
commitmentOptions: [],
presetCommitmentId: null,
lockCommitment: false,
restrictPatientsToOwner: false,
patientScopeOwnerId: null,
workRules: [],
blockedDates: [],
agendaSettings: { session_duration_min: 50, session_break_min: 0 },
allEvents: [],
pausasSemanais: [],
feriados: [],
newPatientRoute: '',
...overrides
});
const emitted = [];
const emit = (...args) => emitted.push(args);
const composer = useAgendaEventComposer(props, emit, extras);
return { props, emit, emitted, composer };
}
const SESSION_COMMITMENT = { id: 'c-session', native_key: 'session', name: 'Sessão' };
const MEETING_COMMITMENT = { id: 'c-meeting', native_key: 'meeting', name: 'Reunião' };
// ════════════════════════════════════════════════════════════════════════
describe('visible (v-model)', () => {
it('lê de props.modelValue', () => {
const { composer, props } = setup({ modelValue: true });
expect(composer.visible.value).toBe(true);
props.modelValue = false;
expect(composer.visible.value).toBe(false);
});
it('escrever emite update:modelValue', () => {
const { composer, emitted } = setup();
composer.visible.value = true;
expect(emitted).toContainEqual(['update:modelValue', true]);
});
});
describe('isEdit', () => {
it('false sem eventRow', () => {
const { composer } = setup({ eventRow: null });
expect(composer.isEdit.value).toBe(false);
});
it('true com id', () => {
const { composer } = setup({ eventRow: { id: 'evt-1' } });
expect(composer.isEdit.value).toBe(true);
});
it('true com is_occurrence (sem id)', () => {
const { composer } = setup({ eventRow: { is_occurrence: true } });
expect(composer.isEdit.value).toBe(true);
});
});
describe('allowBack', () => {
it('true por default', () => {
const { composer } = setup();
expect(composer.allowBack.value).toBe(true);
});
it('false quando lockCommitment', () => {
const { composer } = setup({ lockCommitment: true });
expect(composer.allowBack.value).toBe(false);
});
it('false quando presetCommitmentId', () => {
const { composer } = setup({ presetCommitmentId: 'c-1' });
expect(composer.allowBack.value).toBe(false);
});
});
describe('hasSerie', () => {
it('false sem indicadores de série', () => {
const { composer } = setup({ eventRow: { id: 'evt-1' } });
expect(composer.hasSerie.value).toBe(false);
});
it('true com recurrence_id', () => {
const { composer } = setup({ eventRow: { id: 'evt-1', recurrence_id: 'r-1' } });
expect(composer.hasSerie.value).toBe(true);
});
it('true com serie_id (legado)', () => {
const { composer } = setup({ eventRow: { id: 'evt-1', serie_id: 's-1' } });
expect(composer.hasSerie.value).toBe(true);
});
it('true com is_occurrence', () => {
const { composer } = setup({ eventRow: { is_occurrence: true } });
expect(composer.hasSerie.value).toBe(true);
});
});
describe('isFirstOccurrence', () => {
it('false quando não é série', () => {
const { composer } = setup();
expect(composer.isFirstOccurrence.value).toBe(false);
});
it('true quando recurrence_date é a menor data da série', () => {
const serieEvents = ref([
{ recurrence_date: '2026-05-15' },
{ recurrence_date: '2026-05-22' },
{ recurrence_date: '2026-05-29' }
]);
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-15' } },
{ serieEvents }
);
expect(composer.isFirstOccurrence.value).toBe(true);
});
it('false quando recurrence_date NÃO é a menor', () => {
const serieEvents = ref([
{ recurrence_date: '2026-05-15' },
{ recurrence_date: '2026-05-22' }
]);
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-22' } },
{ serieEvents }
);
expect(composer.isFirstOccurrence.value).toBe(false);
});
it('false quando serieEvents vazio', () => {
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-15' } },
{ serieEvents: ref([]) }
);
expect(composer.isFirstOccurrence.value).toBe(false);
});
});
describe('editScopeOptions', () => {
it('retorna 3 opções (todos_sem_excecao removido da UI em 2026-05-12)', () => {
const { composer } = setup();
expect(composer.editScopeOptions.value).toHaveLength(3);
expect(composer.editScopeOptions.value.map((o) => o.value)).toEqual(['somente_este', 'este_e_seguintes', 'todos']);
});
it('"este_e_seguintes" disabled quando isFirstOccurrence', () => {
const serieEvents = ref([{ recurrence_date: '2026-05-15' }, { recurrence_date: '2026-05-22' }]);
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-15' } },
{ serieEvents }
);
const opt = composer.editScopeOptions.value.find((o) => o.value === 'este_e_seguintes');
expect(opt.disabled).toBe(true);
});
});
describe('qtdSessoesEfetiva', () => {
it('valores fixos pelo mode', () => {
const { composer } = setup();
composer.qtdSessoesMode.value = '4';
expect(composer.qtdSessoesEfetiva.value).toBe(4);
composer.qtdSessoesMode.value = '8';
expect(composer.qtdSessoesEfetiva.value).toBe(8);
composer.qtdSessoesMode.value = '12';
expect(composer.qtdSessoesEfetiva.value).toBe(12);
});
it('"personalizar" usa qtdSessoesCustom', () => {
const { composer } = setup();
composer.qtdSessoesMode.value = 'personalizar';
composer.qtdSessoesCustom.value = 24;
expect(composer.qtdSessoesEfetiva.value).toBe(24);
});
it('clampa em 1 quando custom é 0/null', () => {
const { composer } = setup();
composer.qtdSessoesMode.value = 'personalizar';
composer.qtdSessoesCustom.value = 0;
expect(composer.qtdSessoesEfetiva.value).toBe(1);
composer.qtdSessoesCustom.value = null;
expect(composer.qtdSessoesEfetiva.value).toBe(1);
});
});
describe('proximasOcorrencias', () => {
function asISODate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
it('retorna [] quando avulsa', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'avulsa';
composer.form.value.dia = new Date('2026-05-15');
expect(composer.proximasOcorrencias.value).toEqual([]);
});
it('semanal: 4 datas separadas por 7 dias', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'semanal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00');
const list = composer.proximasOcorrencias.value;
expect(list).toHaveLength(4);
expect(asISODate(list[0])).toBe('2026-05-15');
expect(asISODate(list[1])).toBe('2026-05-22');
expect(asISODate(list[2])).toBe('2026-05-29');
expect(asISODate(list[3])).toBe('2026-06-05');
});
it('quinzenal: separação 14 dias', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'quinzenal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00');
const list = composer.proximasOcorrencias.value;
expect(list).toHaveLength(4);
expect(asISODate(list[1])).toBe('2026-05-29');
expect(asISODate(list[2])).toBe('2026-06-12');
});
it('diasEspecificos: respeita dias selecionados', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'diasEspecificos';
composer.qtdSessoesMode.value = '4';
composer.diasSelecionados.value = [1, 3]; // segunda + quarta
composer.form.value.dia = new Date('2026-05-18T10:00:00'); // segunda
const list = composer.proximasOcorrencias.value;
expect(list).toHaveLength(4);
// 18 (seg) - 20 (qua) - 25 (seg) - 27 (qua)
expect(list[0].getDay()).toBe(1);
expect(list[1].getDay()).toBe(3);
expect(list[2].getDay()).toBe(1);
expect(list[3].getDay()).toBe(3);
});
it('diasEspecificos com array vazio retorna []', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'diasEspecificos';
composer.diasSelecionados.value = [];
composer.form.value.dia = new Date('2026-05-15');
expect(composer.proximasOcorrencias.value).toEqual([]);
});
});
describe('toggleDiaSelecionado', () => {
it('adiciona se não existe, remove se existe', () => {
const { composer } = setup();
composer.toggleDiaSelecionado(1);
expect(composer.diasSelecionados.value).toEqual([1]);
composer.toggleDiaSelecionado(3);
expect(composer.diasSelecionados.value).toEqual([1, 3]);
composer.toggleDiaSelecionado(1);
expect(composer.diasSelecionados.value).toEqual([3]);
});
});
describe('isForaDoPlano (com dataLimiteManual)', () => {
it('false quando dataLimiteManual null', () => {
const { composer } = setup();
expect(composer.isForaDoPlano(new Date('2026-12-31'))).toBe(false);
});
it('true quando data > limite', () => {
const { composer } = setup();
composer.dataLimiteManual.value = '2026-06-30';
expect(composer.isForaDoPlano(new Date('2026-12-31'))).toBe(true);
expect(composer.isForaDoPlano(new Date('2026-06-29'))).toBe(false);
});
});
describe('commitmentCards', () => {
it('coloca native_key="session" primeiro', () => {
const { composer } = setup({
commitmentOptions: [MEETING_COMMITMENT, SESSION_COMMITMENT, { id: 'c-3', name: 'Avaliação' }]
});
const cards = composer.commitmentCards.value;
// commitmentOptions vem via reactive(props) — itens viram proxies,
// por isso comparamos id (toBe) em vez de identidade do objeto.
expect(cards[0].id).toBe('c-session');
});
it('ordem alfabética entre não-session', () => {
const { composer } = setup({
commitmentOptions: [
{ id: 'c-1', name: 'Zeta' },
{ id: 'c-2', name: 'Alpha' },
{ id: 'c-3', name: 'Beta' }
]
});
const cards = composer.commitmentCards.value;
expect(cards.map((c) => c.name)).toEqual(['Alpha', 'Beta', 'Zeta']);
});
});
describe('selectedCommitment + relacionados', () => {
it('selectedCommitment encontra pelo form.commitment_id', () => {
const { composer } = setup({
commitmentOptions: [SESSION_COMMITMENT, MEETING_COMMITMENT]
});
composer.form.value.commitment_id = 'c-meeting';
expect(composer.selectedCommitment.value?.id).toBe('c-meeting');
});
it('selectedCommitmentName fallback "—" quando null', () => {
const { composer } = setup();
expect(composer.selectedCommitmentName.value).toBe('—');
});
it('requiresPatient true quando native_key=session', () => {
const { composer } = setup({ commitmentOptions: [SESSION_COMMITMENT] });
composer.form.value.commitment_id = 'c-session';
expect(composer.requiresPatient.value).toBe(true);
expect(composer.isSessionEvent.value).toBe(true);
});
it('requiresPatient false pra outros commitments', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
composer.form.value.commitment_id = 'c-meeting';
expect(composer.requiresPatient.value).toBe(false);
});
it('patientLocked true só na edição de sessão com paciente', () => {
const { composer } = setup({
commitmentOptions: [SESSION_COMMITMENT],
eventRow: { id: 'evt-1', paciente_id: 'p-1' }
});
composer.form.value.commitment_id = 'c-session';
expect(composer.patientLocked.value).toBe(true);
});
it('hasInsurance reage a form.insurance_plan_id', () => {
const { composer } = setup();
expect(composer.hasInsurance.value).toBe(false);
composer.form.value.insurance_plan_id = 'plan-1';
expect(composer.hasInsurance.value).toBe(true);
});
});
describe('agendaPerms', () => {
it('Inativo bloqueia create', () => {
const { composer } = setup();
composer.form.value.paciente_status = 'Inativo';
expect(composer.agendaPerms.value.canCreateSession).toBe(false);
});
it('Ativo permite tudo', () => {
const { composer } = setup();
composer.form.value.paciente_status = 'Ativo';
expect(composer.agendaPerms.value.canCreateSession).toBe(true);
expect(composer.agendaPerms.value.canCreateRecurrence).toBe(true);
});
});
describe('isSessionFuture / isArchivedPastEdit / isInativoFutureEdit', () => {
let mockNow;
beforeEach(() => {
mockNow = new Date('2026-05-15T12:00:00').getTime();
vi.useFakeTimers();
vi.setSystemTime(mockNow);
});
it('isSessionFuture true quando criando (não-edit)', () => {
const { composer } = setup({ eventRow: null });
expect(composer.isSessionFuture.value).toBe(true);
});
it('isSessionFuture true quando edit + sessão futura', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-20T10:00:00' }
});
expect(composer.isSessionFuture.value).toBe(true);
});
it('isSessionFuture false quando edit + sessão passada', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-10T10:00:00' }
});
expect(composer.isSessionFuture.value).toBe(false);
});
it('isArchivedPastEdit true: edit + Arquivado + passada', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-10T10:00:00' }
});
composer.form.value.paciente_status = 'Arquivado';
expect(composer.isArchivedPastEdit.value).toBe(true);
});
it('isInativoFutureEdit true: edit + Inativo + futura', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-20T10:00:00' }
});
composer.form.value.paciente_status = 'Inativo';
expect(composer.isInativoFutureEdit.value).toBe(true);
});
});
describe('inicioDateTime / fimDateTime', () => {
it('null quando dia ou startTime ausentes', () => {
const { composer } = setup();
composer.form.value.dia = null;
expect(composer.inicioDateTime.value).toBe(null);
});
it('combina dia + startTime corretamente', () => {
const { composer } = setup();
composer.form.value.dia = new Date('2026-05-15T00:00:00');
composer.form.value.startTime = '14:30';
const ini = composer.inicioDateTime.value;
expect(ini.getHours()).toBe(14);
expect(ini.getMinutes()).toBe(30);
});
it('fimDateTime adiciona duracaoMin', () => {
const { composer } = setup();
composer.form.value.dia = new Date('2026-05-15T00:00:00');
composer.form.value.startTime = '14:00';
composer.form.value.duracaoMin = 50;
const fim = composer.fimDateTime.value;
expect(fim.getHours()).toBe(14);
expect(fim.getMinutes()).toBe(50);
});
});
describe('startTimeDate (computed get/set)', () => {
it('getter null quando startTime null', () => {
const { composer } = setup();
composer.form.value.startTime = null;
expect(composer.startTimeDate.value).toBe(null);
});
it('getter retorna Date com hora setada', () => {
const { composer } = setup();
composer.form.value.startTime = '09:30';
const d = composer.startTimeDate.value;
expect(d.getHours()).toBe(9);
expect(d.getMinutes()).toBe(30);
});
it('setter atualiza form.startTime no formato HH:MM', () => {
const { composer } = setup();
const d = new Date();
d.setHours(15, 5, 0, 0);
composer.startTimeDate.value = d;
expect(composer.form.value.startTime).toBe('15:05');
});
it('setter null limpa form.startTime', () => {
const { composer } = setup();
composer.form.value.startTime = '14:00';
composer.startTimeDate.value = null;
expect(composer.form.value.startTime).toBe(null);
});
});
describe('canSave (matriz de validação — núcleo)', () => {
function ready(composer) {
composer.form.value.owner_id = 'owner-1';
composer.form.value.dia = new Date('2026-05-15');
composer.form.value.startTime = '14:00';
composer.form.value.commitment_id = 'c-meeting';
composer.form.value.duracaoMin = 50;
}
it('false sem owner_id', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.owner_id = '';
expect(composer.canSave.value).toBe(false);
});
it('false sem dia', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.dia = null;
expect(composer.canSave.value).toBe(false);
});
it('false sem startTime', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.startTime = null;
expect(composer.canSave.value).toBe(false);
});
it('false ao criar sem commitment_id', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.commitment_id = null;
expect(composer.canSave.value).toBe(false);
});
it('true ao EDITAR sem commitment_id (sessões antigas)', () => {
const { composer } = setup({
commitmentOptions: [MEETING_COMMITMENT],
eventRow: { id: 'evt-1', inicio_em: '2026-05-15T14:00:00' }
});
ready(composer);
composer.form.value.commitment_id = null; // legacy null
expect(composer.canSave.value).toBe(true);
});
it('false em sessão sem paciente_id quando requiresPatient', () => {
const { composer } = setup({ commitmentOptions: [SESSION_COMMITMENT] });
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = null;
expect(composer.canSave.value).toBe(false);
});
it('false em sessão particular sem itens de billing', () => {
const items = ref([]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.billingType.value = 'particular';
expect(composer.canSave.value).toBe(false);
});
it('true em sessão particular COM itens', () => {
const items = ref([{ id: 'i-1' }]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.billingType.value = 'particular';
expect(composer.canSave.value).toBe(true);
});
it('false ao criar sessão pra paciente Inativo', () => {
const items = ref([{ id: 'i-1' }]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.form.value.paciente_status = 'Inativo';
composer.billingType.value = 'particular';
expect(composer.canSave.value).toBe(false);
});
it('false ao criar recorrência pra paciente Arquivado', () => {
const items = ref([{ id: 'i-1' }]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.form.value.paciente_status = 'Arquivado';
composer.billingType.value = 'particular';
composer.recorrenciaType.value = 'semanal';
expect(composer.canSave.value).toBe(false);
});
it('true em billing convenio sem precisar de itens', () => {
const items = ref([]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.billingType.value = 'convenio';
expect(composer.canSave.value).toBe(true);
});
});
describe('timeConflict (pré-check antes do PATCH)', () => {
function readyForm(composer) {
composer.form.value.dia = new Date('2026-05-15T00:00:00');
composer.form.value.startTime = '14:00';
composer.form.value.duracaoMin = 50;
}
it('null quando form ainda incompleto', () => {
const { composer } = setup();
composer.form.value.dia = null;
expect(composer.timeConflict.value).toBe(null);
});
it('null sem allEvents', () => {
const { composer } = setup({ allEvents: [] });
readyForm(composer);
expect(composer.timeConflict.value).toBe(null);
});
it('detecta overlap com evento existente', () => {
const { composer } = setup({
allEvents: [{
id: 'evt-x',
inicio_em: '2026-05-15T14:30:00',
fim_em: '2026-05-15T15:20:00',
paciente_nome: 'Maria'
}]
});
readyForm(composer);
expect(composer.timeConflict.value).toMatch(/Maria/);
});
it('NÃO detecta overlap com o próprio evento (form.id === evt.id)', () => {
const { composer } = setup({
allEvents: [{
id: 'self',
inicio_em: '2026-05-15T14:00:00',
fim_em: '2026-05-15T14:50:00'
}]
});
readyForm(composer);
composer.form.value.id = 'self';
expect(composer.timeConflict.value).toBe(null);
});
it('null quando evento adjacente (sem overlap real)', () => {
const { composer } = setup({
allEvents: [{
id: 'evt-x',
inicio_em: '2026-05-15T15:00:00',
fim_em: '2026-05-15T15:50:00'
}]
});
readyForm(composer);
composer.form.value.duracaoMin = 50; // 14:0014:50
expect(composer.timeConflict.value).toBe(null);
});
it('detecta sobreposição com pausa do dia', () => {
const { composer } = setup({
pausasSemanais: [{
dia_semana: 5, // sexta (2026-05-15)
hora_inicio: '14:30',
hora_fim: '15:00'
}]
});
readyForm(composer);
expect(composer.timeConflict.value).toMatch(/pausa/i);
});
});
describe('totalConflitos / sessoesForaDoPlano', () => {
it('totalConflitos conta ocorrências com folga/feriado/bloqueado/pausa', () => {
const { composer } = setup({
workRules: [
{ dia_semana: 1 }, { dia_semana: 2 }, { dia_semana: 3 },
{ dia_semana: 4 }, { dia_semana: 5 } // seg-sex
],
blockedDates: ['2026-05-22']
});
composer.recorrenciaType.value = 'semanal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00'); // sexta
// 15 (sex ok) - 22 (sex bloqueada) - 29 (sex ok) - 5/jun (sex ok)
expect(composer.totalConflitos.value).toBe(1);
});
it('sessoesForaDoPlano com dataLimiteManual', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'semanal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00');
composer.dataLimiteManual.value = '2026-05-22';
// ocorrências carregam hora 10:00 (de form.dia); limite "2026-05-22"
// vira meia-noite local. Logo 22/5 10:00 > 22/5 00:00 → fora.
// Resultado: 15 (dentro), 22 (fora), 29 (fora), 5/jun (fora) = 3
expect(composer.sessoesForaDoPlano.value).toBe(3);
});
});
describe('headerTitle', () => {
it('"Editar compromisso" quando isEdit', () => {
const { composer } = setup({ eventRow: { id: 'evt-1' } });
expect(composer.headerTitle.value).toBe('Editar compromisso');
});
it('"Novo compromisso — escolha o tipo" no step 1', () => {
const { composer } = setup();
composer.step.value = 1;
expect(composer.headerTitle.value).toMatch(/escolha o tipo/);
});
it('"Novo compromisso" no step 2', () => {
const { composer } = setup();
composer.step.value = 2;
expect(composer.headerTitle.value).toBe('Novo compromisso');
});
});
describe('computedTitulo', () => {
it('usa titulo_custom quando preenchido', () => {
const { composer } = setup();
composer.form.value.titulo_custom = 'Minha custom';
expect(composer.computedTitulo.value).toBe('Minha custom');
});
it('"—" quando não há commitment selecionado (selectedCommitmentName fallback)', () => {
// selectedCommitmentName retorna "—" quando sem commitment, então
// computedTitulo herda esse "—" porque "—" || "Compromisso" → "—".
// Comportamento original do .vue preservado.
const { composer } = setup();
expect(composer.computedTitulo.value).toBe('—');
});
it('combina nome paciente + commitment quando session', () => {
const { composer } = setup({ commitmentOptions: [SESSION_COMMITMENT] });
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_nome = 'Ana';
expect(composer.computedTitulo.value).toBe('Ana [Sessão]');
});
});