Dotfiles_ArchAP/tests/session_v2/test_scratchpad.py

204 lines
11 KiB
Python

"""Scratchpad contracts against production capture/store/journal; inert IPC."""
import json
import tempfile
import unittest
from unittest.mock import patch
from common import *
from test_prewarm import Sway
from session_v2.snapshot import capture
from session_v2.checkpoint import Store
from session_v2.storage import read, private_dir
from session_v2.ipc import walk, workspace
from session_v2.errors import Failure
from session_v2.recovery import Recovery, JOURNAL, observe, command_focus, validate as validate_recovery
from session_v2.slots import assign
from session_v2.layout import compile_plan, compile_placements
def source():
sway = Sway()
for ws in sway.value['nodes'][0]['nodes']:
ws['layout'] = 'splith'; ws['nodes'] = []
sway.add('kitty', 7, focus=True)
sway.add('kitty', 8, focus=False)
sway.add('limusic-app', 9, focus=False)
root = sway.value
hidden = dict(id=80, type='workspace', name='__i3_scratch', layout='splith', nodes=[], floating_nodes=[], focused=False)
root['nodes'].insert(0, dict(id=81, type='output', name='__i3', nodes=[hidden]))
ws = workspace(root, '1')
n = next(n for n in ws['nodes'] if n['id'] == 8)
ws['nodes'].remove(n)
n.update(scratchpad_state='fresh', floating='user_on', rect=dict(x=301,y=106,width=800,height=500))
hidden['floating_nodes'].append(n)
return sway
class ScratchpadTests(unittest.TestCase):
def test_capture_store_reload_and_no_internal_workspace(self):
sway = source()
d = capture(sway, Catalog(), None)
self.assertEqual(d['version'], 2)
self.assertEqual(len(d['window_slots']), 3)
hidden = next(s for s in d['window_slots'] if s['state']['scratchpad'] == 'hidden')
self.assertIsNone(hidden['workspace'])
self.assertEqual(hidden['state']['geometry'], dict(x=301,y=106,width=800,height=500))
self.assertEqual(hidden['identity_requirement'], 'interchangeable')
self.assertEqual(d['focus'], next(s['slot_id'] for s in d['window_slots'] if s['application_id']=='kitty' and s['workspace']=='1'))
self.assertNotIn('__i3_scratch', [o['name'] for o in d['outputs']])
self.assertTrue(all(op.workspace != '__i3_scratch' for op in compile_plan(d,'a'*32)))
self.assertEqual(compile_placements(d)[0]['scratchpad'], 'hidden')
self.assertIsNone(compile_placements(d)[0]['workspace'])
with tempfile.TemporaryDirectory() as tmp:
store = Store(Path(tmp)); store.save('test',d); store.checkpoint(d)
self.assertEqual(store.last(),d); self.assertEqual(store.load('test'),d)
old = fixture(); store.save('old',old)
self.assertEqual(store.load('old'),old)
def test_capture_is_deterministic_for_a_stable_tree(self):
sway=source()
with patch('session_v2.snapshot.time.time_ns',return_value=123):
first=capture(sway,Catalog(),None)
self.assertEqual(first,capture(sway,Catalog(),None))
raw=json.dumps(first)
self.assertNotIn('con_id',raw);self.assertNotIn('starttime',raw)
self.assertNotIn('__i3_scratch',raw)
def test_shown_and_multiple_hidden(self):
sway=source(); hidden=workspace(sway.value,'__i3_scratch'); ws=workspace(sway.value,'1')
shown=hidden['floating_nodes'].pop(); ws.setdefault('floating_nodes',[]).append(shown)
for n in walk(sway.value): n['focused']=n['id']==8
d=capture(sway,Catalog(),None)
slot=next(s for s in d['window_slots'] if s['state']['scratchpad']=='shown')
self.assertEqual(slot['workspace'],'1'); self.assertEqual(d['focus'],slot['slot_id'])
for n in list(ws['nodes']):
n.update(floating='user_on',scratchpad_state='fresh',focused=False,rect=dict(x=0,y=0,width=100,height=100))
hidden['floating_nodes'].append(n)
ws['nodes']=[]
d=capture(sway,Catalog(),None)
self.assertEqual(sum(s['state']['scratchpad']=='hidden' for s in d['window_slots']),2)
def test_change_between_reads_and_ambiguous_focus_refuse(self):
sway=source(); original=sway.windows
def changed():
result=original(); next(n for n in walk(sway.value) if n['id']==8)['scratchpad_state']='none'
return result
sway.windows=changed
with self.assertRaisesRegex(Failure,'SNAPSHOT_CHANGED'):capture(sway,Catalog(),None)
sway=source(); next(n for n in walk(sway.value) if n['id']==8)['focused']=True
with self.assertRaisesRegex(Failure,'SNAPSHOT_FOCUS_AMBIGUOUS'):capture(sway,Catalog(),None)
def test_strict_versions_and_scratchpad_references(self):
d=capture(source(),Catalog(),None)
hidden=next(i for i,s in enumerate(d['window_slots']) if s['workspace'] is None)
mutations=[lambda v:v.update(version=1), lambda v:v['window_slots'][hidden].update(workspace='__i3_scratch'),
lambda v:v.update(focus=v['window_slots'][hidden]['slot_id']),
lambda v:v['window_slots'][hidden]['state'].update(scratchpad=True),
lambda v:v['window_slots'][hidden]['state'].update(floating=False)]
for change in mutations:
v=copy.deepcopy(d);change(v)
with self.assertRaises(Failure):validate(v)
try: import jsonschema
except ImportError: self.skipTest('jsonschema unavailable')
contract=json.loads((BACKEND/'session_v2/schema-v2.json').read_text())
jsonschema.Draft202012Validator(contract).validate(d)
def test_runtime_visibility_assignment_never_exposes_hidden_peer(self):
sway=source(); d=capture(sway,Catalog(),None)
slots=[s for s in d['window_slots'] if s['application_id']=='kitty']
nodes=[n for n in sway.windows() if n['app_id']=='kitty']
states={n['id']:observe(sway.tree(),n,lambda pid:123) for n in nodes}
m=assign(slots,nodes,states=states)
hidden=next(s for s in slots if s['workspace'] is None)
self.assertEqual(m[hidden['slot_id']],8)
for s in slots:s['state']['scratchpad']='none'
with self.assertRaisesRegex(Failure,'SCRATCHPAD_ASSIGNMENT_UNPROVEN'):assign(slots,nodes,states=states)
def test_journal_detects_identity_output_scratchpad_and_interruption(self):
for change in ('identity','output','scratchpad','pending'):
with self.subTest(change=change), tempfile.TemporaryDirectory() as tmp:
sway=source(); r=Recovery(sway,tmp,'a'*32,'labfy-v2-stage-'+'a'*32,start=lambda pid:123)
for n in sway.windows():r.admit(n)
r.baseline({'kitty','limusic-app'})
if change=='identity': next(n for n in walk(sway.value) if n['id']==8)['pid']=999
elif change=='output':sway.value['nodes'][0]['name']='moved'
elif change=='scratchpad': next(n for n in walk(sway.value) if n['id']==8)['scratchpad_state']='none'
else:r.data['windows'][0]['pending']=True;r.data['focus_pending']=True;r.publish()
receipt=r.recover()
self.assertEqual(receipt['status'],'RECOVERY_REQUIRED');self.assertEqual(sway.commands,[])
durable=read(Path(tmp)/JOURNAL,validate_recovery)
if change=='pending':self.assertTrue(durable['focus_pending'])
def test_hidden_focus_is_rejected_before_command(self):
sway=source()
with self.assertRaisesRegex(Failure,'FOCUS_HIDDEN_SCRATCHPAD'):command_focus(sway.tree(),8,'focus')
def test_initial_scratchpad_add_predicts_source_workspace_focus(self):
sway=source(); ws=workspace(sway.value,'__i3_scratch')
n=ws['floating_nodes'].pop(); n['scratchpad_state']='none'
workspace(sway.value,'10')['floating_nodes']=[n]
# Actual root_scratchpad_add_container semantics: even an unfocused
# floating leaf causes source-workspace selection. Not focus-neutral.
self.assertEqual(command_focus(sway.tree(),8,'move scratchpad'),dict(con_id=None,workspace='10'))
self.assertEqual(command_focus(source().tree(),8,'scratchpad show'),dict(con_id=8,workspace='1'))
def test_group_ancestor_is_not_admitted_by_leaf_identity(self):
sway=source(); ws=workspace(sway.value,'__i3_scratch')
n=ws['floating_nodes'].pop(); n['scratchpad_state']='none'
ws['floating_nodes']=[dict(id=99,type='con',scratchpad_state='fresh',nodes=[n])]
with self.assertRaisesRegex(Failure,'SCRATCHPAD_CONTAINER_UNSUPPORTED'):
observe(sway.tree(),n,lambda pid:123)
with self.assertRaisesRegex(Failure,'SCRATCHPAD_CONTAINER_UNSUPPORTED'):
capture(sway,Catalog(),None)
def test_layer_shell_can_leave_no_sway_focus(self):
# Le panneau de déconnexion peut prendre le focus Wayland et ne
# laisser aucune feuille Sway marquée focused. L'absence est une
# observation légitime, distincte de plusieurs candidats ambigus.
sway = source()
for n in walk(sway.value): n['focused'] = False
result = capture(sway, Catalog(), None)
self.assertIsNone(result['focus'])
self.assertIsNone(result['focus_workspace'])
self.assertTrue(any(s['state']['scratchpad'] == 'hidden' for s in result['window_slots']))
with tempfile.TemporaryDirectory() as tmp:
store = Store(Path(tmp))
store.checkpoint(result)
self.assertEqual(store.last(), result)
try:
import jsonschema
except ImportError:
return
contract = json.loads((BACKEND/'session_v2/schema-v2.json').read_text())
jsonschema.validate(result, contract)
def test_multiple_focused_nodes_are_still_refused(self):
sway = source()
for n in walk(sway.value):
if n.get('id') == 9: n['focused'] = True
with self.assertRaisesRegex(Failure, 'SNAPSHOT_FOCUS_AMBIGUOUS'):
capture(sway, Catalog(), None)
def test_empty_focus_workspace_is_persisted(self):
sway=source()
for n in walk(sway.value): n['focused']=n['id']==3
d=capture(sway,Catalog(),None)
self.assertIsNone(d['focus']);self.assertEqual(d['focus_workspace'],'10')
self.assertIn('10',[w['workspace'] for w in d['workspaces']])
broken=copy.deepcopy(d);broken['focus_workspace']='absent'
with self.assertRaises(Failure):validate(broken)
def test_legacy_journal_not_reinterpreted(self):
sway=source()
with tempfile.TemporaryDirectory() as tmp:
r=Recovery(sway,tmp,'a'*32,'labfy-v2-stage-'+'a'*32,start=lambda pid:123)
r.admit(next(n for n in sway.windows() if n['id']==7))
r.data['version']=1
for e in r.data['windows']:
for key in ('origin','expected'): e[key].pop('scratchpad',None)
r.publish()
old=Recovery(sway,tmp,start=lambda pid:123)
self.assertNotIn('scratchpad',old.snapshot(7))
with self.assertRaisesRegex(Failure,'LEGACY_SCRATCHPAD_UNPROVEN'):old.snapshot(8)
if __name__=='__main__':unittest.main()