//+------------------------------------------------------------------+
//|                                       TestCalendarAnalytics.mq5  |
//+------------------------------------------------------------------+
#property description "Verifies date normalization, aggregation, and calendar "
#property description "grid mapping using synthetic data, independent of any "
#property description "live account history or chart rendering."
#property script_show_inputs

#include <CalendarHeatmapDashboard/CalendarTypes.mqh>
#include <CalendarHeatmapDashboard/DailyAggregator.mqh>
#include <CalendarHeatmapDashboard/CalendarGridMapper.mqh>

//--- assertion counters, updated by the ASSERT macro used throughout this script
int g_assertion_passes   = 0;
int g_assertion_failures = 0;

//+--------------------------------------------------------------------+
//| ASSERT                                                             |
//| Records a pass or a failure for one checked condition and prints a |
//| diagnostic message, including the source line number, whenever the |
//| condition is false. Using a macro keeps every test function terse  |
//| while still reporting exactly which check failed and why.          |
//+--------------------------------------------------------------------+
#define ASSERT(condition, message)                                          \
   if(!(condition))                                                         \
     {                                                                      \
      ::PrintFormat("ASSERTION FAILED: %s (line %d)", (message), __LINE__); \
      g_assertion_failures++;                                               \
     }                                                                      \
   else                                                                     \
     {                                                                      \
      g_assertion_passes++;                                                 \
     }

//+----------------------------------------------------------------------+
//| MakeDateTime                                                         |
//| Builds a datetime from explicit calendar and clock components. Tests |
//| use this helper instead of string parsing so that every synthetic    |
//| date in this file is unambiguous and easy to verify by inspection.   |
//+----------------------------------------------------------------------+
datetime MakeDateTime(int year,int month,int day,int hour=0,int minute=0,int second=0)
  {
   MqlDateTime time_parts;
   time_parts.year = year;
   time_parts.mon  = month;
   time_parts.day  = day;
   time_parts.hour = hour;
   time_parts.min  = minute;
   time_parts.sec  = second;
   time_parts.day_of_week = 0;
   time_parts.day_of_year = 0;
   return(::StructToTime(time_parts));
  }

//+-----------------------------------------------------------------------+
//| TestDateNormalization                                                 |
//| Verifies that NormalizeToMidnight strips the hour, minute, and second |
//| components, and that two deals closed at different times on the same  |
//| calendar date normalize to an identical midnight timestamp.           |
//+-----------------------------------------------------------------------+
void TestDateNormalization(void)
  {
   CDailyAggregator aggregator;
   datetime with_time = MakeDateTime(2026, 3, 14, 17, 45, 30);
   datetime expected  = MakeDateTime(2026, 3, 14, 0, 0, 0);
   datetime actual    = aggregator.NormalizeToMidnight(with_time);
   ASSERT(actual == expected, "NormalizeToMidnight must strip hour, minute, and second components.");

   datetime deal_a = MakeDateTime(2026, 3, 14, 9, 0, 0);
   datetime deal_b = MakeDateTime(2026, 3, 14, 21, 30, 0);
   ASSERT(aggregator.NormalizeToMidnight(deal_a) == aggregator.NormalizeToMidnight(deal_b),
          "Two deals on the same calendar date must normalize to an identical midnight timestamp.");
  }

//+-----------------------------------------------------------------------+
//| TestSameDateAggregation                                               |
//| Verifies that multiple deals on the same calendar date collapse into  |
//| exactly one daily record, with correctly summed P&L and trade count.  |
//+-----------------------------------------------------------------------+
void TestSameDateAggregation(void)
  {
   CDealSample deals[];
   ArrayResize(deals, 2);
   deals[0].m_close_time = MakeDateTime(2026, 4, 6, 10, 0, 0);
   deals[0].m_net_profit = 25.50;
   deals[1].m_close_time = MakeDateTime(2026, 4, 6, 15, 30, 0);
   deals[1].m_net_profit = -10.00;

   CDailyAggregator aggregator;
   CDailyRecord     days[];
   aggregator.Aggregate(deals, ArraySize(deals), days);

   ASSERT(ArraySize(days) == 1, "Two deals on the same calendar date must aggregate into a single daily record.");
   ASSERT(MathAbs(days[0].m_daily_pnl - 15.50) < 0.0001, "Daily P&L must equal the sum of same-date net profits.");
   ASSERT(days[0].m_trade_count == 2, "Trade count must equal the number of deals aggregated on that date.");
  }

//+------------------------------------------------------------------------+
//| TestMultiDateAggregation                                               |
//| Verifies that deals on distinct calendar dates produce distinct daily  |
//| records, each with its own correctly isolated P&L and trade count.     |
//+------------------------------------------------------------------------+
void TestMultiDateAggregation(void)
  {
   CDealSample deals[];
   ArrayResize(deals, 5);
   deals[0].m_close_time = MakeDateTime(2026, 5, 4, 9, 0, 0);
   deals[0].m_net_profit = 12.00;
   deals[1].m_close_time = MakeDateTime(2026, 5, 4, 14, 0, 0);
   deals[1].m_net_profit = 8.00;
   deals[2].m_close_time = MakeDateTime(2026, 5, 5, 10, 0, 0);
   deals[2].m_net_profit = -30.00;
   deals[3].m_close_time = MakeDateTime(2026, 5, 6, 11, 0, 0);
   deals[3].m_net_profit = 0.00;
   deals[4].m_close_time = MakeDateTime(2026, 5, 6, 16, 0, 0);
   deals[4].m_net_profit = 0.00;

   CDailyAggregator aggregator;
   CDailyRecord     days[];
   aggregator.Aggregate(deals, ArraySize(deals), days);

   ASSERT(ArraySize(days) == 3, "Three distinct calendar dates must produce three daily records.");

   datetime day1 = aggregator.NormalizeToMidnight(MakeDateTime(2026, 5, 4, 0, 0, 0));
   datetime day2 = aggregator.NormalizeToMidnight(MakeDateTime(2026, 5, 5, 0, 0, 0));
   datetime day3 = aggregator.NormalizeToMidnight(MakeDateTime(2026, 5, 6, 0, 0, 0));

   for(int i = 0; i < ArraySize(days); i++)
     {
      if(days[i].m_date == day1)
        {
         ASSERT(MathAbs(days[i].m_daily_pnl - 20.00) < 0.0001, "May 4 daily P&L must equal the sum of its two deals.");
         ASSERT(days[i].m_trade_count == 2, "May 4 trade count must equal two.");
        }
      else
         if(days[i].m_date == day2)
           {
            ASSERT(MathAbs(days[i].m_daily_pnl - (-30.00)) < 0.0001, "May 5 daily P&L must equal its single negative deal.");
            ASSERT(days[i].m_trade_count == 1, "May 5 trade count must equal one.");
           }
         else
            if(days[i].m_date == day3)
              {
               ASSERT(days[i].m_daily_pnl == 0.0, "May 6 daily P&L must equal exactly zero.");
               ASSERT(days[i].m_trade_count == 2, "May 6 trade count must equal two even though the net P&L is zero.");
              }
     }
  }

//+------------------------------------------------------------------------+
//| TestExactZeroPnl                                                       |
//| Verifies that offsetting deals on the same date aggregate to an exact  |
//| zero daily P&L, and that the record still carries the correct trade    |
//| count rather than being mistaken for a date with no trading activity.  |
//+------------------------------------------------------------------------+
void TestExactZeroPnl(void)
  {
   CDealSample deals[];
   ArrayResize(deals, 2);
   deals[0].m_close_time = MakeDateTime(2026, 6, 1, 9, 0, 0);
   deals[0].m_net_profit = 40.00;
   deals[1].m_close_time = MakeDateTime(2026, 6, 1, 13, 0, 0);
   deals[1].m_net_profit = -40.00;

   CDailyAggregator aggregator;
   CDailyRecord     days[];
   aggregator.Aggregate(deals, ArraySize(deals), days);

   ASSERT(ArraySize(days) == 1, "Offsetting deals on the same date must still produce exactly one daily record.");
   ASSERT(days[0].m_daily_pnl == 0.0, "Offsetting deals must aggregate to an exact zero daily P&L.");
   ASSERT(days[0].m_trade_count == 2, "Trade count must still reflect both deals even though the net P&L is zero.");
  }

//+------------------------------------------------------------------------+
//| TestWeekdayMapping                                                     |
//| Verifies that every day of the week from Sunday through Saturday maps  |
//| to its expected weekday row, using a mid-week start date so that the   |
//| mapping is exercised for both ends of the week.                        |
//+------------------------------------------------------------------------+
void TestWeekdayMapping(void)
  {
   CCalendarGridMapper mapper;
   mapper.SetStartDate(MakeDateTime(2026, 1, 1, 0, 0, 0)); // Thursday, January 1, 2026

   ASSERT(mapper.GetWeekdayRow(MakeDateTime(2026, 1, 4, 0, 0, 0)) == 0, "January 4, 2026 is a Sunday and must map to weekday row 0.");
   ASSERT(mapper.GetWeekdayRow(MakeDateTime(2026, 1, 5, 0, 0, 0)) == 1, "January 5, 2026 is a Monday and must map to weekday row 1.");
   ASSERT(mapper.GetWeekdayRow(MakeDateTime(2026, 1, 6, 0, 0, 0)) == 2, "January 6, 2026 is a Tuesday and must map to weekday row 2.");
   ASSERT(mapper.GetWeekdayRow(MakeDateTime(2026, 1, 7, 0, 0, 0)) == 3, "January 7, 2026 is a Wednesday and must map to weekday row 3.");
   ASSERT(mapper.GetWeekdayRow(MakeDateTime(2026, 1, 1, 0, 0, 0)) == 4, "January 1, 2026 is a Thursday and must map to weekday row 4.");
   ASSERT(mapper.GetWeekdayRow(MakeDateTime(2026, 1, 2, 0, 0, 0)) == 5, "January 2, 2026 is a Friday and must map to weekday row 5.");
   ASSERT(mapper.GetWeekdayRow(MakeDateTime(2026, 1, 3, 0, 0, 0)) == 6, "January 3, 2026 is a Saturday and must map to weekday row 6.");
  }

//+------------------------------------------------------------------------+
//| TestStartAndEndOfWeekMapping                                           |
//| Verifies week-column boundaries using a mid-week start date. Confirms  |
//| that the partial first week and the following full week both resolve   |
//| to the expected week columns, which is exactly where off-by-one errors |
//| are most likely to appear.                                             |
//+------------------------------------------------------------------------+
void TestStartAndEndOfWeekMapping(void)
  {
   CCalendarGridMapper mapper;
   mapper.SetStartDate(MakeDateTime(2026, 1, 1, 0, 0, 0)); // Thursday, mid-week start

   ASSERT(mapper.GetWeekColumn(MakeDateTime(2026, 1, 1, 0, 0, 0)) == 0, "The start date itself must fall in week column 0.");
   ASSERT(mapper.GetWeekColumn(MakeDateTime(2026, 1, 3, 0, 0, 0)) == 0, "Saturday January 3 must remain in week column 0, the end of the first partial week.");
   ASSERT(mapper.GetWeekColumn(MakeDateTime(2026, 1, 4, 0, 0, 0)) == 1, "Sunday January 4 must begin week column 1, the classic week-boundary off-by-one case.");
   ASSERT(mapper.GetWeekColumn(MakeDateTime(2026, 1, 10, 0, 0, 0)) == 1, "Saturday January 10 must still be the last date of week column 1.");
   ASSERT(mapper.GetWeekColumn(MakeDateTime(2026, 1, 11, 0, 0, 0)) == 2, "Sunday January 11 must begin week column 2.");
  }

//+-----------------------------------------------------------------------+
//| TestMidWeekStartOrigin                                                |
//| Verifies that a range beginning on a weekday other than Sunday still  |
//| derives a correct Sunday-aligned grid origin, which is the mechanism  |
//| that keeps subsequent dates correctly aligned across week columns.    |
//+-----------------------------------------------------------------------+
void TestMidWeekStartOrigin(void)
  {
   CCalendarGridMapper mapper;
   mapper.SetStartDate(MakeDateTime(2026, 1, 1, 0, 0, 0)); // Thursday
   datetime expected_origin = MakeDateTime(2025, 12, 28, 0, 0, 0); // Sunday on or before Jan 1, 2026
   ASSERT(mapper.GetColumnStartDate(0) == expected_origin,
          "Week column 0 must originate on the Sunday on or before a mid-week start date.");
  }

//+-----------------------------------------------------------------------+
//| Script program start function                                         |
//| Runs every test function in sequence and prints a final pass/fail     |
//| summary. This script deliberately performs no chart rendering and     |
//| requires no live account history: every input is synthetic, which is  |
//| what makes the analytical logic testable independent of the visual    |
//| rendering pipeline.                                                   |
//+-----------------------------------------------------------------------+
void OnStart(void)
  {
   g_assertion_passes   = 0;
   g_assertion_failures = 0;

   ::Print("Running TestCalendarAnalytics...");

   TestDateNormalization();
   TestSameDateAggregation();
   TestMultiDateAggregation();
   TestExactZeroPnl();
   TestWeekdayMapping();
   TestStartAndEndOfWeekMapping();
   TestMidWeekStartOrigin();

   ::PrintFormat("TestCalendarAnalytics complete: %d passed, %d failed.", g_assertion_passes, g_assertion_failures);
   if(g_assertion_failures == 0)
      ::Print("TEST SUITE PASSED");
   else
      ::Print("TEST SUITE FAILED");
  }
//+------------------------------------------------------------------+