Files
carinha-control-panel/tests/test_aprendizado.py
T
julioandClaude Opus 5.5 b4cf13df11 feat(vida): página "Dia a dia" com as decisões e o porquê
Cada decisão tomada com base no que ele aprendeu passa a ser guardada
com o motivo (aprendizado.decidir, até 800, persistidas):
- acordou do cochilo antes da hora: "às 18h de quarta costuma ter
  gente, pelo plano que você contou";
- cochilo com a casa vazia, com a duração e o porquê;
- sensor segurado numa hora em que acordou à toa (no máximo 1 nota a
  cada 10 min);
- gesto escolhido porque costuma agradar (chance ≥ ×1,3);
- fechamento do dia: horas com gente, carinhos e quanto o dia conferiu
  com o plano;
- análise da IA (adotada ou sugestão, com a porcentagem) e rotina
  contada por você.

esperado_e_fonte diz de onde veio a expectativa (plano seu, plano da
IA, o que viu naquele dia da semana ou média). GET /aprendizado/dia
devolve um dia: medido × plano por hora, conferência do dia, resumo e
decisões.

Painel: tela "Dia a dia" (#/dia-a-dia) com navegação entre dias, os
números do dia, as faixas "Viu" × "Esperava" e a linha do tempo das
decisões; atalho no cartão "O que ele aprendeu", no Ctrl+K e no guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:34:30 -03:00

432 lines
20 KiB
Python

"""Aprendizado com o dia a dia: gosto pelos atos, rotina da casa, sono e o fechamento do dia."""
import http.client
import importlib.util
import json
import random
import sys
import tempfile
import threading
import time
import unittest
from datetime import date, timedelta
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from aprendizado import Aprendizado # noqa: E402
from vida import Vida # noqa: E402
spec = importlib.util.spec_from_file_location('kiosk_aprendizado', ROOT / 'kiosk-server.py')
kiosk = importlib.util.module_from_spec(spec)
spec.loader.exec_module(kiosk)
def em(dia, hora, minuto=0, segundo=0):
return time.mktime((2026, 10, dia, hora, minuto, segundo, 0, 0, -1))
class AprendizadoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.caminho = Path(self.tmp.name) / 'aprendizado.json'
self.a = Aprendizado(self.caminho)
self.a.hoje = self.a._dia_vazio('2026-10-06')
self.diario = []
self.a.anotar = lambda tipo, texto, agora=None: self.diario.append((tipo, texto))
def tearDown(self):
self.tmp.cleanup()
def fazer(self, nome, vezes, resposta=None, inicio=None):
t = inicio or em(6, 15)
for i in range(vezes):
self.a.ato(nome, t + i * 120, perto=True)
if resposta:
self.a.resposta(resposta, t + i * 120 + 20)
def test_gosto_sobe_com_carinho_e_desce_quando_ignorado(self):
self.fazer('rir', 5, 'carinho')
self.fazer('bocejar', 5, inicio=em(6, 16))
self.assertGreater(self.a.peso('rir'), 1.0)
self.assertLess(self.a.peso('bocejar'), 1.0)
self.assertLessEqual(self.a.peso('rir'), 2.0)
self.assertGreaterEqual(self.a.peso('bocejar'), 0.5)
def test_sem_opiniao_com_poucas_tentativas_ou_sozinho(self):
self.fazer('rir', 2, 'carinho')
self.assertEqual(self.a.peso('rir'), 1.0)
for i in range(10):
self.a.ato('pensar', em(6, 15) + i, perto=False) # sem plateia ninguém pode gostar
self.assertEqual(self.a.peso('pensar'), 1.0)
self.assertNotIn('pensar', self.a.itens)
def test_resposta_fora_da_janela_nao_conta(self):
for i in range(4):
self.a.ato('acenar', em(6, 15) + i * 300, perto=True)
self.a.resposta('carinho', em(6, 15) + i * 300 + 200) # 200 s depois: já era outra coisa
self.assertEqual(self.a.itens['acenar']['s'], 0)
def test_cada_ato_agrada_uma_vez(self):
self.a.ato('rir', em(6, 15), perto=True)
for _ in range(5):
self.a.resposta('carinho', em(6, 15) + 10)
self.assertEqual(self.a.itens['rir']['s'], 1.0)
def test_fechar_o_dia_gera_licoes_e_esquece_um_pouco(self):
self.fazer('rir', 4, 'carinho')
self.fazer('bocejar', 5, inicio=em(6, 17))
t_antes = self.a.itens['rir']['t']
self.a.observar(em(7, 0, 1), perto=False) # meia-noite: fecha o dia 6
textos = [t for _, t in self.diario]
self.assertTrue(any('gostam de me ver dar risada' in t for t in textos), textos)
self.assertTrue(any('Tentei bocejar 5 vezes e ninguém ligou' in t for t in textos), textos)
self.assertAlmostEqual(self.a.itens['rir']['t'], t_antes * 0.85, places=2)
self.assertEqual(self.a.hoje['dia'], '2026-10-07')
self.assertTrue(self.caminho.exists())
def test_rotina_da_casa_e_acordar_antes_de_chegarem(self):
for dia in (6, 7, 8):
for minuto in range(60):
self.a.observar(em(dia, 17, minuto), perto=False) # 17h: casa vazia
self.a.observar(em(dia, 18, minuto), perto=True) # 18h: chegou gente
self.a.observar(em(9, 0, 1), perto=False)
self.assertGreaterEqual(self.a.rotina[18], 0.5)
self.assertLess(self.a.rotina[17], 0.3)
self.assertTrue(any('costumam estar por perto das 18h às 19h' in t for _, t in self.diario), self.diario)
self.assertTrue(self.a.chega_logo(em(9, 17, 50)))
self.assertFalse(self.a.chega_logo(em(9, 17, 10))) # ainda falta muito
self.assertFalse(self.a.chega_logo(em(9, 12, 50)))
def test_acordar_a_toa_pede_mais_movimento_naquela_hora(self):
for dia in (6, 7, 8, 9):
for vez in range(2):
t = em(dia, 22, vez * 20)
self.a.acordou_pelo_sensor(t)
for s in range(0, 200, 5):
self.a.observar(t + s, perto=s < 10) # mexeu e sumiu: foi à toa
self.a.observar(em(dia + 1, 0, 1), perto=False)
self.assertGreaterEqual(self.a.extra_pir(em(10, 22, 30)), 1)
self.assertEqual(self.a.extra_pir(em(10, 15)), 0)
self.assertTrue(any('Acordei à toa 2 vezes por volta das 22h' in t for _, t in self.diario), self.diario)
def test_acordar_com_carinho_valeu_a_pena(self):
t = em(6, 22)
self.a.acordou_pelo_sensor(t)
self.a.observar(t + 5, perto=True)
self.a.resposta('carinho', t + 30)
for s in range(35, 200, 5):
self.a.observar(t + s, perto=False)
self.assertEqual(self.a.hoje['falsos'][22], 0)
def test_desligado_nao_aprende_nem_muda_nada(self):
self.a.configurar(dict(ligado=False))
self.fazer('rir', 6, 'carinho')
self.assertEqual(self.a.peso('rir'), 1.0)
self.assertEqual(self.a.extra_pir(em(6, 22)), 0)
self.assertFalse(self.a.chega_logo(em(6, 17, 50)))
def test_esquecer_e_persistencia(self):
self.fazer('rir', 5, 'carinho')
self.fazer('bocejar', 5, inicio=em(6, 17)) # o gosto é relativo: precisa de algo menos querido
self.a.salvar()
outro = Aprendizado(self.caminho)
self.assertGreater(outro.peso('rir'), 1.0)
outro.anotar = lambda *a, **k: None
outro.esquecer()
self.assertEqual(outro.peso('rir'), 1.0)
self.assertEqual(Aprendizado(self.caminho).itens, {})
def test_validar(self):
with self.assertRaises(ValueError):
self.a.configurar(dict(ligado='sim'))
with self.assertRaises(ValueError):
self.a.configurar(dict(outro=True))
class VidaComAprendizadoTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'aprendizado.json')
self.a.hoje = self.a._dia_vazio('2026-10-06')
self.v = Vida(Path(self.tmp.name) / 'vida.json', iniciar=False, rng=random.Random(3))
self.v.aprendizado = self.a
self.ordens = []
self.v.enfileirar = self.ordens.append
self.v.ultimo_tick = em(6, 15)
def tearDown(self):
self.tmp.cleanup()
def test_gesto_que_agrada_sai_mais(self):
for i in range(8):
self.a.ato('rir', em(6, 15) + i * 100, perto=True)
self.a.resposta('carinho', em(6, 15) + i * 100 + 5)
self.a.ato('feliz', em(6, 15) + i * 100 + 50, perto=True)
opcoes = [('rir', 1, ''), ('feliz', 1, '')]
saiu = [self.v.escolher(opcoes)[0] for _ in range(400)]
self.assertGreater(saiu.count('rir'), saiu.count('feliz') * 2)
def test_carinho_e_atos_com_plateia_chegam_ao_aprendizado(self):
self.v.alguem_perto = lambda: True
self.v.celular_online = lambda: True
self.v.proximo_ato = 0
self.v.necessidades.update(energia=90, social=90, diversao=90, alegria=90)
ato = None
for i in range(30):
ato = self.v.tick(em(6, 15) + i * 200)
if ato in self.a.itens:
break
self.assertIn(ato, self.a.itens)
self.v.estimulo('carinho', em(6, 15) + i * 200 + 10)
self.assertGreater(self.a.itens[ato]['s'], 0)
def test_pir_pede_mais_quando_acordou_a_toa_naquela_hora(self):
self.a.falsos[22] = 1.0
self.assertFalse(self.v.pir_acorda(em(6, 22)))
self.assertTrue(self.v.pir_acorda(em(6, 22) + 10))
self.assertEqual(self.a.acordou_em, em(6, 22) + 10)
self.assertTrue(self.v.pir_acorda(em(6, 15)))
def test_acorda_do_cochilo_antes_de_chegarem(self):
self.a.rotina[18], self.a.rotina[17] = 0.8, 0.1
self.v.dormindo_proprio = True
self.v.necessidades['energia'] = 70
self.assertEqual(self.v.agir(em(6, 17, 50)), 'acordar')
self.assertIn('acordar', self.ordens)
def test_sono_profundo_nao_e_interrompido_pela_rotina(self):
self.a.rotina[18], self.a.rotina[17] = 0.8, 0.1
self.v.dormindo_proprio = True
self.v.sono_profundo = True
self.v.necessidades['energia'] = 70
self.assertIsNone(self.v.agir(em(6, 17, 50)))
def semana_tipica(a, semanas=2):
"""Segunda, sexta e fim de semana em casa (7h-23h); terça a quinta fora das 8h às 18h. Começa em 05/10 (segunda)."""
inicio = date(2026, 10, 5)
for i in range(semanas * 7):
d = inicio + timedelta(days=i)
a.hoje = a._dia_vazio(d.isoformat())
for h in range(24):
for m in range(0, 60, 2):
fora = d.weekday() in (1, 2, 3) and 8 <= h < 18
perto = 7 <= h < 23 and not fora and m < 40
a.minuto = None
a.observar(time.mktime((d.year, d.month, d.day, h, m, 0, 0, 0, -1)), perto)
fim = d + timedelta(days=1)
a.observar(time.mktime((fim.year, fim.month, fim.day, 0, 0, 30, 0, 0, -1)), False)
def plano_certo():
dias = []
for dia in range(7):
if dia in (1, 2, 3):
periodos = [dict(ini=0, fim=7, tipo='dormindo'), dict(ini=7, fim=8, tipo='casa'),
dict(ini=8, fim=18, tipo='fora'), dict(ini=18, fim=23, tipo='casa'), dict(ini=23, fim=24, tipo='dormindo')]
else:
periodos = [dict(ini=0, fim=7, tipo='dormindo'), dict(ini=7, fim=23, tipo='casa'), dict(ini=23, fim=24, tipo='dormindo')]
dias.append(dict(dia=dia, periodos=periodos))
return dict(padroes=['Percebi que de terça a quinta a casa fica vazia das 8h às 18h'], dias=dias)
class SemanaTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.diario = []
self.a.anotar = lambda tipo, texto, agora=None: self.diario.append(texto)
self.pedidos = []
self.resposta = plano_certo()
def ia(provedor, prompt, schema):
self.pedidos.append((provedor, prompt))
return self.resposta
self.a.ia, self.a.pode_ia = ia, lambda provedor: True
self.a.config['analise_ia'] = False # nos testes a análise é chamada direto
def tearDown(self):
self.tmp.cleanup()
def test_quadro_da_semana_separa_os_dias(self):
semana_tipica(self.a)
self.assertEqual(len(self.a.historico), 14)
self.assertLess(self.a.semana[2][10], 0.3) # quarta 10h: fora
self.assertGreater(self.a.semana[0][10], 0.5) # segunda 10h: em casa
self.assertGreater(self.a.semana[4][10], 0.5) # sexta 10h: em casa
self.assertEqual(self.a.esperado(em(14, 10)), 'fora') # 14/10 é quarta
self.assertEqual(self.a.esperado(em(12, 10)), 'casa') # 12/10 é segunda
self.assertTrue(self.a.chega_logo(em(14, 17, 50))) # quarta: chegam às 18h
self.assertFalse(self.a.chega_logo(em(12, 17, 50))) # segunda: já tem gente em casa
self.assertTrue(self.a.casa_vazia(em(14, 10)))
def test_plano_da_ia_que_bate_com_os_dados_e_usado(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
self.assertIn('quarta', self.pedidos[0][1])
self.assertIn('sexta (2 dias medidos)', self.pedidos[0][1])
self.assertEqual(self.a.plano['origem'], 'ia')
self.assertGreaterEqual(self.a.conferir(self.a.plano), 0.7)
self.assertIsNone(self.a.sugestao)
self.assertEqual(self.a.esperado(em(14, 10)), 'fora')
self.assertEqual(self.a.esperado(em(14, 2)), 'dormindo')
self.assertTrue(any('terça a quinta' in t for t in self.diario))
def test_plano_da_ia_que_nao_bate_vira_so_sugestao(self):
semana_tipica(self.a)
errado = plano_certo()
for d in errado['dias']:
d['periodos'] = [dict(ini=0, fim=24, tipo='fora')] # "a casa vive vazia": falso
self.resposta = errado
self.a.analisar(em(19, 1))
self.assertIsNone(self.a.plano)
self.assertIsNotNone(self.a.sugestao)
self.assertLess(self.a.conferir(self.a.sugestao), 0.7)
self.assertTrue(any(t.startswith('Acho que') for t in self.diario))
self.a.usar_sugestao()
self.assertEqual(self.a.plano['origem'], 'voce')
def test_rotina_contada_vale_na_hora_e_a_ia_nao_atropela(self):
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
self.assertIn('Fico fora de terça a quinta', self.pedidos[0][1])
self.assertEqual(self.a.plano['origem'], 'voce')
self.assertIsNone(self.a.conferir(self.a.plano)) # ainda sem dados para conferir
semana_tipica(self.a)
self.assertGreaterEqual(self.a.conferir(self.a.plano), 0.7)
self.a.analisar(em(19, 1))
self.assertEqual(self.a.plano['origem'], 'voce') # o que a pessoa disse continua valendo
self.assertIsNotNone(self.a.sugestao)
def test_validar_plano_conserta_o_que_a_ia_erra(self):
p = Aprendizado.validar_plano(dict(padroes=['ok'] * 9, dias=[
dict(dia=0, periodos=[dict(ini=8, fim=20, tipo='casa'), dict(ini=18, fim=30, tipo='fora'),
dict(ini=5, fim=5, tipo='casa'), dict(ini=1, fim=3, tipo='festa')]),
dict(dia=9, periodos=[dict(ini=0, fim=24, tipo='casa')]), 'lixo']), 'ia')
self.assertEqual(p['dias'][0], [dict(ini=8, fim=18, tipo='casa'), dict(ini=18, fim=24, tipo='fora')])
self.assertEqual(len(p['padroes']), 5)
with self.assertRaises(ValueError):
Aprendizado.validar_plano(dict(padroes=[], dias=[]), 'ia')
def test_analise_roda_sozinha_a_meia_noite_com_uma_semana(self):
self.a.config['analise_ia'] = True
semana_tipica(self.a, semanas=1)
for _ in range(50):
if not self.a.analise['ocupado'] and (self.a.plano or self.a.sugestao):
break
time.sleep(0.05)
self.assertEqual(len(self.pedidos), 1) # só na 7ª meia-noite
self.assertIsNotNone(self.a.plano or self.a.sugestao)
def test_persistencia_da_semana_e_do_plano(self):
semana_tipica(self.a, semanas=1)
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
self.a.salvar()
outro = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.assertEqual(outro.semana, self.a.semana)
self.assertEqual(outro.plano['dias'], self.a.plano['dias'])
self.assertEqual(len(outro.historico), 7)
def test_casa_vazia_faz_ele_cochilar_para_guardar_pique(self):
v = Vida(Path(self.tmp.name) / 'v.json', iniciar=False, rng=random.Random(5))
v.aprendizado = self.a
v.enfileirar = lambda ordem: None
v.celular_online = lambda: True
v.necessidades.update(energia=70, diversao=80, social=80, alegria=80)
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
v.visto_em = em(14, 9)
atos = [v.agir(em(14, 10) + i * 120) for i in range(10)]
self.assertIn('atividade dormir', atos)
cochilo = next(d for d in self.a.decisoes if d['tipo'] == 'cochilo')
self.assertEqual(cochilo['motivo'], 'às 10h de quarta a casa costuma ficar vazia, pelo plano que você contou')
class DiaADiaTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.a.ia, self.a.pode_ia = (lambda provedor, prompt, schema: plano_certo()), (lambda provedor: True)
self.a.config['analise_ia'] = False
self.v = Vida(Path(self.tmp.name) / 'v.json', iniciar=False, rng=random.Random(2))
self.v.aprendizado = self.a
self.v.enfileirar = lambda ordem: None
def tearDown(self):
self.tmp.cleanup()
def test_cada_decisao_guarda_o_porque(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
self.assertEqual(self.a.decisoes[0]['tipo'], 'ia')
self.assertIn('plano adotado: confere', self.a.decisoes[0]['motivo'])
# acordar antes da chegada (quarta, 17h50)
self.v.dormindo_proprio = True
self.v.necessidades['energia'] = 70
self.assertEqual(self.v.agir(em(14, 17, 50)), 'acordar')
self.assertEqual(self.a.decisoes[0]['motivo'], 'às 18h de quarta costuma ter gente, pelo plano da IA')
# sensor segurado numa hora em que acordou à toa
self.a.falsos[22] = 1.0
self.v.pir_acorda(em(14, 22))
self.v.pir_acorda(em(14, 22) + 30) # a 2ª nota em menos de 10 min não repete
notas = [d for d in self.a.decisoes if d['tipo'] == 'sono']
self.assertEqual(len(notas), 1)
self.assertIn('pede 1 movimento a mais', notas[0]['motivo'])
def test_fechar_o_dia_resume_e_confere_com_o_plano(self):
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
semana_tipica(self.a, semanas=1)
fechou = [d for d in self.a.decisoes if d['tipo'] == 'dia']
self.assertEqual(len(fechou), 7)
quarta = next(d for d in fechou if 'quarta' in d['texto'])
self.assertIn('conferiu 100% com o plano', quarta['motivo'])
def test_dia_a_dia_de_um_dia_passado_e_de_hoje(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
d = self.a.dia_a_dia('2026-10-14')
self.assertEqual(d['nome_dia'], 'quarta')
self.assertFalse(d['hoje'])
self.assertLess(d['medido'][10], 0.3)
self.assertEqual(d['plano'][2], dict(ini=8, fim=18, tipo='fora'))
self.assertEqual(d['confere'], 1.0)
self.assertTrue(any(x['tipo'] == 'dia' for x in d['decisoes']))
self.assertIn('2026-10-05', d['dias'])
hoje = self.a.dia_a_dia()
self.assertTrue(hoje['hoje'])
with self.assertRaises(ValueError):
self.a.dia_a_dia('ontem')
class RotasTest(unittest.TestCase):
def test_status_config_e_esquecer(self):
with tempfile.TemporaryDirectory() as tmp:
kiosk.ARQUIVO_APARENCIA = str(Path(tmp) / 'a.json')
s = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
s.aprendizado = Aprendizado(Path(tmp) / 'ap.json')
kiosk.ligar_eventos(s)
threading.Thread(target=s.serve_forever, daemon=True).start()
try:
def pedir(metodo, rota, corpo=None):
c = http.client.HTTPConnection('127.0.0.1', s.server_port, timeout=5)
dados = json.dumps(corpo).encode() if corpo is not None else None
c.request(metodo, rota, body=dados, headers={'Content-Type': 'application/json'} if dados else {})
r = c.getresponse()
return r.status, json.loads(r.read())
cod, st = pedir('GET', '/aprendizado/status')
self.assertEqual(cod, 200)
self.assertEqual(len(st['rotina']), 24)
cod, st = pedir('POST', '/aprendizado/config', dict(ligado=False))
self.assertEqual((cod, st['config']['ligado']), (200, False))
cod, st = pedir('POST', '/aprendizado/config', dict(ligado='x'))
self.assertEqual(cod, 400)
cod, st = pedir('POST', '/aprendizado/esquecer', {})
self.assertEqual((cod, st['dias']), (200, 0))
cod, est = pedir('GET', '/estado')
self.assertIn('aprendizado', est)
finally:
s.shutdown()
kiosk.EVENTOS.fechar()
if __name__ == '__main__':
unittest.main()