This commit is contained in:
khodowany
2026-10-04 22:29:06 -05:00
parent 0e68f5364b
commit ef62af892b
40 changed files with 1754 additions and 789 deletions
+171
View File
@@ -0,0 +1,171 @@
"""Independently authored operator, fitting-boundary and cache regressions."""
import copy
import dataclasses
from fractions import Fraction as F
from pathlib import Path
import tempfile
import unittest
import contextlib
import io
from unittest.mock import patch
from generator import resources, vocabulary
from generator.solo import expressions, harmonic, operators, selection, types
from tests import solo_fixtures as fixture, rhythm_fixtures, scale_fixtures
from tests.test_phrase_composer import builder
class Operators(unittest.TestCase):
def test_supported_sixteenth_tail_can_finish_the_allocation(self):
b=builder(('CM7',),count=1)
actions=b.operators.at(F(15,4))
self.assertTrue(any(a.kind=='tone' and a.end==4 for a in actions))
# A source/role cutoff does not require a new long cadence.
b.start=F(15,4)
phrases=b.compose()
self.assertEqual(phrases[-1].end,F(4))
self.assertLessEqual(sum(len(p.notes) for p in phrases),1)
def test_source_relation_uses_order_and_does_not_reward_subdivision(self):
b=builder(('CM7',),count=1)
slow=operators.Action(F(),F(4),tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('c','d','e','f'))),'scalar')
fast=dataclasses.replace(slow,end=F(2),notes=tuple(dataclasses.replace(n,start=n.start/2,end=n.end/2) for n in slow.notes))
self.assertEqual(b.selector.evaluate(slow).raw['prior/source_relation'],b.selector.evaluate(fast).raw['prior/source_relation'])
changed=dataclasses.replace(b.analysis,melody=tuple(fixture.note(i,i+1,p,event_id=str(i)) for i,p in enumerate(('e','f','g','a'))))
other=selection.Selector(b.harmony,changed,b.selector.center,{})
self.assertNotEqual(b.selector.evaluate(slow).raw.get('prior/source_relation',0),other.evaluate(slow).raw.get('prior/source_relation',0))
def test_shared_fitter_learns_an_independent_synthetic_decision(self):
from audition.fit_selection import fit
b=builder(('CM7',),count=1)
low=operators.Action(F(),F(4),(fixture.note(0,4,'c',event_id='low'),),'tone')
high=operators.Action(F(),F(4),(fixture.note(0,4,'e',event_id='high'),),'tone')
b.selector.weights={}
with patch.object(b.operators,'at',return_value=(low,high)),contextlib.redirect_stdout(io.StringIO()):
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
self.assertTrue(result['complete'])
b.selector.weights=result['weights']['acoustic_solo']
self.assertLess(b.selector.evaluate(high).total,b.selector.evaluate(low).total)
selection.validate_weights(b.selector.weights)
def test_fitting_normalization_covers_pickup_plus_whole_note(self):
from audition.fit_selection import fit
b=builder(('CM7',),count=2);b.end=F(17,4)
low=operators.Action(F(),b.end,(fixture.note(0,F(1,4),'c',event_id='lead'),fixture.note(F(1,4),b.end,'e',event_id='low')),'anticipation')
high=dataclasses.replace(low,notes=(low.notes[0],fixture.note(F(1,4),b.end,'g',event_id='high')))
b.selector.weights={}
with patch.object(b.operators,'at',return_value=(low,high)),contextlib.redirect_stdout(io.StringIO()):
result=fit([('synthetic','acoustic_solo',b,(high,))],10)
self.assertTrue(result['complete'])
selection.validate_weights(result['weights']['acoustic_solo'])
def test_anticipated_reattack_is_not_an_invented_tie(self):
b=builder(('C7','FM7'),count=2)
actions=b.operators.at(F(7,2))
a=next(a for a in actions if a.kind=='anticipation' and a.source=='early_reattack')
self.assertEqual(len(a.notes),2)
self.assertEqual(a.notes[0].end,a.notes[1].start)
self.assertEqual(a.notes[0].pitch,a.notes[1].pitch)
self.assertFalse(a.notes[1].continuation)
self.assertTrue(b.harmony.evaluate(a.notes,expressions=a.contracts).valid)
self.assertFalse(b.harmony.evaluate(a.notes[:1],expressions=a.contracts).valid)
def test_open_motion_and_all_color_octaves_are_constructed(self):
b=builder(('C7','FM7'),count=2)
actions=b.operators.at(F())
self.assertTrue(any(len(a.notes)==1 and a.notes[0].pitch.number()%12==2 and a.end==F(1,2) for a in actions))
h=builder(('E7','Am7'),count=2)
values=h.operators.at(F())
self.assertTrue(any(a.kind=='color' and len(a.notes)==1 and a.notes[0].pitch.number()==66 for a in values))
self.assertTrue(any(a.kind=='rest' for a in values))
def test_anticipation_color_has_actual_future_resolution(self):
h=harmonic.Harmony(fixture.choices(('Dm7','G7','CM7')))
held=fixture.note(F(7,2),F(11,2),'c',alter=1,event_id='held')
target=fixture.note(F(11,2),7,'d',event_id='target')
c=types.ExpressionContract('anticipated','anticipated_color','lydian_color',(held,),target,'G7',boundary=F(4))
self.assertTrue(h.evaluate((held,target),expressions=(c,)).valid)
self.assertFalse(h.evaluate((held,),expressions=(c,)).valid)
changed=dataclasses.replace(target,pitch=dataclasses.replace(target.pitch,octave=5))
self.assertFalse(h.evaluate((held,changed),expressions=(c,)).valid)
self.assertFalse(h.evaluate((held,target)).valid)
def test_delayed_approach_is_pending_and_expires(self):
h=harmonic.Harmony(fixture.choices(('Dm7',)))
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
target=fixture.note(3,F(7,2),'d',event_id='target')
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
r=h.evaluate((approach,),expressions=(c,),until=F(1,2),complete=False)
self.assertTrue(r.valid,r.error);self.assertEqual(r.pending,(c,))
self.assertFalse(h.evaluate((approach,),expressions=(c,),until=4,complete=True).valid)
self.assertTrue(h.evaluate((approach,target),expressions=(c,)).valid)
def test_distinct_pending_targets_are_visible_to_the_selector(self):
b=builder(('Dm7',),count=2)
actions=[a for a in b.operators.at(F()) if a.kind=='delayed_approach']
first=actions[0]
second=next(a for a in actions if a.notes==first.notes and a.contracts[0].target.start!=first.contracts[0].target.start)
left,_=b.selector.features(first);right,_=b.selector.features(second)
self.assertNotEqual(left,right)
selection.validate_weights(dict.fromkeys(left,1))
def test_missed_pending_target_is_rejected_before_expensive_validation(self):
from generator.solo.construction import State
b=builder(('Dm7',),count=2)
approach=fixture.note(0,F(1,2),'c',alter=1,event_id='approach')
target=fixture.note(3,4,'d',event_id='target')
c=types.ExpressionContract('delayed','delayed_approach','bounded_semitone_resolution',(approach,),target,'Dm7')
state=State(pending=(c,))
wrong=operators.Action(F(3),F(4),(fixture.note(3,4,'e',event_id='wrong'),),'tone')
with patch.object(b.harmony,'evaluate',side_effect=AssertionError('unnecessary full check')):
self.assertIsNone(b.advance(state,None,wrong))
good=dataclasses.replace(wrong,notes=(target,))
self.assertIsNotNone(b.advance(state,None,good))
def test_functional_degree_does_not_depend_on_enharmonic_label(self):
h=harmonic.Harmony(fixture.choices(('E7','Am7')))
n=fixture.note(0,2,'g',event_id='sharp9')
c=types.ExpressionContract('color','color','dominant_sharp_ninth',(n,),n,'E7')
self.assertTrue(h.evaluate((n,),expressions=(c,)).valid)
wrong=dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,alter=F(1)))
self.assertFalse(h.evaluate((wrong,),expressions=(dataclasses.replace(c,events=(wrong,),target=wrong),)).valid)
def test_weight_contributions_and_target_free_features(self):
b=builder(count=1)
a=next(a for a in b.operators.at(F()) if a.kind=='tone')
v=b.selector.evaluate(a)
self.assertEqual(v.total,sum(v.contributions.values()))
self.assertEqual(v.total,b.selector.cost(a))
key='kind/tone';b.selector.weights={key:3}
changed=b.selector.evaluate(a)
self.assertEqual(changed.total,b.selector.cost(a))
self.assertEqual(changed.total-v.total,3*v.raw[key])
with self.assertRaises(ValueError):selection.validate_weights({'song/all_of_me':1})
def test_derivation_cannot_inject_an_illegal_reference_note(self):
from audition.fit_selection import derive
b=builder(('CM7',),count=1)
target={'alignment':{'encoded_offset':'0'},'sounding_events':[{'start':'0','end':'4','sounding_midi':'99','pitch':{},'measure':1}]}
path,result=derive(b,target)
self.assertFalse(result['complete']);self.assertFalse(path)
class CompiledResources(unittest.TestCase):
def test_compiled_and_xml_libraries_have_identical_contents(self):
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
import json
encoded=json.loads(json.dumps(resources.encode(original),default=str))
restored=resources.decode(encoded)
self.assertEqual(original[0],restored[0])
self.assertEqual(original[1].paths,restored[1].paths)
self.assertEqual(original[2].snippets,restored[2].snippets)
def test_warm_cache_avoids_xml_import_and_corruption_recompiles(self):
original=(scale_fixtures.library(),fixture.library(),rhythm_fixtures.library())
with tempfile.TemporaryDirectory() as root:
with patch('generator.resources.scales.load_library',return_value=original[0]) as sc,patch('generator.resources.sequences.load_library',return_value=original[1]),patch('generator.resources.rhythm.load_library',return_value=original[2]):
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
self.assertEqual(sc.call_count,1)
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
self.assertEqual(sc.call_count,1)
next((Path(root)/'_resources').glob('*.json')).write_text('{}')
resources._load.cache_clear();resources.load(Path(root),{'fixture':'1'},root)
self.assertEqual(sc.call_count,2)
+3 -1
View File
@@ -193,7 +193,9 @@ class PartGenerationTests(unittest.TestCase):
unittest.mock.patch('generator.playback.compile_tree'),
):
generator.__main__.generate_assets(args)
load_library.assert_called_once_with(args.data_dir.resolve() / 'rhythms')
# Role catalog loading is replaced by this infrastructure fixture. The
# parent asset pipeline must not perform a redundant catalog parse.
load_library.assert_not_called()
self.assertEqual(load.call_args_list, [unittest.mock.call(args.data_dir.resolve() / 'songs/other.xml'), unittest.mock.call(args.data_dir.resolve() / 'songs/song.xml')])
self.assertEqual(normalize.call_count, 2)
original = {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()}
+58 -92
View File
@@ -1,11 +1,10 @@
"""General, independently authored capabilities; no optional reference inputs."""
import copy,dataclasses,unittest
"""General, independently authored capabilities of the active action search."""
import dataclasses, unittest
from fractions import Fraction as F
from unittest.mock import patch
from generator import vocabulary,model
from generator.scales import selection,timeline
from generator.solo import analysis,construction,harmonic,profiles,types,scoring,composer,routes,placement
from tests import solo_fixtures as fixtures,rhythm_fixtures,scale_fixtures
from generator import model, vocabulary
from generator.scales import selection, timeline
from generator.solo import analysis, construction, harmonic, profiles, types, placement, operators
from tests import solo_fixtures as fixtures, rhythm_fixtures, scale_fixtures
def builder(symbols=('C7','FM7','Dm7','G7'),seed='independent',count=8):
@@ -15,111 +14,78 @@ def builder(symbols=('C7','FM7','Dm7','G7'),seed='independent',count=8):
return construction.Builder(a,ms,harmonic.Harmony(cs,profiles.Instrument(reference=60)),fixtures.library(),rhythm_fixtures.library(),end,seed)
class PhraseConstruction(unittest.TestCase):
def test_complete_candidates_have_varied_functions_and_joint_expression(self):
b=builder();values=[b.propose(F(),(),i) for i in range(64)];values=[p for p in values if p]
self.assertTrue(values)
self.assertIn('open',{p.ending for p in values})
self.assertTrue(any(p.destination and p.destination.duration>=2 for p in values))
self.assertTrue(any(p.expressions for p in values))
for p in values:
self.assertTrue(b.harmony.evaluate(p.notes,until=p.end,complete=True,expressions=p.expressions).valid)
self.assertEqual(p.score,p.evaluation.total)
self.assertTrue(all(p.start<=n.start<n.end<=p.end for n in p.notes))
def test_candidates_include_open_motion_arrivals_and_joint_expression(self):
b=builder(count=2);values=b.operators.at(F())
self.assertTrue(any(a.kind=='tone' and a.end==F(1,2) for a in values))
self.assertTrue(any(len(a.notes)==1 and a.end>=2 for a in values))
self.assertTrue(any(a.contracts for a in values))
for a in values:
checked=b.harmony.evaluate(a.notes,until=a.end,complete=False,expressions=a.contracts)
self.assertTrue(checked.valid,checked.error)
evaluation=b.selector.evaluate(a)
self.assertEqual(evaluation.total,sum(evaluation.contributions.values()))
self.assertTrue(all(a.start<=n.start<n.end<=a.end for n in a.notes))
def test_all_operations_are_eligible_and_construct_complete_music(self):
b=builder(count=12);first=b.candidates(F(),())[0]
history=(first,dataclasses.replace(first,id='second'))
self.assertEqual(set(b.actions(history)),set(vocabulary.data('solo')['actions'])-{'head'})
history=(*history,dataclasses.replace(first,id='third'))
self.assertEqual(set(b.actions(history)),set(vocabulary.data('solo')['actions']))
# Request each operation through the same active constructor in eligible contexts.
for action in b.actions(history):
with self.subTest(action=action),patch.object(b,'actions',return_value=[action]):
candidates=[b.propose(F(16),history,i) for i in range(32)]
self.assertTrue(any(p and p.purpose==action for p in candidates))
def test_audible_theme_returns_and_answers_are_reachable(self):
b=builder(('CM7',),count=2)
home=(fixtures.note(0,F(1,2),'c'),fixtures.note(F(1,2),1,'d'),fixtures.note(1,2,'e'))
for kind in ('return','answer'):
values=b.operators.related(F(4),home,kind)
self.assertTrue(values,kind)
self.assertTrue(all(len(a.notes)==len(home) and a.kind==kind for a in values))
def test_head_is_optional_not_required_rhythm(self):
b=builder();b.analysis=dataclasses.replace(b.analysis,melody=())
values=b.candidates(F(),())
self.assertTrue(values)
self.assertTrue(all(p.purpose!='head' for p in values))
def test_recomposed_opening_is_not_source_hold(self):
source=fixtures.song(count=4,chords=('CM7',))
n=source['staves'][0]['measures'][0]['voices'][0]['notes'][0]
source['staves'][0]['measures'][0]['voices'][0]['notes']=[{**n,'duration':model.rational(4),'pitches':[{**n['pitches'][0],'duration':model.rational(4)}]}]
result=fixtures.compose(source)
self.assertTrue(result.notes());self.assertLess(result.notes()[0].end-result.notes()[0].start,4)
def test_disabled_expressions_are_strict_and_reproducible(self):
b=builder();b.enabled=False
def test_disabled_expressions_stay_strict(self):
b=builder(count=1);b.operators.enabled=False
ps=b.compose();ns=tuple(n for p in ps for n in p.notes)
self.assertFalse(any(p.expressions for p in ps))
self.assertTrue(all(n.pitch.number()%12 in types.permitted(b.harmony.choices,n.start,n.end) for n in ns))
self.assertEqual(ps,b.compose())
again=b.compose()
self.assertEqual(ps,again)
def test_role_domains_and_complete_tied_position(self):
for role in ('acoustic','electric'):
p=profiles.for_part(role)
self.assertEqual(p.strings,(1,2,3,4,5,6))
p=profiles.for_part(role);self.assertEqual(p.strings,(1,2,3,4,5,6))
for midi in range(41,77):self.assertTrue(placement.positions(midi,p))
a=fixtures.note(0,2,'e',event_id='held');ns=(a,dataclasses.replace(a,start=F(2),end=F(4),continuation=True))
positioned=placement.place(ns,F(120))[1]
self.assertEqual(positioned[0].position,positioned[1].position)
self.assertEqual(len(types.sounding_notes(positioned)),1)
def test_routes_keep_actual_pitches_across_collection_change(self):
def test_complete_routes_cross_boundaries_without_a_forced_chord_tone(self):
b=builder(('C7','DmM7'),count=2)
ns=(fixtures.note(3,F(7,2),'g'),fixtures.note(F(7,2),4,'a'),fixtures.note(4,F(9,2),'c'),fixtures.note(F(9,2),5,'d'))
values=b.routes.realize(ns,{0,3},'scalar','up','crossing',64)
self.assertTrue(values)
for route in values:
self.assertEqual(route[0].pitch,ns[0].pitch);self.assertEqual(route[-1].pitch,ns[-1].pitch)
self.assertTrue(all(n.pitch.number()%12 in types.permitted(b.harmony.choices,n.start,n.end) for n in route))
values=b.operators.at(F(3))
crossed=[a for a in values if a.start<4<a.end and a.kind=='scalar']
self.assertTrue(crossed)
for a in crossed:self.assertTrue(b.harmony.evaluate(a.notes).valid)
one=builder(('C7',),count=1)
self.assertTrue(any(a.kind=='tone' and a.notes[0].pitch.number()%12==5 and a.end==F(1,2) for a in one.operators.at(F())))
def test_no_internal_chordal_endpoint_requirement(self):
b=builder(('C7',),count=2)
ns=(fixtures.note(0,F(1,2),'e'),fixtures.note(F(1,2),1,'f'))
paths=b.routes.realize(ns,{0,1},'scalar','up','open',64)
self.assertTrue(paths)
self.assertTrue(b.harmony.evaluate(paths[0]).valid)
self.assertNotIn(paths[0][-1].pitch.number()%12,b.harmony.at(F(1,2)).roles.basic)
def test_actual_incoming_state_changes_ranking(self):
b=builder(count=1)
a=b.candidates(F(),construction.State(recent=(fixtures.note(-1,0,'c',octave=3),)))
c=b.candidates(F(),construction.State(recent=(fixtures.note(-1,0,'c',octave=5),)))
self.assertNotEqual([v[1].key for v in a],[v[1].key for v in c])
def test_meaningful_incoming_state_changes_continuations(self):
b=builder();history=(b.candidates(F(),())[0],)
a=b.candidates(history[-1].end,history)
old=history[0];last=old.notes[-1]
changed=dataclasses.replace(old,id='changed',notes=(*old.notes[:-1],dataclasses.replace(last,pitch=dataclasses.replace(last.pitch,octave=last.pitch.octave+1))))
c=b.candidates(changed.end,(changed,))
self.assertNotEqual(tuple(p.notes for p in a),tuple(p.notes for p in c))
def test_handoff_is_not_forced_to_have_a_long_note(self):
b=builder(count=1)
values=b.operators.at(F(7,2))
self.assertTrue(any(a.kind=='tone' and a.end==4 for a in values))
self.assertTrue(any(a.kind=='rest' and a.end==4 for a in values))
def test_no_short_accidental_handoff_tail(self):
b=builder(count=4);ps=b.compose()
self.assertEqual(ps[-1].end,b.end)
if ps[-1].destination:self.assertGreaterEqual(ps[-1].destination.duration,2)
def test_cache_restores_exact_music_without_source_ancestry_growth(self):
b=builder(count=4);cold=b.candidates(F(),());warm=b.candidates(F(),())
self.assertEqual(cold,warm)
self.assertTrue(all(not n.sources for p in cold for n in p.notes))
def test_cached_actions_do_not_copy_source_ancestry(self):
b=builder(count=1);a=b.operators.at(F());self.assertIs(a,b.operators.at(F()))
self.assertTrue(all(not n.sources for action in a for n in action.notes))
class EvaluationContract(unittest.TestCase):
def phrase(self,ns,**kwargs):return types.Phrase('test',F(),F(8),tuple(ns),'scalar','up','open',**kwargs)
def test_configured_terms_are_authoritative_and_nonzero(self):
ns=(fixtures.note(0,1,'c',octave=3),fixtures.note(1,2,'e',octave=4),fixtures.note(2,4,'c',octave=4))
p=self.phrase(ns);incoming=fixtures.note(-1,0,'c',octave=5)
base=scoring.evaluate(p,incoming,(p,),40)
self.assertEqual(base.total,sum(base.contributions.values()))
for name,value in base.raw.items():
self.assertGreater(value,0,name)
altered=scoring.evaluate(p,incoming,(p,),40,weights={**base.weights,name:base.weights[name]+1})
self.assertAlmostEqual(altered.total-base.total,value)
def test_silence_is_explicit_and_tie_partition_is_invariant(self):
a=fixtures.note(0,4,'e',event_id='a')
split=(dataclasses.replace(a,end=F(2)),dataclasses.replace(a,start=F(2),continuation=True))
self.assertEqual(scoring.evaluate(self.phrase((a,)),None,(),60),scoring.evaluate(self.phrase(split),None,(),60))
self.assertFalse(scoring.evaluate(self.phrase(()),None,(),60).valid)
self.assertTrue(scoring.evaluate(dataclasses.replace(self.phrase(()),purpose='space'),None,(),60).valid)
def test_tie_partition_is_invariant_and_reattack_is_distinct(self):
b=builder(('C7',),count=1);n=fixtures.note(0,4,'e',event_id='hold')
a=operators.Action(F(),F(4),(n,),'tone')
split=(dataclasses.replace(n,end=F(2)),dataclasses.replace(n,start=F(2),continuation=True))
x=b.selector.evaluate(a);y=b.selector.evaluate(dataclasses.replace(a,notes=split))
self.assertEqual(x,y)
reattack=tuple(dataclasses.replace(n,continuation=False) for n in split)
self.assertNotEqual(x,b.selector.evaluate(dataclasses.replace(a,notes=reattack)))
def test_target_touch_and_repeated_attacks_do_not_fulfill_hold(self):
a=fixtures.note(2,4,'e',event_id='arrival')
anchor=types.Anchor('held',F(2),F(2),a.pitch,frozenset({4}),(41,76),F(2),'terminal','C7',hold_until=F(4))
+4
View File
@@ -39,6 +39,10 @@ class ScoreBundleTests(unittest.TestCase):
def setUp(self):
tests.production_fixtures.install(self)
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
from generator import resources
# Validation now belongs to the catalog-consuming worker, not a redundant
# parent parse. Exercise its real content keys/importer in this rollback test.
self.enterContext(unittest.mock.patch('generator.production._solo_libraries',side_effect=lambda directory,keys,cache=None:resources.load(directory,cache_dir=cache)))
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
+15 -1
View File
@@ -7,10 +7,13 @@ from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import contextlib
import io
from audition import reference_review, solo_compare
from generator import vocabulary, production
from generator.solo import export, harmonic, notation, placement, routes, types
from generator.solo import export, harmonic, notation, placement, types
from tests.solo_fixtures import song
from tests.solo_fixtures import note
from tests.solo_fixtures import choices
@@ -26,6 +29,17 @@ def pitched(duration, tie='', octave=5):
class ComparisonTests(unittest.TestCase):
def test_exact_cli_is_a_real_exit_gate(self):
args=['solo_compare','--generated-dir','unused','--output','unused','--reference','fixture/acoustic=unused','--require-exact']
review={'exact_musical_match':False,'exact_matching_attacks':0,'differences':[{}],'rest_difference':True}
with patch('sys.argv',args),patch.object(solo_compare,'run',return_value={'solos':{'fixture/acoustic':review}}),contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(solo_compare.main(),1)
review.update(exact_musical_match=True,differences=[],rest_difference=False)
self.assertEqual(solo_compare.main(),0)
with patch('sys.argv',args[:-3]+['--require-exact']),contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as error:solo_compare.main()
self.assertEqual(error.exception.code,2)
def test_note_container_does_not_turn_color_label_into_fingering(self):
with tempfile.TemporaryDirectory() as root:
note_xml=pitched(24).replace('</note>','<notations><technical><fingering>6</fingering></technical></notations></note>')