"""Whole-path position objectives and ties, with independently checked examples.""" import dataclasses import fractions import itertools import unittest import unittest.mock import generator.comping import generator.fretboard import generator.guitar import generator.hand_position import generator.solo.placement import tests.solo_fixtures import tests.test_acoustic_voicing import tests.test_comping_voicings def position(string, fret): return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret) class HandPositionTests(unittest.TestCase): def test_single_notes_can_share_a_four_fret_hand_region(self): self.assertEqual(set.intersection(*(set(generator.hand_position.regions((position(1, f),))) for f in range(7, 11))), {7}) self.assertEqual(generator.hand_position.regions((position(1, 1),)), (1,)) self.assertIn(12, generator.hand_position.regions((position(1, 12),))) self.assertTrue(all(p.fret <= 12 for n in range(41, 89) for p in generator.solo.placement.positions(n))) def test_chords_share_the_same_window_as_single_notes(self): self.assertEqual(generator.hand_position.regions((position(5, 7), position(4, 8), position(3, 7), position(2, 8))), (5, 6, 7)) self.assertEqual(generator.hand_position.regions((position(5, 7), position(4, 10))), (7,)) a, b = (position(5, 7),), (position(3, 9),) motion = generator.hand_position.motion(a, b, 7, 7) self.assertEqual(motion.shift, 0) self.assertGreater(motion.ergonomics, 0) def test_future_peak_can_equalize_histories_at_the_same_endpoint(self): low, high, join, final = ((position(1, f),) for f in (1, 4, 2, 8)) layers = ((low, high), (join,), (final,)) costs = ({low: generator.comping.VoicingCost(register=10), high: generator.comping.VoicingCost()}, {join: generator.comping.VoicingCost()}, {final: generator.comping.VoicingCost()}) # Isolate peak propagation: a higher earlier peak must remain available # when a common later maximum exposes its better subordinate tie-break. with unittest.mock.patch('generator.hand_position.motion', return_value=generator.hand_position.Motion()): path, cost, hands = generator.comping.select_grips(layers, costs, 'electric', 'peak') self.assertEqual(path, (high, join, final)) self.assertEqual((cost.travel, cost.peak_position, cost.register), (0, 5, 0)) def test_complete_path_avoids_a_locally_lower_position_trap(self): low, high, middle, end = ((position(1, f),) for f in (1, 4, 5, 8)) layers = ((low, high), (middle,), (end,)) costs = tuple({v: generator.comping.VoicingCost(register=int(v == high)) for v in layer} for layer in layers) first, _, _ = generator.comping.select_grips(layers[:1], costs[:1], 'electric', 'lookahead') path, cost, hands = generator.comping.select_grips(layers, costs, 'electric', 'lookahead') self.assertEqual(first, (low,)) self.assertEqual(path, (high, middle, end)) self.assertEqual(cost.travel, 1) self.assertEqual(cost, tests.test_comping_voicings.path_cost(path, costs, 'electric', hands=hands)) def test_lower_travel_beats_easier_finger_changes(self): notes = tuple(tests.solo_fixtures.note(i, i + 1, pitch, octave=4) for i, pitch in enumerate(('e', 'g', 'f', 'e'))) result = generator.solo.placement.paths(notes, fractions.Fraction(120))[0] self.assertEqual(result.travel, 0) self.assertEqual(len(set(result.hands)), 1) self.assertGreater(result.effort, 0) def test_solo_matches_exhaustive_pitch_and_hand_paths(self): notes = (tests.solo_fixtures.note(0, 1, 'e', octave=4), tests.solo_fixtures.note(1, 2, 'g', octave=4), tests.solo_fixtures.note(2, 3, 'f', octave=4)) layers = [tuple((p, h) for p in generator.solo.placement.positions(n.pitch.number()) for h in range(max(1, p.fret - 3), p.fret + 1)) for n in notes] expected = [] for path in itertools.product(*layers): effort = sum(200 * (abs(h - k) + abs((p.fret - h) - (q.fret - k)) + abs(p.string_number - q.string_number)) for (p, h), (q, k) in zip(path, path[1:])) expected.append((sum(abs(h - k) for (_, h), (_, k) in zip(path, path[1:])), max(h for _, h in path), effort)) result = generator.solo.placement.paths(notes, fractions.Fraction(120))[0] self.assertEqual((result.travel, result.peak, result.effort), min(expected)) self.assertEqual([(n.pitch, n.start, n.end) for n in result.notes], [(n.pitch, n.start, n.end) for n in notes]) report = generator.solo.placement.describe(result.notes, fractions.Fraction(120)) self.assertEqual(fractions.Fraction(report['secondary_effort']), sum(fractions.Fraction(r['secondary_effort']) for r in report['positions'])) def test_tie_split_never_creates_a_shift_or_shorter_travel_time(self): merged = (tests.solo_fixtures.note(0, 3, 'e', octave=4), tests.solo_fixtures.note(3, 4, 'g', octave=4)) split = (dataclasses.replace(merged[0], end=fractions.Fraction(1)), dataclasses.replace(merged[0], start=fractions.Fraction(1), continuation=True), merged[1]) a, b = (generator.solo.placement.paths(notes, fractions.Fraction(120))[0] for notes in (merged, split)) self.assertEqual((a.travel, a.peak, a.effort), (b.travel, b.peak, b.effort)) self.assertEqual([n.position for n in a.notes], [b.notes[0].position, b.notes[2].position]) self.assertEqual((b.notes[0].position, b.hands[0]), (b.notes[1].position, b.hands[1])) report = generator.solo.placement.describe(b.notes, fractions.Fraction(120)) self.assertEqual(report['positions'][1]['secondary_effort'], '0') def test_guitar_cost_composes_peak_with_maximum(self): first = generator.guitar.VoicingCost(effort=3, peak_position=4, register=fractions.Fraction(1, 2)) second = generator.guitar.VoicingCost(effort=2, peak_position=7, register=fractions.Fraction(1, 3)) result = first.plus(second) self.assertEqual((result.effort, result.peak_position, result.register), (5, 7, fractions.Fraction(5, 6)))