mirror of
https://github.com/acamarata/moon-sighting-dart.git
synced 2026-07-01 03:04:36 +00:00
441 lines
14 KiB
Dart
441 lines
14 KiB
Dart
import 'package:moon_sighting/moon_sighting.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
// ── Constants ────────────────────────────────────────────────────────────
|
|
|
|
group('Constants', () {
|
|
test('Yallop threshold A is 0.216', () {
|
|
expect(yallopThresholds[YallopCategory.a], equals(0.216));
|
|
});
|
|
|
|
test('Yallop threshold E is -0.293', () {
|
|
expect(yallopThresholds[YallopCategory.e], equals(-0.293));
|
|
});
|
|
|
|
test('All Yallop thresholds are defined', () {
|
|
for (final cat in [
|
|
YallopCategory.a,
|
|
YallopCategory.b,
|
|
YallopCategory.c,
|
|
YallopCategory.d,
|
|
YallopCategory.e,
|
|
]) {
|
|
expect(yallopThresholds[cat], isNotNull);
|
|
}
|
|
});
|
|
|
|
test('Yallop thresholds descend A > B > C > D > E', () {
|
|
expect(
|
|
yallopThresholds[YallopCategory.a]!,
|
|
greaterThan(yallopThresholds[YallopCategory.b]!),
|
|
);
|
|
expect(
|
|
yallopThresholds[YallopCategory.b]!,
|
|
greaterThan(yallopThresholds[YallopCategory.c]!),
|
|
);
|
|
expect(
|
|
yallopThresholds[YallopCategory.c]!,
|
|
greaterThan(yallopThresholds[YallopCategory.d]!),
|
|
);
|
|
expect(
|
|
yallopThresholds[YallopCategory.d]!,
|
|
greaterThan(yallopThresholds[YallopCategory.e]!),
|
|
);
|
|
});
|
|
|
|
test('Odeh threshold A is 5.65', () {
|
|
expect(odehThresholds[OdehZone.a], equals(5.65));
|
|
});
|
|
|
|
test('Odeh threshold C is -0.96', () {
|
|
expect(odehThresholds[OdehZone.c], equals(-0.96));
|
|
});
|
|
|
|
test('Odeh thresholds descend A > B > C', () {
|
|
expect(
|
|
odehThresholds[OdehZone.a]!,
|
|
greaterThan(odehThresholds[OdehZone.b]!),
|
|
);
|
|
expect(
|
|
odehThresholds[OdehZone.b]!,
|
|
greaterThan(odehThresholds[OdehZone.c]!),
|
|
);
|
|
});
|
|
|
|
test('WGS84.a is 6378137.0', () {
|
|
expect(WGS84.a, equals(6378137.0));
|
|
});
|
|
|
|
test('WGS84.e2 is positive and < 1', () {
|
|
expect(WGS84.e2, greaterThan(0));
|
|
expect(WGS84.e2, lessThan(1));
|
|
});
|
|
|
|
test('WGS84.b < WGS84.a (oblate spheroid)', () {
|
|
expect(WGS84.b, lessThan(WGS84.a));
|
|
});
|
|
|
|
test('Yallop descriptions are non-empty strings', () {
|
|
for (final cat in YallopCategory.values) {
|
|
expect(yallopDescriptions[cat], isNotEmpty);
|
|
}
|
|
});
|
|
|
|
test('Odeh descriptions are non-empty strings', () {
|
|
for (final zone in OdehZone.values) {
|
|
expect(odehDescriptions[zone], isNotEmpty);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── getMoonPhase ───────────────────────────────────────────────────────
|
|
|
|
final dateMarch1 = DateTime.utc(2025, 3, 1, 12);
|
|
final phaseMarch1 = getMoonPhase(dateMarch1);
|
|
|
|
group('getMoonPhase structure', () {
|
|
test('illumination is in [0, 100]', () {
|
|
expect(phaseMarch1.illumination, greaterThanOrEqualTo(0));
|
|
expect(phaseMarch1.illumination, lessThanOrEqualTo(100));
|
|
});
|
|
|
|
test('age is >= 0', () {
|
|
expect(phaseMarch1.age, greaterThanOrEqualTo(0));
|
|
});
|
|
|
|
test('elongationDeg is in [0, 180]', () {
|
|
expect(phaseMarch1.elongationDeg, greaterThanOrEqualTo(0));
|
|
expect(phaseMarch1.elongationDeg, lessThanOrEqualTo(180));
|
|
});
|
|
|
|
test('nextNewMoon is a DateTime', () {
|
|
expect(phaseMarch1.nextNewMoon, isA<DateTime>());
|
|
});
|
|
|
|
test('prevNewMoon is before reference date', () {
|
|
expect(phaseMarch1.prevNewMoon.isBefore(dateMarch1), isTrue);
|
|
});
|
|
|
|
test('nextNewMoon is after prevNewMoon', () {
|
|
expect(phaseMarch1.nextNewMoon.isAfter(phaseMarch1.prevNewMoon), isTrue);
|
|
});
|
|
});
|
|
|
|
group('getMoonPhase phase boundaries', () {
|
|
test('near full moon: illumination > 85%', () {
|
|
final phaseFull = getMoonPhase(DateTime.utc(2025, 3, 14, 12));
|
|
expect(phaseFull.illumination, greaterThan(85));
|
|
});
|
|
|
|
test('near full moon: elongation > 120 deg', () {
|
|
final phaseFull = getMoonPhase(DateTime.utc(2025, 3, 14, 12));
|
|
expect(phaseFull.elongationDeg, greaterThan(120));
|
|
});
|
|
|
|
test('near new moon: illumination < 10%', () {
|
|
final phaseNew = getMoonPhase(DateTime.utc(2025, 3, 29, 12));
|
|
expect(phaseNew.illumination, lessThan(10));
|
|
});
|
|
|
|
test('near new moon: elongation < 30 deg', () {
|
|
final phaseNew = getMoonPhase(DateTime.utc(2025, 3, 29, 12));
|
|
expect(phaseNew.elongationDeg, lessThan(30));
|
|
});
|
|
});
|
|
|
|
group('getMoonPhase consistency', () {
|
|
test('5 days after new moon: isWaxing = true', () {
|
|
expect(getMoonPhase(DateTime.utc(2025, 3, 5, 12)).isWaxing, isTrue);
|
|
});
|
|
|
|
test('6 days after full moon: isWaxing = false', () {
|
|
expect(getMoonPhase(DateTime.utc(2025, 3, 20, 12)).isWaxing, isFalse);
|
|
});
|
|
|
|
test('default date (now) returns valid result', () {
|
|
final nowPhase = getMoonPhase();
|
|
expect(nowPhase.illumination, greaterThanOrEqualTo(0));
|
|
expect(nowPhase.illumination, lessThanOrEqualTo(100));
|
|
});
|
|
|
|
test('synodic month duration is ~29.5 days', () {
|
|
final synodicMs =
|
|
phaseMarch1.nextNewMoon
|
|
.difference(phaseMarch1.prevNewMoon)
|
|
.inMilliseconds;
|
|
final synodicDays = synodicMs / 86400000;
|
|
expect(synodicDays, greaterThan(29.0));
|
|
expect(synodicDays, lessThan(30.1));
|
|
});
|
|
});
|
|
|
|
group('getMoonPhase phaseName and phaseSymbol', () {
|
|
test('waxing crescent: phaseName is Waxing Crescent', () {
|
|
final p = getMoonPhase(DateTime.utc(2025, 3, 5, 12));
|
|
expect(p.phaseName, equals('Waxing Crescent'));
|
|
});
|
|
|
|
test('phaseName is a valid string', () {
|
|
final validNames = {
|
|
'New Moon',
|
|
'Waxing Crescent',
|
|
'First Quarter',
|
|
'Waxing Gibbous',
|
|
'Full Moon',
|
|
'Waning Gibbous',
|
|
'Last Quarter',
|
|
'Waning Crescent',
|
|
};
|
|
final p = getMoonPhase(dateMarch1);
|
|
expect(validNames.contains(p.phaseName), isTrue);
|
|
});
|
|
});
|
|
|
|
// ── getMoonPosition ────────────────────────────────────────────────────
|
|
|
|
group('getMoonPosition', () {
|
|
final moonPos = getMoonPosition(
|
|
DateTime.utc(2025, 3, 14, 20),
|
|
51.5074,
|
|
-0.1278,
|
|
elevation: 10,
|
|
);
|
|
|
|
test('azimuth in [0, 360)', () {
|
|
expect(moonPos.azimuth, greaterThanOrEqualTo(0));
|
|
expect(moonPos.azimuth, lessThan(360));
|
|
});
|
|
|
|
test('altitude in [-90, 90]', () {
|
|
expect(moonPos.altitude, greaterThanOrEqualTo(-90));
|
|
expect(moonPos.altitude, lessThanOrEqualTo(90));
|
|
});
|
|
|
|
test('distance in lunar orbit range [356000, 407000] km', () {
|
|
expect(moonPos.distance, greaterThanOrEqualTo(356000));
|
|
expect(moonPos.distance, lessThanOrEqualTo(407000));
|
|
});
|
|
|
|
test('parallacticAngle is finite', () {
|
|
expect(moonPos.parallacticAngle.isFinite, isTrue);
|
|
});
|
|
|
|
test('default date returns valid result', () {
|
|
final pos = getMoonPosition(null, 21.4225, 39.8262);
|
|
expect(pos.azimuth, greaterThanOrEqualTo(0));
|
|
expect(pos.azimuth, lessThan(360));
|
|
expect(pos.distance, greaterThan(350000));
|
|
expect(pos.distance, lessThan(410000));
|
|
});
|
|
});
|
|
|
|
// ── getMoonIllumination ────────────────────────────────────────────────
|
|
|
|
group('getMoonIllumination', () {
|
|
final illumFull = getMoonIllumination(DateTime.utc(2025, 3, 14, 12));
|
|
final illumNew = getMoonIllumination(DateTime.utc(2025, 3, 29, 12));
|
|
final illumWaxing = getMoonIllumination(DateTime.utc(2025, 3, 5, 12));
|
|
|
|
test('near full moon: fraction > 0.85', () {
|
|
expect(illumFull.fraction, greaterThan(0.85));
|
|
});
|
|
|
|
test('near full moon: phase close to 0.5', () {
|
|
expect(illumFull.phase, greaterThan(0.4));
|
|
expect(illumFull.phase, lessThan(0.6));
|
|
});
|
|
|
|
test('near new moon: fraction < 0.05', () {
|
|
expect(illumNew.fraction, lessThan(0.05));
|
|
});
|
|
|
|
test('near new moon: phase close to 0 or 1', () {
|
|
expect(illumNew.phase < 0.08 || illumNew.phase > 0.92, isTrue);
|
|
});
|
|
|
|
test('waxing: isWaxing = true', () {
|
|
expect(illumWaxing.isWaxing, isTrue);
|
|
});
|
|
|
|
test('fraction in [0, 1]', () {
|
|
expect(illumFull.fraction, greaterThanOrEqualTo(0));
|
|
expect(illumFull.fraction, lessThanOrEqualTo(1));
|
|
expect(illumNew.fraction, greaterThanOrEqualTo(0));
|
|
expect(illumNew.fraction, lessThanOrEqualTo(1));
|
|
});
|
|
|
|
test('phase in [0, 1)', () {
|
|
expect(illumFull.phase, greaterThanOrEqualTo(0));
|
|
expect(illumFull.phase, lessThan(1));
|
|
});
|
|
|
|
test('angle is finite', () {
|
|
expect(illumFull.angle.isFinite, isTrue);
|
|
});
|
|
|
|
test('default date returns valid result', () {
|
|
final illum = getMoonIllumination();
|
|
expect(illum.fraction, greaterThanOrEqualTo(0));
|
|
expect(illum.fraction, lessThanOrEqualTo(1));
|
|
});
|
|
});
|
|
|
|
// ── getMoonVisibilityEstimate ──────────────────────────────────────────
|
|
|
|
group('getMoonVisibilityEstimate', () {
|
|
final vis = getMoonVisibilityEstimate(
|
|
DateTime.utc(2025, 3, 2, 18, 30),
|
|
51.5074,
|
|
-0.1278,
|
|
elevation: 10,
|
|
);
|
|
|
|
test('zone is A, B, C, or D', () {
|
|
expect(OdehZone.values.contains(vis.zone), isTrue);
|
|
});
|
|
|
|
test('V is finite', () {
|
|
expect(vis.v.isFinite, isTrue);
|
|
});
|
|
|
|
test('ARCL is in [0, 180]', () {
|
|
expect(vis.arcl, greaterThanOrEqualTo(0));
|
|
expect(vis.arcl, lessThanOrEqualTo(180));
|
|
});
|
|
|
|
test('W >= 0', () {
|
|
expect(vis.w, greaterThanOrEqualTo(0));
|
|
});
|
|
|
|
test('isApproximate is true', () {
|
|
expect(vis.isApproximate, isTrue);
|
|
});
|
|
|
|
test('isVisibleNakedEye matches zone A', () {
|
|
expect(vis.isVisibleNakedEye, equals(vis.zone == OdehZone.a));
|
|
});
|
|
|
|
test('isVisibleWithOpticalAid matches zone A or B', () {
|
|
expect(
|
|
vis.isVisibleWithOpticalAid,
|
|
equals(vis.zone == OdehZone.a || vis.zone == OdehZone.b),
|
|
);
|
|
});
|
|
|
|
test('near new moon: zone is D or C', () {
|
|
final nearNew = getMoonVisibilityEstimate(
|
|
DateTime.utc(2025, 3, 29, 18),
|
|
21.4225,
|
|
39.8262,
|
|
);
|
|
expect(nearNew.zone == OdehZone.c || nearNew.zone == OdehZone.d, isTrue);
|
|
});
|
|
});
|
|
|
|
// ── getMoon ────────────────────────────────────────────────────────────
|
|
|
|
group('getMoon', () {
|
|
final moon = getMoon(
|
|
DateTime.utc(2025, 3, 5, 20),
|
|
51.5074,
|
|
-0.1278,
|
|
elevation: 10,
|
|
);
|
|
|
|
test('returns object with phase, position, illumination, visibility', () {
|
|
expect(moon.phase, isNotNull);
|
|
expect(moon.position, isNotNull);
|
|
expect(moon.illumination, isNotNull);
|
|
expect(moon.visibility, isNotNull);
|
|
});
|
|
|
|
test('phase is consistent with getMoonPhase standalone', () {
|
|
final standalone = getMoonPhase(DateTime.utc(2025, 3, 5, 20));
|
|
expect(moon.phase.phase, equals(standalone.phase));
|
|
expect(moon.phase.phaseName, equals(standalone.phaseName));
|
|
});
|
|
|
|
test('illumination.isWaxing matches phase.isWaxing', () {
|
|
expect(moon.illumination.isWaxing, equals(moon.phase.isWaxing));
|
|
});
|
|
|
|
test('visibility.isApproximate is true', () {
|
|
expect(moon.visibility.isApproximate, isTrue);
|
|
});
|
|
|
|
test('position has valid azimuth and altitude', () {
|
|
expect(moon.position.azimuth, greaterThanOrEqualTo(0));
|
|
expect(moon.position.azimuth, lessThan(360));
|
|
expect(moon.position.altitude, greaterThanOrEqualTo(-90));
|
|
expect(moon.position.altitude, lessThanOrEqualTo(90));
|
|
});
|
|
});
|
|
|
|
// ── Input validation ──────────────────────────────────────────────────
|
|
|
|
group('Input validation', () {
|
|
test('getMoonPosition rejects latitude out of range', () {
|
|
expect(() => getMoonPosition(DateTime.now(), 91, 0), throwsRangeError);
|
|
});
|
|
|
|
test('getMoonPosition rejects longitude out of range', () {
|
|
expect(() => getMoonPosition(DateTime.now(), 0, 181), throwsRangeError);
|
|
});
|
|
|
|
test('getMoonPosition rejects NaN latitude', () {
|
|
expect(
|
|
() => getMoonPosition(DateTime.now(), double.nan, 0),
|
|
throwsRangeError,
|
|
);
|
|
});
|
|
|
|
test('getMoonVisibilityEstimate rejects invalid coordinates', () {
|
|
expect(
|
|
() => getMoonVisibilityEstimate(DateTime.now(), -91, 0),
|
|
throwsRangeError,
|
|
);
|
|
});
|
|
|
|
test('getMoon rejects invalid coordinates', () {
|
|
expect(() => getMoon(DateTime.now(), 0, 200), throwsRangeError);
|
|
});
|
|
});
|
|
|
|
// ── arcvMinimum polynomial ─────────────────────────────────────────────
|
|
|
|
group('arcvMinimum polynomial', () {
|
|
test('arcvMinimum(0) = 11.8371', () {
|
|
expect(arcvMinimum(0), closeTo(11.8371, 0.0001));
|
|
});
|
|
|
|
test('arcvMinimum decreases with moderate W', () {
|
|
expect(arcvMinimum(1), lessThan(arcvMinimum(0)));
|
|
});
|
|
});
|
|
|
|
// ── nearestNewMoon accuracy ────────────────────────────────────────────
|
|
|
|
group('nearestNewMoon accuracy', () {
|
|
test('2024-04-08 solar eclipse near new moon', () {
|
|
// Known new moon: 2024-04-08 ~18:21 UTC
|
|
final knownNewMoon = DateTime.utc(2024, 4, 8, 18, 21);
|
|
final phase = getMoonPhase(knownNewMoon);
|
|
// Near new moon: illumination should be very low
|
|
expect(phase.illumination, lessThan(1.0));
|
|
expect(phase.elongationDeg, lessThan(5));
|
|
});
|
|
});
|
|
|
|
// ── distanceKm range ──────────────────────────────────────────────────
|
|
|
|
group('distanceKm', () {
|
|
test('distance stays within lunar orbit bounds over a month', () {
|
|
for (var day = 1; day <= 30; day++) {
|
|
final pos = getMoonPosition(DateTime.utc(2025, 3, day, 12), 0, 0);
|
|
expect(pos.distance, greaterThan(355000));
|
|
expect(pos.distance, lessThan(410000));
|
|
}
|
|
});
|
|
});
|
|
}
|