"""Run: python -m unittest discover -s public/py -p test_observation_suggestions_api.py""" import unittest import astronomy import observation_suggestions_api as api def payload(): return {'location': {'latitude': 50.3774, 'longitude': 11.1899, 'elevation': 640, 'timezone': 'Europe/Berlin'}, 'filters': {'observation_date': '2026-09-15', 'object_types': ['moon', 'planets', 'galaxies'], 'night_halves': ['first', 'second'], 'object_size_min': '', 'object_size_max': '', 'object_magnitude': '12', 'object_altitude_min': '20', 'object_altitude_max': '90', 'object_azimuth_start': '0', 'object_azimuth_end': '360', 'sun_altitude_max': '-12', 'moon_separation_min': '', 'include_unknown_magnitude': ''}, 'objects': [{'id': 'm31', 'name': 'M31', 'group': 'galaxies', 'ra_deg': 10.6847, 'dec_deg': 41.269, 'magnitude': 3.44, 'size_arcsec': 10680}]} class ObservationTests(unittest.TestCase): def test_azimuth_wrap_and_full_circle(self): for value in (0, 20, 60, 300, 350): self.assertTrue(api.azimuth_matches(value, 300, 60)) self.assertFalse(api.azimuth_matches(180, 300, 60)) for value in (0, 90, 180, 359.9): self.assertTrue(api.azimuth_matches(value, 0, 360)) self.assertFalse(api.azimuth_matches(0, 90, 270)) def test_size_exempts_non_deep_sky(self): f = api.validate(payload())[0] f.update(object_size_min=60, object_size_max=600) for group in ('moon', 'planets', 'double_stars', 'variable_stars', 'comets'): self.assertTrue(api.size_matches(None, group, f)) self.assertFalse(api.size_matches(None, 'galaxies', f)) self.assertFalse(api.size_matches(601, 'galaxies', f)) self.assertTrue(api.size_matches(60, 'galaxies', f)) def test_magnitude_direction_unknown_and_zero(self): f = api.validate(payload())[0] self.assertTrue(api.magnitude_matches(-2, f)) self.assertTrue(api.magnitude_matches(12, f)) self.assertFalse(api.magnitude_matches(12.1, f)) self.assertFalse(api.magnitude_matches(None, f)) f['unknown'] = True self.assertTrue(api.magnitude_matches(None, f)) f['object_magnitude'] = 0 self.assertFalse(api.magnitude_matches(1, f)) def test_invalid_input_rejected(self): for key, value in [('object_altitude_min', '91'), ('sun_altitude_max', 'NaN'), ('object_size_min', '-1'), ('observation_date', '2026-02-30'), ('object_types', []), ('night_halves', [])]: p = payload() p['filters'][key] = value with self.subTest(key=key), self.assertRaises(ValueError): api.validate(p) p = payload() p['filters'].update(object_size_min='600', object_size_max='60') with self.assertRaises(ValueError): api.validate(p) def test_frame_matches_library_horizon(self): _, observer, _, start, _ = api.validate(payload()) t = api.astro_time(start) rotation = astronomy.Rotation_EQJ_HOR(t, observer).rot for body in (astronomy.Body.Moon, astronomy.Body.Mars, astronomy.Body.Sun): j2000 = astronomy.Equator(body, t, observer, False, True) ofdate = astronomy.Equator(body, t, observer, True, True) expected = astronomy.Horizon(t, observer, ofdate.ra, ofdate.dec, astronomy.Refraction.Airless) altitude, azimuth = api.horizontal(api.xyz(j2000.vec), rotation) self.assertAlmostEqual(altitude, expected.altitude, places=7) self.assertAlmostEqual(azimuth, expected.azimuth, places=7) def test_night_bounds_and_half_split(self): f, observer, _, start, end = api.validate(payload()) windows = api.night_windows(start, end, observer, f['sun_altitude_max']) self.assertEqual(len(windows), 1) a, b = windows[0] for instant in (a, b): self.assertLessEqual(api.sun_altitude(instant, observer), -12) self.assertAlmostEqual(api.sun_altitude(instant, observer), -12, delta=0.01) first = api.selected_windows(windows, ['first'])[0] second = api.selected_windows(windows, ['second'])[0] self.assertEqual(first[1], second[0]) self.assertEqual(first[1] - first[0], second[1] - second[0]) def test_dst_night_duration(self): for date, hours in [('2026-03-28', 23), ('2026-10-24', 25)]: p = payload() p['filters']['observation_date'] = date _, _, _, a, b = api.validate(p) self.assertEqual((b - a).total_seconds() / 3600, hours) def test_polar_day_empty_and_polar_night_explained(self): p = payload() p['location'].update(latitude=80, longitude=15, timezone='Arctic/Longyearbyen') p['filters']['observation_date'] = '2026-06-21' result = api.calculate(p) self.assertEqual(result['rows'], []) self.assertTrue(result['message']) p['filters']['observation_date'] = '2026-12-21' result = api.calculate(p) self.assertTrue(any('12 Uhr' in warning for warning in result['warnings'])) def test_moon_separation_exemption_and_below_horizon(self): f = api.validate(payload())[0] f['moon_separation_min'] = 60 frame = {'rotation': [[1, 0, 0], [0, 1, 0], [0, 0, 1]], 'moon': (0, 0, 1), 'moon_alt': 90} self.assertIsNone(api.sample_matches((0, 0, 1), 5, {'group': 'galaxies'}, frame, f)) self.assertIsNotNone(api.sample_matches((0, 0, 1), 5, {'group': 'moon'}, frame, f)) frame['moon_alt'] = -5 self.assertIsNotNone(api.sample_matches((0, 0, 1), 5, {'group': 'galaxies'}, frame, f)) def test_real_night_returns_m31_and_matching_positions(self): p = payload() result = api.calculate(p) self.assertTrue(result['ok']) self.assertIn('m31', [row['id'] for row in result['rows']]) for row in result['rows']: self.assertGreaterEqual(row['best']['altitude'], 20) self.assertLessEqual(row['best']['magnitude'], 12) self.assertTrue(row['intervals']) p['filters'].update(object_size_max='1') small = api.calculate(p) self.assertNotIn('m31', [row['id'] for row in small['rows']]) self.assertEqual({r['id'] for r in result['rows'] if r['group'] in ('moon', 'planets')}, {r['id'] for r in small['rows'] if r['group'] in ('moon', 'planets')}) if __name__ == '__main__': unittest.main()