e95ed9b585
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>
703 lines
28 KiB
JavaScript
703 lines
28 KiB
JavaScript
/**
|
||
* 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:00–14: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]');
|
||
});
|
||
});
|