|
| 1 | +import argparse |
| 2 | +import os |
| 3 | +import pytest |
| 4 | +from unittest.mock import patch, mock_open, MagicMock |
| 5 | + |
| 6 | +from structkit.commands.search import SearchCommand |
| 7 | + |
| 8 | + |
| 9 | +@pytest.fixture |
| 10 | +def parser(): |
| 11 | + return argparse.ArgumentParser() |
| 12 | + |
| 13 | + |
| 14 | +def _make_args(parser, query, structures_path=None, names_only=False): |
| 15 | + cmd = SearchCommand(parser) |
| 16 | + argv = [query] |
| 17 | + if structures_path: |
| 18 | + argv += ['-s', structures_path] |
| 19 | + if names_only: |
| 20 | + argv.append('--names-only') |
| 21 | + args = parser.parse_args(argv) |
| 22 | + return cmd, args |
| 23 | + |
| 24 | + |
| 25 | +def test_search_match_by_name(parser): |
| 26 | + """Structures whose name matches the query are returned.""" |
| 27 | + cmd, args = _make_args(parser, 'docker') |
| 28 | + |
| 29 | + yaml_content = b'files: []' |
| 30 | + walk_data = [('/fake/contribs', [], ['docker-files.yaml', 'helm-chart.yaml'])] |
| 31 | + |
| 32 | + def fake_open(path, *a, **kw): |
| 33 | + return mock_open(read_data=yaml_content)() |
| 34 | + |
| 35 | + with patch('os.path.dirname', return_value='/fake/commands'), \ |
| 36 | + patch('os.path.realpath', return_value='/fake/commands'), \ |
| 37 | + patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \ |
| 38 | + patch('os.walk', return_value=walk_data), \ |
| 39 | + patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \ |
| 40 | + patch('builtins.open', side_effect=fake_open), \ |
| 41 | + patch('yaml.safe_load', return_value={}), \ |
| 42 | + patch('builtins.print') as mock_print: |
| 43 | + cmd._search_structures(args) |
| 44 | + |
| 45 | + printed = ' '.join(str(c) for c in mock_print.call_args_list) |
| 46 | + assert 'docker-files' in printed |
| 47 | + assert 'helm-chart' not in printed |
| 48 | + |
| 49 | + |
| 50 | +def test_search_match_by_description(parser): |
| 51 | + """Structures whose description matches the query are returned.""" |
| 52 | + cmd, args = _make_args(parser, 'kubernetes') |
| 53 | + |
| 54 | + configs = { |
| 55 | + 'helm-chart.yaml': {'description': 'Deploy apps to kubernetes clusters'}, |
| 56 | + 'docker-files.yaml': {}, |
| 57 | + } |
| 58 | + walk_data = [('/fake/contribs', [], ['helm-chart.yaml', 'docker-files.yaml'])] |
| 59 | + |
| 60 | + # Track which file is currently being opened so yaml.safe_load can return the right config |
| 61 | + current_path = [None] |
| 62 | + |
| 63 | + def fake_open(path, *a, **kw): |
| 64 | + current_path[0] = path |
| 65 | + return mock_open(read_data=b'')() |
| 66 | + |
| 67 | + def fake_yaml_load(f): |
| 68 | + for basename, config in configs.items(): |
| 69 | + if current_path[0] and current_path[0].endswith(basename): |
| 70 | + return config |
| 71 | + return {} |
| 72 | + |
| 73 | + with patch('os.path.dirname', return_value='/fake/commands'), \ |
| 74 | + patch('os.path.realpath', return_value='/fake/commands'), \ |
| 75 | + patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \ |
| 76 | + patch('os.walk', return_value=walk_data), \ |
| 77 | + patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \ |
| 78 | + patch('builtins.open', side_effect=fake_open), \ |
| 79 | + patch('yaml.safe_load', side_effect=fake_yaml_load), \ |
| 80 | + patch('builtins.print') as mock_print: |
| 81 | + cmd._search_structures(args) |
| 82 | + |
| 83 | + printed = ' '.join(str(c) for c in mock_print.call_args_list) |
| 84 | + assert 'helm-chart' in printed |
| 85 | + assert 'docker-files' not in printed |
| 86 | + |
| 87 | + |
| 88 | +def test_search_no_results(parser): |
| 89 | + """No-match query prints an appropriate message.""" |
| 90 | + cmd, args = _make_args(parser, 'xyznotfound') |
| 91 | + |
| 92 | + walk_data = [('/fake/contribs', [], ['docker-files.yaml'])] |
| 93 | + |
| 94 | + with patch('os.path.dirname', return_value='/fake/commands'), \ |
| 95 | + patch('os.path.realpath', return_value='/fake/commands'), \ |
| 96 | + patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \ |
| 97 | + patch('os.walk', return_value=walk_data), \ |
| 98 | + patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \ |
| 99 | + patch('builtins.open', mock_open(read_data=b'{}')), \ |
| 100 | + patch('yaml.safe_load', return_value={}), \ |
| 101 | + patch('builtins.print') as mock_print: |
| 102 | + cmd._search_structures(args) |
| 103 | + |
| 104 | + printed = ' '.join(str(c) for c in mock_print.call_args_list) |
| 105 | + assert 'No structures found' in printed |
| 106 | + |
| 107 | + |
| 108 | +def test_search_names_only(parser): |
| 109 | + """--names-only prints bare names without decoration.""" |
| 110 | + cmd, args = _make_args(parser, 'docker', names_only=True) |
| 111 | + |
| 112 | + walk_data = [('/fake/contribs', [], ['docker-files.yaml', 'docker-compose.yaml'])] |
| 113 | + |
| 114 | + with patch('os.path.dirname', return_value='/fake/commands'), \ |
| 115 | + patch('os.path.realpath', return_value='/fake/commands'), \ |
| 116 | + patch('os.path.join', side_effect=lambda *parts: '/'.join(parts)), \ |
| 117 | + patch('os.walk', return_value=walk_data), \ |
| 118 | + patch('os.path.relpath', side_effect=lambda fp, base: os.path.basename(fp)), \ |
| 119 | + patch('builtins.open', mock_open(read_data=b'{}')), \ |
| 120 | + patch('yaml.safe_load', return_value={}), \ |
| 121 | + patch('builtins.print') as mock_print: |
| 122 | + cmd._search_structures(args) |
| 123 | + |
| 124 | + calls = [str(c.args[0]) for c in mock_print.call_args_list] |
| 125 | + # Should only print plain names, no emoji or bullet prefix |
| 126 | + assert all('🔍' not in c and ' - ' not in c for c in calls) |
| 127 | + assert any('docker-compose' in c for c in calls) |
| 128 | + assert any('docker-files' in c for c in calls) |
| 129 | + |
| 130 | + |
| 131 | +def test_search_custom_structures_path(parser, tmp_path): |
| 132 | + """Custom --structures-path structures appear with '+' marker.""" |
| 133 | + custom_yaml = tmp_path / 'my-custom.yaml' |
| 134 | + custom_yaml.write_text('description: my custom structure\n') |
| 135 | + |
| 136 | + cmd = SearchCommand(parser) |
| 137 | + args = parser.parse_args(['custom', '-s', str(tmp_path)]) |
| 138 | + |
| 139 | + with patch('builtins.print') as mock_print: |
| 140 | + cmd._search_structures(args) |
| 141 | + |
| 142 | + printed = ' '.join(str(c) for c in mock_print.call_args_list) |
| 143 | + assert 'my-custom' in printed |
| 144 | + assert '+' in printed |
| 145 | + |
| 146 | + |
| 147 | +def test_search_command_registered_in_main(): |
| 148 | + """The search subcommand is registered in the main parser.""" |
| 149 | + from structkit.main import get_parser |
| 150 | + parser = get_parser() |
| 151 | + # If 'search' is not registered, parse_args will error |
| 152 | + args = parser.parse_args(['search', 'docker']) |
| 153 | + assert hasattr(args, 'func') |
| 154 | + assert args.query == 'docker' |
0 commit comments