Files
DemonGatanjieu-Anomalous_Mo…/tests/test_activity_log.py
T
DemonGatanjieuandClaude Opus 5.5 99576e60bd Fix picker jumping to the top, combo saved per tag, unreadable long-text activity
- Current node's model picker redrew the whole grid to mark the chosen card, so
  choosing one further down scrolled back to the top. The cards are repainted in
  place (paintCard) instead.
- The classic combo editor saved when its bilingual tags were shown and again for
  each tag's translation as it arrived: a long prompt wrote the combo dozens of
  times in a second (34 note_save entries in one user's log). Rendering no longer
  saves and the translations share one save half a second after the last. Its
  prompt placeholder moves into locales.js (no inline language branch).
- Activity values are capped at 400 characters from the start, so an edit late in
  a long prompt logged identical before and after. A long before/after pair is now
  cut around its first difference (test included).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:36:15 +08:00

102 lines
4.9 KiB
Python

import asyncio
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import activity_log # noqa: E402
class ActivityLogTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.path = str(Path(self.directory.name, 'activity_log.json'))
self.patch = mock.patch.object(activity_log, 'log_path', return_value=self.path)
self.patch.start()
activity_log._entries = None
def tearDown(self):
self.patch.stop()
activity_log._entries = None
self.directory.cleanup()
def test_entries_are_newest_first_bounded_and_saved(self):
with mock.patch.object(activity_log, 'MAX_ENTRIES', 3):
for index in range(5):
activity_log.add_entry('file', 'recipe_save', f'r{index}')
self.assertEqual([e['target'] for e in activity_log.list_entries()], ['r4', 'r3', 'r2'])
activity_log._entries = None # read back from disk
self.assertEqual([e['target'] for e in activity_log.list_entries()], ['r4', 'r3', 'r2'])
self.assertEqual([e['target'] for e in activity_log.list_entries(source='canvas')], [])
activity_log.clear()
self.assertEqual(json.loads(Path(self.path).read_text()), [])
def test_log_stays_under_its_byte_limit(self):
with mock.patch.object(activity_log, 'MAX_LOG_BYTES', 20_000):
for index in range(200):
activity_log.add_entry('file', 'note_save', f'{index}-' + 'x' * 300)
self.assertLessEqual(Path(self.path).stat().st_size, 20_000)
self.assertTrue(activity_log.list_entries()[0]['target'].startswith('199-'))
def test_long_values_are_shortened(self):
entry = activity_log.add_entry('file', 'note_save', 'x' * 1000)
self.assertLessEqual(len(entry['target']), activity_log.MAX_VALUE_CHARS + 1)
def test_a_change_late_in_a_long_text_still_shows(self):
before = ', '.join(f'tag{i}' for i in range(200)) # far longer than MAX_VALUE_CHARS
after = before.replace('tag180', 'tag180, extra')
item = activity_log._clean_changes([{'kind': 'changed', 'node': 'n', 'widget': 'text',
'before': before, 'after': after}])[0]
self.assertNotEqual(item['before'], item['after'])
self.assertIn('extra', item['after'])
self.assertTrue(item['before'].startswith('…'))
self.assertLessEqual(len(item['after']), activity_log.MAX_VALUE_CHARS + 2)
def test_failure_responses_are_not_recorded(self):
ok = web.json_response({'success': True})
self.assertFalse(activity_log._failed(ok))
for bad in (web.json_response({'status': 'error'}), web.json_response({'success': False}),
web.json_response({'ok': True}, status=400)):
self.assertTrue(activity_log._failed(bad))
def test_middleware_records_successful_writes_only(self):
async def run(path, status, payload):
request = make_mocked_request('POST', path, headers={'Content-Type': 'application/json'})
request._read_bytes = json.dumps(payload).encode()
async def handler(_request):
await _request.json()
return web.json_response({'success': status < 300}, status=status)
await activity_log.activity_middleware(request, handler)
asyncio.run(run('/anomalous/delete_recipe', 200, {'filename': 'folder/My Recipe.json'}))
asyncio.run(run('/anomalous/delete_recipe', 500, {'filename': 'broken.json'}))
asyncio.run(run('/anomalous/folders', 200, {'name': 'not a write route'}))
entries = activity_log.list_entries()
self.assertEqual([(e['action'], e['target']) for e in entries], [('recipe_delete', 'My Recipe.json')])
def test_canvas_post_keeps_known_change_kinds(self):
async def post(body):
request = make_mocked_request('POST', '/anomalous/activity', headers={'Content-Type': 'application/json'})
request._read_bytes = json.dumps(body).encode()
return await activity_log.api_post_activity(request)
bad = asyncio.run(post({'changes': [{'kind': 'script', 'node': 'x'}]}))
self.assertEqual(bad.status, 400)
good = asyncio.run(post({'changes': [{'kind': 'changed', 'node': 'KSampler #3', 'widget': 'steps',
'before': '20', 'after': '30'}], 'total': 4, 'workflow': 'a.json'}))
self.assertEqual(good.status, 200)
entry = activity_log.list_entries()[0]
self.assertEqual((entry['source'], entry['target'], entry['detail']['total']), ('canvas', 'a.json', 4))
if __name__ == '__main__':
unittest.main()