2026-10-29 14:39:39 +03:00 | | | '''Обновления проверяются на локальном remote, без сетевого репозитория пользователя.'''
|
| | |
|
| | | import os
|
2026-10-31 14:22:28 +03:00 | | | import shutil
|
2026-10-29 14:39:39 +03:00 | | | import subprocess
|
| | | import tempfile
|
| | | import unittest
|
| | | from pathlib import Path
|
| | | from unittest.mock import patch
|
| | |
|
2026-10-31 14:22:28 +03:00 | | | from deploy_service import (SERVICE_EX5_REL, compile_reference, reference_terminal,
|
| | | service_build_paths, services_ini_update, write_services_ini)
|
2026-10-29 14:39:39 +03:00 | | | from project_updates import ProjectUpdates
|
| | |
|
| | |
|
| | | class ProjectUpdateTests(unittest.TestCase):
|
| | | def setUp(self):
|
| | | self.temp = tempfile.TemporaryDirectory()
|
| | | self.root = Path(self.temp.name)
|
| | | self.remote = self.root / 'remote.git'
|
| | | self.writer = self.root / 'writer'
|
| | | self.reference = self.root / 'reference'
|
| | | self.project = self.reference / 'MQL5/Shared Projects/mt5-manager'
|
| | | self.project.parent.mkdir(parents=True)
|
| | | self.git(self.root, 'init', '--bare', str(self.remote))
|
| | | self.git(self.root, 'clone', str(self.remote), str(self.writer))
|
| | | self.git(self.writer, 'checkout', '-b', 'main')
|
| | | self.git(self.writer, 'config', 'user.name', 'Fixture')
|
| | | self.git(self.writer, 'config', 'user.email', 'fixture@example.invalid')
|
| | | (self.writer / 'ServiceStateWriter.mq5').write_text('first source', encoding='utf-8')
|
2026-10-31 14:22:28 +03:00 | | | self.copy_template(self.writer)
|
2026-10-29 14:39:39 +03:00 | | | self.git(self.writer, 'add', '.')
|
| | | self.git(self.writer, 'commit', '-m', 'Initial fixture')
|
| | | self.git(self.writer, 'push', '-u', 'origin', 'main')
|
| | | self.git(self.root, 'clone', '--branch', 'main', str(self.remote), str(self.project))
|
| | | self.updates = ProjectUpdates(self.project)
|
| | |
|
2026-10-31 14:22:28 +03:00 | | | @staticmethod
|
| | | def copy_template(project):
|
| | | (project / 'static').mkdir(parents=True, exist_ok=True)
|
| | | shutil.copyfile(Path(__file__).resolve().parent.parent / 'static/services.ini',
|
| | | project / 'static/services.ini')
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | def tearDown(self):
|
| | | # Git на Windows помечает объекты read-only.
|
| | | for path in self.root.rglob('*'):
|
| | | if path.is_file():
|
| | | path.chmod(0o666)
|
| | | self.temp.cleanup()
|
| | |
|
| | | @staticmethod
|
| | | def git(cwd, *args):
|
| | | return subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True, check=True).stdout.strip()
|
| | |
|
| | | def publish(self):
|
| | | (self.writer / 'ServiceStateWriter.mq5').write_text('second source', encoding='utf-8')
|
| | | self.git(self.writer, 'add', '.')
|
| | | self.git(self.writer, 'commit', '-m', 'Update fixture service')
|
| | | self.git(self.writer, 'push')
|
| | |
|
| | | def test_fetch_preview_and_fast_forward(self):
|
| | | self.publish()
|
| | | before = self.git(self.project, 'rev-parse', 'HEAD')
|
| | | status = self.updates.check()
|
| | | self.assertEqual(status['behind'], 1)
|
| | | self.assertEqual(self.git(self.project, 'rev-parse', 'HEAD'), before)
|
| | | with patch.dict(os.environ, {'MT5_MANAGER_RELOAD': '0'}):
|
| | | result = self.updates.update()
|
| | | self.assertTrue(result['updated'])
|
| | | self.assertEqual((self.project / 'ServiceStateWriter.mq5').read_text(), 'second source')
|
| | | self.assertEqual(self.updates.repository()['behind'], 0)
|
| | |
|
| | | def test_dirty_tree_and_divergence_preserved(self):
|
| | | self.publish()
|
| | | local = self.project / 'local.txt'
|
| | | local.write_text('keep this')
|
| | | with patch.dict(os.environ, {'MT5_MANAGER_RELOAD': '0'}):
|
| | | with self.assertRaisesRegex(RuntimeError, 'локальные изменения'):
|
| | | self.updates.update()
|
| | | self.assertEqual(local.read_text(), 'keep this')
|
| | | self.git(self.project, 'config', 'user.name', 'Fixture')
|
| | | self.git(self.project, 'config', 'user.email', 'fixture@example.invalid')
|
| | | self.git(self.project, 'add', '.')
|
| | | self.git(self.project, 'commit', '-m', 'Local fixture change')
|
| | | before = self.git(self.project, 'rev-parse', 'HEAD')
|
| | | with patch.dict(os.environ, {'MT5_MANAGER_RELOAD': '0'}):
|
| | | with self.assertRaisesRegex(RuntimeError, 'свои коммиты'):
|
| | | self.updates.update()
|
| | | self.assertEqual(self.git(self.project, 'rev-parse', 'HEAD'), before)
|
| | |
|
| | | def test_reload_mode_rejects_update(self):
|
| | | with patch.dict(os.environ, {'MT5_MANAGER_RELOAD': '1'}), \
|
| | | patch.object(self.updates, 'git') as git:
|
| | | with self.assertRaisesRegex(RuntimeError, '--reload'):
|
| | | self.updates.update()
|
| | | git.assert_not_called()
|
| | |
|
2026-10-31 07:26:44 +03:00 | | | def test_deploy_uses_base_folder_ignores_old_paths_and_reports_partial_failure(self):
|
2026-10-29 14:39:39 +03:00 | | | (self.reference / 'MetaEditor64.exe').write_bytes(b'fixture')
|
| | | source = self.reference / SERVICE_EX5_REL
|
| | | source.parent.mkdir(parents=True)
|
| | | source.write_bytes(b'old-ex5')
|
2026-10-31 07:26:44 +03:00 | | | base = self.root / 'instances'
|
| | | target = base / 'Custom'
|
2026-10-29 14:39:39 +03:00 | | | target.mkdir(parents=True)
|
| | | (target / 'terminal64.exe').write_bytes(b'fixture')
|
2026-10-31 07:26:44 +03:00 | | | old_target = self.root / 'old-base/Custom'
|
| | | (old_target / SERVICE_EX5_REL).parent.mkdir(parents=True)
|
| | | (old_target / SERVICE_EX5_REL).write_bytes(b'keep-old-location')
|
2026-10-31 14:22:28 +03:00 | | | data = {'mt5_folder': str(base), 'mt5_exe': 'terminal64.exe', 'database': 'custom.sqlite', 'terminals': {
|
2026-10-31 07:26:44 +03:00 | | | 'Custom': {'path': str(old_target)}, 'Missing': {'path': str(self.root / 'missing')}}}
|
2026-10-31 14:22:28 +03:00 | | | ini_path, ini_text, _ = services_ini_update(target, self.project / 'static/services.ini', data['database'])
|
| | | write_services_ini(ini_path, ini_text)
|
2026-10-29 14:39:39 +03:00 | | | self.assertTrue(self.updates.service_status(data)['pending'])
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | def compile_service(editor, mq5, output, *, force=False):
|
2026-10-29 14:39:39 +03:00 | | | self.assertTrue(force)
|
| | | source.write_bytes(b'new-ex5')
|
| | | return True
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | with patch('project_updates.compile_service', side_effect=compile_service):
|
2026-10-29 14:39:39 +03:00 | | | result = self.updates.deploy(data, lambda folder: folder == 'Custom')
|
| | | self.assertEqual((target / SERVICE_EX5_REL).read_bytes(), b'new-ex5')
|
| | | self.assertEqual(result['copied'], ['Custom'])
|
| | | self.assertEqual(result['failed'], ['Missing'])
|
2026-10-31 07:26:44 +03:00 | | | self.assertIn(str(base / 'Missing/terminal64.exe'), result['errors']['Missing'])
|
2026-10-29 14:39:39 +03:00 | | | self.assertEqual(result['restart_required'], ['Custom'])
|
2026-10-31 07:26:44 +03:00 | | | self.assertFalse((base / 'Missing').exists())
|
| | | self.assertEqual((old_target / SERVICE_EX5_REL).read_bytes(), b'keep-old-location')
|
2026-10-29 14:39:39 +03:00 | | |
|
2026-10-31 07:14:06 +03:00 | | | def test_deploy_reports_windows_copy_error_and_preserves_previous_service(self):
|
| | | source = self.reference / SERVICE_EX5_REL
|
| | | source.parent.mkdir(parents=True)
|
| | | source.write_bytes(b'new-build')
|
2026-10-31 07:26:44 +03:00 | | | target = self.root / 'One'
|
2026-10-31 07:14:06 +03:00 | | | destination = target / SERVICE_EX5_REL
|
| | | destination.parent.mkdir(parents=True)
|
| | | destination.write_bytes(b'old-build')
|
| | | (target / 'terminal64.exe').write_bytes(b'terminal')
|
2026-10-31 14:22:28 +03:00 | | | data = {'mt5_folder': str(self.root), 'mt5_exe': 'terminal64.exe', 'database': 'custom.sqlite', 'terminals': {'One': {}}}
|
2026-10-31 07:14:06 +03:00 | | | error = PermissionError(13, 'Access is denied', str(destination))
|
| | | with patch('project_updates.compile_service', return_value=True), \
|
| | | patch('project_updates.os.replace', side_effect=error):
|
| | | result = self.updates.deploy(data, lambda _: False)
|
| | | self.assertEqual(result['failed'], ['One'])
|
| | | self.assertEqual(result['copied'], [])
|
| | | self.assertIn('Access is denied', result['errors']['One'])
|
| | | self.assertIn(str(destination), result['errors']['One'])
|
| | | self.assertIn('Замена EX5', result['errors']['One'])
|
| | | self.assertEqual(destination.read_bytes(), b'old-build')
|
| | | self.assertFalse(list(destination.parent.glob('*.tmp')))
|
| | |
|
2026-10-29 14:39:39 +03:00 | | | def test_failed_compilation_never_distributes_previous_ex5(self):
|
2026-10-31 07:26:44 +03:00 | | | target = self.root / 'One'
|
2026-10-29 14:39:39 +03:00 | | | destination = target / SERVICE_EX5_REL
|
| | | destination.parent.mkdir(parents=True)
|
| | | destination.write_bytes(b'keep-old-service')
|
2026-10-31 14:22:28 +03:00 | | | data = {'mt5_folder': str(self.root), 'mt5_exe': 'terminal64.exe', 'database': 'custom.sqlite', 'terminals': {'One': {}}}
|
2026-10-31 06:48:02 +03:00 | | | with patch('project_updates.compile_service', return_value=False):
|
2026-10-29 14:39:39 +03:00 | | | with self.assertRaisesRegex(RuntimeError, 'не подтверждена'):
|
| | | self.updates.deploy(data, lambda _: False)
|
| | | self.assertEqual(destination.read_bytes(), b'keep-old-service')
|
| | |
|
| | | def test_force_build_does_not_accept_metaeditor_no_op(self):
|
| | | (self.reference / 'MetaEditor64.exe').write_bytes(b'fixture')
|
| | | output = self.reference / SERVICE_EX5_REL
|
| | | output.parent.mkdir(parents=True)
|
| | | output.write_bytes(b'old-build')
|
| | | with patch('deploy_service.run') as run:
|
| | | self.assertFalse(compile_reference(self.reference, force=True))
|
| | | run.assert_called_once()
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | def test_standalone_status_and_deploy_use_uploaded_editor_and_current_project(self):
|
| | | project = self.root / 'standalone'
|
| | | project.mkdir()
|
2026-10-31 14:22:28 +03:00 | | | self.copy_template(project)
|
2026-10-31 06:48:02 +03:00 | | | (project / 'ServiceStateWriter.mq5').write_text('standalone source')
|
| | | template = self.root / 'uploads'
|
| | | template.mkdir()
|
| | | (template / 'MetaEditor64.exe').write_bytes(b'editor')
|
2026-10-31 07:26:44 +03:00 | | | target = self.root / 'One'
|
2026-10-31 06:48:02 +03:00 | | | target.mkdir()
|
| | | (target / 'terminal64.exe').write_bytes(b'terminal')
|
| | | data = {'terminal_template': str(template), 'mt5_exe': 'terminal64.exe',
|
2026-10-31 14:22:28 +03:00 | | | 'mt5_folder': str(self.root), 'database': 'custom.sqlite', 'terminals': {'One': {}}}
|
2026-10-31 06:48:02 +03:00 | | | updates = ProjectUpdates(project)
|
| | | self.assertTrue(updates.service_status(data)['available'])
|
| | | self.assertTrue(updates.service_status(data)['needs_compile'])
|
| | |
|
| | | def compile_service(editor, source, output, *, force=False):
|
| | | self.assertEqual(editor, template / 'MetaEditor64.exe')
|
| | | self.assertEqual(source.read_text(), 'standalone source')
|
| | | self.assertTrue(force)
|
| | | output.write_bytes(b'standalone build')
|
| | | return True
|
| | |
|
| | | with patch('project_updates.compile_service', side_effect=compile_service):
|
| | | result = updates.deploy(data, lambda _: False)
|
| | | self.assertEqual(result['copied'], ['One'])
|
| | | self.assertEqual((target / SERVICE_EX5_REL).read_bytes(), b'standalone build')
|
2026-10-31 14:22:28 +03:00 | | | self.assertIn('InpDatabase=custom.sqlite', (target / 'config/services.ini').read_text(encoding='utf-16'))
|
2026-10-31 06:48:02 +03:00 | | | self.assertFalse(updates.service_status(data)['pending'])
|
| | |
|
2026-10-31 14:22:28 +03:00 | | | def test_running_terminal_requires_stop_before_autostart_repair(self):
|
| | | source = self.reference / SERVICE_EX5_REL
|
| | | source.parent.mkdir(parents=True)
|
| | | source.write_bytes(b'compiled')
|
| | | target = self.root / 'One'
|
| | | target.mkdir()
|
| | | (target / 'terminal64.exe').write_bytes(b'terminal')
|
| | | data = {'mt5_folder': str(self.root), 'mt5_exe': 'terminal64.exe',
|
| | | 'database': 'custom.sqlite', 'terminals': {'One': {}}}
|
| | | with patch('project_updates.compile_service', return_value=True):
|
| | | result = self.updates.deploy(data, lambda _: True)
|
| | | self.assertEqual(result['failed'], ['One'])
|
| | | self.assertIn('Остановите терминал', result['errors']['One'])
|
| | | self.assertFalse((target / 'config/services.ini').exists())
|
| | |
|
2026-10-31 06:48:02 +03:00 | | | def test_missing_compiler_at_filesystem_root_does_not_index_ancestors(self):
|
| | | project = Path(self.root.anchor)
|
| | | self.assertIsNone(reference_terminal(project))
|
| | | editor, source, output = service_build_paths(project, {})
|
| | | self.assertEqual(source, project / 'ServiceStateWriter.mq5')
|
| | | self.assertEqual(output, source.with_suffix('.ex5'))
|
| | | self.assertEqual(editor, project / 'terminal-template/MetaEditor64.exe')
|
| | |
|
2026-10-29 14:39:39 +03:00 | | |
|
| | | if __name__ == '__main__':
|
| | | unittest.main()
|