204 lines
11 KiB
Python
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()
|