2026-09-29 12:36:34 +02:00
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
from rtut import project as J , run as R
from rtut . attrs import Registry , fold , load_set , normalize , set_layers
ROOT = Path ( __file__ ) . resolve ( ) . parent . parent
def write ( path : Path , data : dict ) :
path . parent . mkdir ( parents = True , exist_ok = True )
path . write_text ( yaml . safe_dump ( data , sort_keys = False ) )
@pytest.fixture
def repo ( tmp_path ) :
for name in ( " showcase-lighthouse " , " example-hello " ) :
( tmp_path / " runbooks " / name ) . mkdir ( parents = True )
shutil . copy ( ROOT / f " runbooks/ { name } /pack.yaml " , tmp_path / " runbooks " / name / " pack.yaml " )
for f in ( ROOT / " attribute-sets " ) . glob ( " *.yaml " ) :
write ( tmp_path / " attribute-sets " / f . name , yaml . safe_load ( f . read_text ( ) ) )
shutil . copy ( ROOT / " project-templates/lighthouse-watch.yaml " , tmp_path / " project-templates/lighthouse-watch.yaml " ) \
if ( tmp_path / " project-templates " ) . mkdir ( exist_ok = True ) is None else None
return tmp_path
def project ( repo , * * over ) :
d = { " spec " : " project/v0.1 " , " id " : " north-watch " , " title " : " North watch " , " environment " : " rehearsal " ,
" attribute_sets " : [ " stormy-coast " ] ,
" attributes " : { " keeper " : " Ada " , " logdir " : " /tmp/rtut-proj-north " } ,
" runbooks " : [ { " pack " : " rtut.showcase-lighthouse " } , { " pack " : " rtut.example-hello " , " after " : [ " rtut.showcase-lighthouse " ] } ] }
d . update ( over )
write ( repo / " projects " / d [ " id " ] / " project.yaml " , d )
return Registry ( [ repo ] )
# ---- attribute sets ---------------------------------------------------------------------------------------
def test_shipped_examples_are_valid ( ) :
reg = Registry ( [ ROOT ] )
assert all ( d . ok for d in reg . sets . values ( ) ) , [ d . errors for d in reg . sets . values ( ) ]
assert reg . templates [ " lighthouse-watch " ] . ok and set ( reg . sets ) > = { " harbour-defaults " , " stormy-coast " }
def test_extends_order_provenance_and_modified_flag ( repo ) :
reg = Registry ( [ repo ] )
f = fold ( set_layers ( reg , " stormy-coast " ) )
assert f [ " weather " ] [ " value " ] == " storm " and f [ " weather " ] [ " source " ] == " set:stormy-coast "
assert f [ " signal " ] [ " value " ] == " flags " and f [ " signal " ] [ " modified " ] is True # replaced a base value
assert [ h [ " source " ] for h in f [ " signal " ] [ " history " ] ] == [ " set:harbour-defaults " , " set:stormy-coast " ]
assert f [ " motto " ] [ " value " ] and f [ " motto " ] [ " modified " ] is False
assert f [ " keeper " ] [ " state " ] == " nil " and f [ " keeper " ] [ " requirement " ] == " required "
def test_attribute_set_errors ( tmp_path ) :
write ( tmp_path / " attribute-sets/a.yaml " , { " spec " : " attribute-set/v0.1 " , " id " : " a " , " title " : " Set A " , " extends " : [ " b " ] , " attributes " : { } } )
write ( tmp_path / " attribute-sets/b.yaml " , { " spec " : " attribute-set/v0.1 " , " id " : " b " , " title " : " Set B " , " extends " : [ " a " ] , " attributes " : { } } )
write ( tmp_path / " attribute-sets/c.yaml " , { " spec " : " attribute-set/v0.1 " , " id " : " c " , " title " : " Set C " , " attributes " : { " token " : " hvs.ABCDEFGHIJKLMN " } } )
write ( tmp_path / " attribute-sets/d.yaml " , { " spec " : " attribute-set/v0.1 " , " id " : " d " , " title " : " Set D " , " attributes " : { " Bad-Name " : " x " } } )
reg = Registry ( [ tmp_path ] )
with pytest . raises ( ValueError , match = " cycle " ) :
set_layers ( reg , " a " )
with pytest . raises ( ValueError , match = " unknown attribute set " ) :
set_layers ( reg , " zzz " )
assert not reg . sets [ " c " ] . ok and any ( " secret " in e for e in reg . sets [ " c " ] . errors )
assert not reg . sets [ " d " ] . ok
assert not load_set ( tmp_path / " attribute-sets/d.yaml " ) . ok
def test_normalize_scalar_and_object ( ) :
n = normalize ( { " a " : " x " , " b " : { " requirement " : " required " } , " c " : 3 } )
assert n [ " a " ] == { " value " : " x " , " requirement " : None } and n [ " b " ] [ " value " ] is None and n [ " c " ] [ " value " ] == 3
# ---- resolution against runbooks -----------------------------------------------------------------------------
def test_member_attribute_states_requirements_and_layers ( repo ) :
reg = project ( repo , attributes = { " logdir " : " /etc " , " surplus " : " x " } ) # invalid for the runbook, and unused
d = reg . projects [ " north-watch " ] . data
rows = { r [ " id " ] : r for r in J . member_attributes ( reg , d , d [ " runbooks " ] [ 0 ] ) [ " rows " ] }
assert rows [ " weather " ] [ " state " ] == " set " and rows [ " weather " ] [ " source " ] == " set:stormy-coast "
assert rows [ " logdir " ] [ " state " ] == " err " and " rtut- " in rows [ " logdir " ] [ " error " ] or rows [ " logdir " ] [ " state " ] == " err "
assert rows [ " keeper " ] [ " requirement " ] == " required " # the set declares it required; the pack has a default
assert rows [ " keeper " ] [ " state " ] == " nil " and rows [ " keeper " ] [ " default " ] == " Ada "
assert J . member_attributes ( reg , d , d [ " runbooks " ] [ 0 ] ) [ " unused " ] == [ " surplus " ]
def test_target_and_member_layers_override_the_project ( repo ) :
reg = project ( repo , targets = { " cape " : { " attributes " : { " weather " : " fog " } } } ,
runbooks = [ { " pack " : " rtut.showcase-lighthouse " , " target " : " cape " , " attributes " : { " motto " : " Hold fast " } } ] )
d = reg . projects [ " north-watch " ] . data
rows = { r [ " id " ] : r for r in J . member_attributes ( reg , d , d [ " runbooks " ] [ 0 ] ) [ " rows " ] }
assert rows [ " weather " ] [ " value " ] == " fog " and rows [ " weather " ] [ " source " ] == " target:cape " and rows [ " weather " ] [ " modified " ]
assert rows [ " motto " ] [ " value " ] == " Hold fast " and rows [ " motto " ] [ " source " ] . startswith ( " member: " )
def test_missing_required_lists_what_a_member_still_needs ( repo ) :
reg = project ( repo , attributes = { " logdir " : " /tmp/rtut-proj-north " } ) # keeper has a pack default, so not missing
d = reg . projects [ " north-watch " ] . data
assert J . missing_required ( reg , d ) == [ ]
reg = project ( repo , runbooks = [ { " pack " : " rtut.example-hello " } ] , attribute_sets = [ ] , attributes = { " host " : " h " } )
write ( repo / " runbooks/example-hello/pack.yaml " , { * * yaml . safe_load ( ( ROOT / " runbooks/example-hello/pack.yaml " ) . read_text ( ) ) ,
" parameters " : [ { " id " : " host " , " label " : " Host " , " type " : " string " } , { " id " : " count " , " label " : " Count " , " type " : " int " , " default " : 1 } ] } )
reg = Registry ( [ repo ] ) ; d = reg . projects [ " north-watch " ] . data ; d [ " attributes " ] = { }
assert [ m [ " attribute " ] for m in J . missing_required ( reg , d ) ] == [ " host " ]
# ---- validation ------------------------------------------------------------------------------------------------
@pytest.mark.parametrize ( " over,frag " , [
( { " runbooks " : [ { " pack " : " nope.nothing " } ] } , " not found " ) ,
( { " attribute_sets " : [ " ghost " ] } , " unknown attribute set " ) ,
( { " runbooks " : [ { " pack " : " rtut.showcase-lighthouse " , " target " : " ghost " } ] } , " unknown target " ) ,
( { " runbooks " : [ { " pack " : " rtut.showcase-lighthouse " , " after " : [ " rtut.example-hello " ] } ] } , " not a member " ) ,
( { " runbooks " : [ { " pack " : " rtut.showcase-lighthouse " , " after " : [ " rtut.example-hello " ] } ,
{ " pack " : " rtut.example-hello " , " after " : [ " rtut.showcase-lighthouse " ] } ] } , " cycle " ) ,
( { " runbooks " : [ { " pack " : " rtut.showcase-lighthouse " } , { " pack " : " rtut.showcase-lighthouse " } ] } , " twice " ) ,
] )
def test_project_validation ( repo , over , frag ) :
reg = project ( repo , * * over )
errs = J . validate ( reg , reg . projects [ " north-watch " ] )
assert any ( frag in e for e in errs ) , errs
def test_project_needs_an_environment_and_valid_class ( repo ) :
write ( repo / " projects/x/project.yaml " , { " spec " : " project/v0.1 " , " id " : " x " , " title " : " Project x " , " runbooks " : [ { " pack " : " rtut.example-hello " } ] } )
write ( repo / " projects/y/project.yaml " , { " spec " : " project/v0.1 " , " id " : " y " , " title " : " Project y " , " environment " : " prod " , " runbooks " : [ { " pack " : " rtut.example-hello " } ] } )
reg = Registry ( [ repo ] )
assert not reg . projects [ " x " ] . ok and not reg . projects [ " y " ] . ok
# ---- runs and state ----------------------------------------------------------------------------------------------
def finish ( r ) :
for s in r . pack . data [ " steps " ] :
sid = s [ " id " ] ; R . start ( r , sid )
cmd = R . command_for ( r , sid )
if cmd :
subprocess . run ( cmd , shell = True , check = True , capture_output = True )
R . verify ( r , sid , confirm = True , manual_ok = True if s [ " verify " ] [ " expect " ] == " manual " else None )
R . complete ( r , sid , { n : " 1 " for n in s . get ( " evidence " , [ ] ) } )
R . finalize ( r , " succeeded " )
def test_start_run_uses_attributes_and_records_context ( repo ) :
reg = project ( repo )
r = J . start_run ( reg , reg . projects [ " north-watch " ] , " rtut.showcase-lighthouse " , " bob " )
assert r . data [ " params " ] [ " weather " ] == " storm " and r . data [ " params " ] [ " signal " ] == " flags " and r . data [ " params " ] [ " logdir " ] == " /tmp/rtut-proj-north "
assert r . data [ " context " ] == { " project " : " north-watch " , " environment " : " rehearsal " }
with pytest . raises ( R . RunError , match = " manual mode only " ) :
J . start_run ( reg , reg . projects [ " north-watch " ] , " rtut.showcase-lighthouse " , " bob " , mode = " auto-confirm " )
def test_project_state_planned_applying_applied_and_next ( repo ) :
reg = project ( repo )
doc = reg . projects [ " north-watch " ]
st = J . state ( reg , doc )
assert st [ " state " ] == " planned " and st [ " next " ] == [ " rtut.showcase-lighthouse " ] # the second member waits for the first
r = J . start_run ( reg , doc , " rtut.showcase-lighthouse " , " bob " )
st = J . state ( reg , doc )
assert st [ " state " ] == " applying " and st [ " members " ] [ 0 ] [ " status " ] == " applying " and st [ " next " ] == [ ]
finish ( r )
st = J . state ( reg , doc )
assert st [ " members " ] [ 0 ] [ " status " ] == " applied " and st [ " state " ] == " applying " and st [ " next " ] == [ " rtut.example-hello " ]
finish ( J . start_run ( reg , doc , " rtut.example-hello " , " bob " ) )
assert J . state ( reg , doc ) [ " state " ] == " applied "
def test_project_goes_stale_when_a_member_changes_and_failed_members_are_shown ( repo ) :
reg = project ( repo , runbooks = [ { " pack " : " rtut.showcase-lighthouse " } ] )
doc = reg . projects [ " north-watch " ]
finish ( J . start_run ( reg , doc , " rtut.showcase-lighthouse " , " bob " ) )
assert J . state ( reg , doc ) [ " state " ] == " applied "
p = repo / " runbooks/showcase-lighthouse/pack.yaml "
p . write_text ( p . read_text ( ) + " \n # revised \n " )
reg = Registry ( [ repo ] )
st = J . state ( reg , reg . projects [ " north-watch " ] )
assert st [ " state " ] == " stale " and st [ " members " ] [ 0 ] [ " status " ] == " stale "
r = J . start_run ( reg , reg . projects [ " north-watch " ] , " rtut.showcase-lighthouse " , " bob " )
R . finalize ( r , " abandoned " )
assert J . state ( reg , reg . projects [ " north-watch " ] ) [ " members " ] [ 0 ] [ " status " ] == " failed "
def test_runs_of_other_projects_do_not_count ( repo ) :
reg = project ( repo , runbooks = [ { " pack " : " rtut.showcase-lighthouse " } ] )
write ( repo / " projects/other/project.yaml " , { * * reg . projects [ " north-watch " ] . data , " id " : " other " , " title " : " Other " } )
reg = Registry ( [ repo ] )
finish ( J . start_run ( reg , reg . projects [ " other " ] , " rtut.showcase-lighthouse " , " bob " ) )
assert J . state ( reg , reg . projects [ " north-watch " ] ) [ " state " ] == " planned "
# ---- templates ------------------------------------------------------------------------------------------------------
def test_instantiate_template_records_origin_merges_values_and_lists_needs ( repo ) :
reg = Registry ( [ repo ] )
2026-09-29 14:12:21 +02:00
out = J . instantiate ( reg , " lighthouse-watch " , " harbour-north " , " rehearsal " , { " keeper " : " Ada " , " logdir " : " /tmp/rtut-harbour " } ,
title = " North harbour watch " , description = " Our own harbour " , goal = " Both lights keep watch " )
2026-09-29 12:36:34 +02:00
made = yaml . safe_load ( out . read_text ( ) )
assert made [ " spec " ] == " project/v0.1 " and made [ " template " ] == " lighthouse-watch " and made [ " environment " ] == " rehearsal "
2026-09-29 14:12:21 +02:00
assert made [ " attributes " ] [ " keeper " ] == " Ada "
assert made [ " title " ] == " North harbour watch " and made [ " description " ] == " Our own harbour " and made [ " goal " ] == " Both lights keep watch "
assert " The night watch is played through " not in out . read_text ( ) and " A watch at a lighthouse " not in out . read_text ( ) # nothing of the template's text is copied
2026-09-29 12:36:34 +02:00
assert set ( made [ " targets " ] ) == { " north-light " , " south-light " }
reg = Registry ( [ repo ] )
assert reg . projects [ " harbour-north " ] . ok and J . validate ( reg , reg . projects [ " harbour-north " ] ) == [ ]
r = J . start_run ( reg , reg . projects [ " harbour-north " ] , " rtut.showcase-lighthouse " , " bob " , target = " north-light " )
assert r . data [ " params " ] [ " weather " ] == " fog " and r . data [ " context " ] [ " target " ] == " north-light " # target attributes applied
with pytest . raises ( ValueError , match = " already exists " ) :
J . instantiate ( reg , " lighthouse-watch " , " harbour-north " , " rehearsal " )
with pytest . raises ( ValueError , match = " environment " ) :
J . instantiate ( reg , " lighthouse-watch " , " harbour-x " , " prod " )
with pytest . raises ( R . RunError , match = " template is not run " ) :
J . start_run ( reg , reg . templates [ " lighthouse-watch " ] , " rtut.showcase-lighthouse " , " bob " )
2026-09-29 13:12:47 +02:00
def test_set_attributes_validates_against_the_consuming_runbooks ( repo ) :
reg = project ( repo , attributes = { } )
doc = reg . projects [ " north-watch " ]
J . set_attributes ( reg , doc , { " keeper " : " Bea " , " logdir " : " /tmp/rtut-set-here " , " unrelated " : " x " } )
made = yaml . safe_load ( doc . path . read_text ( ) ) [ " attributes " ]
assert made [ " keeper " ] == " Bea " and made [ " logdir " ] == " /tmp/rtut-set-here " and made [ " unrelated " ] == " x "
with pytest . raises ( ValueError , match = " rtut-|violates " ) :
J . set_attributes ( reg , doc , { " logdir " : " /etc " } )
with pytest . raises ( ValueError , match = " secret " ) :
J . set_attributes ( reg , doc , { " keeper " : " hvs.ABCDEFGHIJKLMN " } )
with pytest . raises ( ValueError , match = " bad attribute name " ) :
J . set_attributes ( reg , doc , { " Bad-Name " : " x " } )
assert yaml . safe_load ( doc . path . read_text ( ) ) [ " attributes " ] [ " logdir " ] == " /tmp/rtut-set-here " # refused values change nothing
reg = Registry ( [ repo ] )
with pytest . raises ( ValueError , match = " template " ) :
J . set_attributes ( reg , reg . templates [ " lighthouse-watch " ] , { " keeper " : " x " } )
def test_attribute_notes_survive_resolution ( repo ) :
reg = Registry ( [ repo ] )
assert " Who keeps this light " in fold ( set_layers ( reg , " harbour-defaults " ) ) [ " keeper " ] [ " note " ]
2026-09-29 14:12:21 +02:00
def test_project_without_a_given_name_uses_its_id_never_the_templates_title ( repo ) :
reg = Registry ( [ repo ] )
out = J . instantiate ( reg , " lighthouse-watch " , " plain-one " , " staging " )
made = yaml . safe_load ( out . read_text ( ) )
assert made [ " title " ] == " plain-one " and " goal " not in made and " description " not in made
with pytest . raises ( ValueError , match = " 3 characters " ) :
J . instantiate ( reg , " lighthouse-watch " , " ab " , " staging " )
with pytest . raises ( ValueError , match = " secret " ) :
J . instantiate ( reg , " lighthouse-watch " , " leaky-one " , " staging " , description = " token hvs.ABCDEFGHIJKLMN " )