import copy import json import tempfile from pathlib import Path import unittest from unittest.mock import patch from engine import memory from engine.memory_controls import change_memory, set_relationship_options, context_messages, fingerprint from engine.relationship import (get_default_relationship, apply_relationship_changes, relationship_values, relationship_descriptions, relationship_to_prompt) from engine.saveload import export_save, import_save, restore_save, validate_save from engine.chat import generate_response from test_conversations import ConversationFixture, Client, A, B, CHAR class RelationshipMemoryTests(ConversationFixture, unittest.TestCase): def relationship_client(self, **kwargs): return Client(summary={'summary': 'A meaningful encounter.', 'new_memories': [], 'relationship_changes': [{'axis': axis, 'delta': delta, 'reason': 'A concrete event in the scene.'} for axis, delta in kwargs.items()]}) def test_optional_axes_independent_of_low_trust_and_familiarity(self): state = get_default_relationship() state['enabled'] = {'romance': True, 'attraction': True} state = apply_relationship_changes(state, [ {'axis': 'trust', 'delta': -8, 'reason': 'A lie.'}, {'axis': 'romance', 'delta': 5, 'reason': 'A romantic gesture.'}, {'axis': 'attraction', 'delta': 8, 'reason': 'An attraction.'}, ], 0, 5) self.assertEqual({'trust': -8, 'familiarity': 0, 'romance': 5, 'attraction': 8}, relationship_values(state)) state = apply_relationship_changes(state, [{'axis': 'romance', 'delta': -3, 'reason': 'A disagreement.'}], 5, 10) self.assertEqual(2, relationship_values(state)['romance']) self.assertEqual(8, relationship_values(state)['attraction']) def test_off_axes_are_absent_and_dont_receive_events(self): state = get_default_relationship() changed = apply_relationship_changes(state, [{'axis': 'attraction', 'delta': 10, 'reason': 'An event.'}], 0, 5) self.assertEqual(state, changed) self.assertIsNone(relationship_values(state)['attraction']) self.assertNotIn('Sexual attraction:', relationship_to_prompt(state)) self.assertEqual(2, len(relationship_descriptions(state))) def test_labels_show_no_numeric_values(self): state = get_default_relationship() state['enabled'] = {'romance': True, 'attraction': True} for language in ('English', 'Turkish'): labels = relationship_descriptions(state, language) self.assertEqual(4, len(labels)) self.assertFalse(any(char.isdigit() for title, text in labels for char in title + text)) def test_relationship_summary_uses_one_call_with_character_and_persona(self): state = self.populated() state = set_relationship_options(A, CHAR, True, True) client = self.relationship_client(trust=-3, romance=4, attraction=7) self.assertTrue(memory.update_memory(A, CHAR, state, client, 'test', persona={'name': 'Deniz'})) self.assertEqual(1, len(client.calls)) payload = json.loads(client.calls[0][0][1]['content']) self.assertEqual('Deniz', payload['user_persona']['name']) self.assertIn('Hermione', payload['character']['name']) self.assertEqual({'trust': -3, 'familiarity': 0, 'romance': 4, 'attraction': 7}, relationship_values(memory.load_conversation(A, CHAR)['relationship'])) def test_switching_options_off_clears_only_that_axis_and_invalidates_jobs(self): self.populated() original = set_relationship_options(A, CHAR, True, True) memory.update_memory(A, CHAR, original, self.relationship_client(romance=7, attraction=5, trust=4), 'test') snapshot = memory.load_conversation(A, CHAR) changed = set_relationship_options(A, CHAR, False, True) self.assertIsNone(relationship_values(changed['relationship'])['romance']) self.assertEqual(5, relationship_values(changed['relationship'])['attraction']) self.assertGreater(changed['revision'], snapshot['revision']) restored = set_relationship_options(A, CHAR, True, True) self.assertEqual(0, relationship_values(restored['relationship'])['romance']) def test_edit_and_retry_roll_back_derived_events_without_double_count(self): first = self.populated(turns=50) client = self.relationship_client(trust=7) memory.update_memory(A, CHAR, first, client, 'test') second = memory.load_conversation(A, CHAR) memory.update_memory(A, CHAR, second, client, 'test') state = memory.load_conversation(A, CHAR) self.assertEqual(14, relationship_values(state['relationship'])['trust']) cutoff = state['relationship']['events'][0]['end'] prefix = memory.sync_history(A, CHAR, state['recent_messages'][:cutoff + 1]) self.assertEqual(7, relationship_values(prefix['relationship'])['trust']) # Replaying the unchanged first batch must not count it twice. replayed = apply_relationship_changes(prefix['relationship'], [{'axis': 'trust', 'delta': 7, 'reason': 'Same event'}], 0, cutoff) self.assertEqual(7, relationship_values(replayed)['trust']) def test_manual_memories_survive_summary_and_overrule_same_auto_fact(self): state = self.populated() state = change_memory(A, CHAR, 'remember', 'The user is Alex.') identifier = state['long_term_memory'][0]['id'] memory.update_memory(A, CHAR, state, Client(), 'test') facts = memory.load_conversation(A, CHAR)['long_term_memory'] self.assertEqual(1, len(facts)) self.assertEqual(identifier, facts[0]['id']) self.assertEqual('manual', facts[0]['origin']) def test_forget_excludes_source_and_prevents_exact_fact_reintroduction(self): state = self.populated() memory.update_memory(A, CHAR, state, Client(), 'test') state = memory.load_conversation(A, CHAR) fact = state['long_term_memory'][0] state = change_memory(A, CHAR, 'forget', memory_id=fact['id']) self.assertEqual('', state['summary']) self.assertEqual(0, state['summarized_count']) self.assertEqual([], state['long_term_memory']) self.assertIn(fingerprint(fact['content']), state['forgotten_facts']) self.assertNotEqual(state['recent_messages'][0]['content'], context_messages(state)[0]['content']) memory.update_memory(A, CHAR, state, Client(), 'test') self.assertEqual([], memory.load_conversation(A, CHAR)['long_term_memory']) def test_forget_uses_suppressed_context_for_chat(self): state = self.populated() state = change_memory(A, CHAR, 'remember', 'Question 0') state = change_memory(A, CHAR, 'forget', memory_id=state['long_term_memory'][0]['id']) client = Client() with patch('engine.chat.schedule_memory_update'): list(generate_response('Next', CHAR, client, A, state['recent_messages'])) prompt = client.calls[0][0] self.assertNotIn('Question 0', json.dumps(prompt)) self.assertEqual('Question 0', memory.load_conversation(A, CHAR)['recent_messages'][1]['content']) def test_manual_edit_pins_replacement_and_survives_undo(self): state = self.populated() memory.update_memory(A, CHAR, state, Client(), 'test') state = memory.load_conversation(A, CHAR) identifier = state['long_term_memory'][0]['id'] state = change_memory(A, CHAR, 'edit', 'The user is Deniz.', identifier) result = memory.sync_history(A, CHAR, state['recent_messages'][:2]) self.assertEqual('The user is Deniz.', result['long_term_memory'][0]['content']) self.assertEqual('manual', result['long_term_memory'][0]['origin']) cleared = memory.sync_history(A, CHAR, []) self.assertEqual([], cleared['long_term_memory']) self.assertEqual([], cleared['forgotten_facts']) self.assertEqual(get_default_relationship(), cleared['relationship']) def test_memory_mutation_rejects_pending_extraction(self): state = self.populated() changed = change_memory(A, CHAR, 'remember', 'A personal fact.') self.assertFalse(memory.update_memory(A, CHAR, state, self.relationship_client(trust=6), 'test')) self.assertEqual(changed, memory.load_conversation(A, CHAR)) def test_invalid_relationship_does_not_block_valid_summary(self): state = self.populated() client = self.relationship_client(trust=1000) self.assertTrue(memory.update_memory(A, CHAR, state, client, 'test')) updated = memory.load_conversation(A, CHAR) self.assertEqual(state['relationship'], updated['relationship']) self.assertEqual(state['recent_messages'], updated['recent_messages']) self.assertGreater(updated['summarized_count'], 0) def test_new_save_roundtrip_restores_all_controls(self): self.populated() state = set_relationship_options(A, CHAR, True, True) memory.update_memory(A, CHAR, state, self.relationship_client(attraction=6), 'test') state = change_memory(A, CHAR, 'remember', 'Remember this.') state = change_memory(A, CHAR, 'remember', 'Question 0') state = change_memory(A, CHAR, 'forget', memory_id=state['long_term_memory'][-1]['id']) path = export_save(CHAR, {}, A) self.addCleanup(lambda: Path(path).unlink(missing_ok=True)) data = import_save(path) self.assertEqual('3.0', data['version']) restore_save(data, B) restored = memory.load_conversation(B, CHAR) for key in ('relationship', 'long_term_memory', 'excluded_sources', 'forgotten_facts'): self.assertEqual(state[key], restored[key]) def test_invalid_new_save_metadata_rejected_before_write(self): state = self.populated() for key, value in [('excluded_sources', [[0, 100000]]), ('forgotten_facts', ['bad']), ('relationship', {'enabled': {'romance': 'yes', 'attraction': False}, 'events': []})]: data = {'version': '3.0', 'character_id': CHAR, 'conversation': copy.deepcopy(state)} data['conversation'][key] = value with self.subTest(key=key), self.assertRaises(ValueError): restore_save(data, A) self.assertEqual(state, memory.load_conversation(A, CHAR)) def test_old_save_migrates_with_options_off(self): state = self.populated() for version in ('1.0', '2.0'): data = validate_save({'version': version, 'character_id': CHAR, 'conversation': state}) self.assertEqual(get_default_relationship(), data['conversation']['relationship'])