lotrointerface.com
Search Downloads

LoTROInterface SVN Calendar

[/] [Release_3.0/] [FrostyPlugins/] [Calendar/] [DateTime.lua] - Rev 25

Compare with Previous | Blame | View Log

-- A of date time object

import "Turbine";
--import "strict";
import "FrostyPlugins.Calendar.CalTools";

--------------------------------------------------------------------------
--- DateTime
--
-- A date + time object
--   Contains fields: 
--      Year      = the 4 digit year
--          Month     = the month of the year (1 thru 12)
--          Day       = the day of the month (1 thru 31)
--          Hour      = the hour (0-23)
--      Minute    = the minute (0-59)
--      utcOffset = the UTC timezone offset (-11 to +12, where 0 = UTC)
--      isDST     = if Daylight Savings time is active or not
-- See: RFC-822/ISO 8601 standard timezone specification
DateTime = {
        -- Ideally this object would simply be the current seconds since epoch UTC.
        -- However, the epoch to local time routines are not available to LOTRO
        -- so rather than implement them in LUA (non-trivial), instead we'll
        -- capture the fields needed to allow sharing a date in a way that 
        -- supports localization to different timezones.
        Year = 1970;
        Month = 1;
        Day = 1;
        Hour = 0;
        Minute = 0;
        Second = 0;
    DayOfWeek = 1;
    DayOfYear = 1;
        Offset = 0; -- +/- hh:mm - but expressed as minutes
        isDST = false;
};

local expectedTypeMap = {
        Year = "number";
        Month = "number";
        Day = "number";
        Hour = "number";
        Minute = "number";
    Second = "number";
        Offset   = "number";
        isDST = "boolean";
};

--Turbine.Shell.WriteLine("type: " .. type(DateTime.Year));

-- protect the metatable so it cannot be modified
-- DateTime.mt.__metatable = "not your business"
-- print(getmetatable(s1))     --> not your business
--    setmetatable(s1, {})
--      stdin:1: cannot change protected metatable

--------------------------------------------------------------------------
--- getmetatable - returns the metatable for the DateTime
-- Used for validating input arguments of type DateTime
-- @return a new object set to today
function DateTime:getmetatable()
        return getmetatable(o, self); -- inherit operations from DateTime
end

--------------------------------------------------------------------------
--- new - Create a new datetime object - set to the epoch.
-- Some description, can be over several lines.
-- @return a new object set to today
function DateTime:new()
        return DateTime:epoch();
end

--------------------------------------------------------------------------
--- new - Create a new datetime object - set to the epoch.
-- @return a new object set to the start of the epoch
function DateTime:epoch()
        o = {}; -- create NEW object (NOT another reference to *same* object)
        setmetatable(o, self); -- inherit operations from DateTime
        self.__index = self;
        return o;
end

--------------------------------------------------------------------------
--- now - Create a new datetime object - set to now.
-- @return a new object set to now
function DateTime:now()
        --o = o or Turbine.Engine.GetDate();
        --o = o or {}; -- create new object if one not provided
        o = {}; -- create NEW object (NOT another reference to *same* object)
        setmetatable(o, self); -- inherit operations from DateTime
        self.__index = self;
        
        -- initialize from new object.
        -- - intentionally iterate over a DateTime to make sure
        --   ONLY the EXACT fields from the DateTime are copied
        --   and that none are missing
        local now = Turbine.Engine.GetDate();
        for k,v in pairs(DateTime) do
                o[k] = now[k];
        end     
        
        -- TODO - determine local utcOffset
        o.Offset = 0; -- assume zulu time
        
        return o;
end

--------------------------------------------------------------------------
--- clone - Returns a COPY/clone of the current existing datetime object
-- @return a new object copy of the current
function DateTime:clone()

        o = DateTime:new(); -- create NEW object
        
        -- copy values from other object to this object
        for k,v in pairs(self) do
                o[k] = v;
        end
        return o;
end

--------------------------------------------------------------------------
--- validate - Verify the DateTime is valid
-- @param level of caller in callstack to report on (default = 0)
-- @return 1 if is valid
function DateTime:validate(level)
        level = level or 2; -- set default value to zero
        
        -- verify data types
        for k,v in pairs(expectedTypeMap) do
                local actualType = type(self[k]);
                local expectedType = expectedTypeMap[k];
                if( expectedType ~= actualType ) then
                        error("DataObject." .. k .. " type expected to be " .. expectedType .. ", not " .. actualType, level );
                end
        end

        -- verify values within range
        if( self.Year < 1970 ) then
                error( "Invalid Year [" .. self.Year .. "]", level);
        end
        if( (self.Month < 1) or (self.Month > 12) ) then
                error( "Invalid Month [" .. self.Month .. "]", level);
        end
        if( (self.Day < 1) or (self.Day > 31) or
                (self.Day > self:daysInMonth()) ) then
                error( "Invalid Day [" .. self.Day .. "]", level);
        end
        if( (self.Hour < 0) or (self.Hour>=24) ) then
                error( "Invalid Hour [" .. self.Hour .. "]", level);
        end
        if( (self.Minute < 0) or (self.Minute >= 60) ) then
                error( "Invalid Minute [" .. self.Minute .. "]", level);
        end
        if( (self.Second < 0) or (self.Second >= 60) ) then
                error( "Invalid Second [" .. self.Second .. "]", level);
        end
        if( (self.Offset < -11*60) or (self.Offset >= 12*60) ) then
                error( "Invalid Offset [" .. self.Offset .. "]", level);
        end
        return 1;
end

--------------------------------------------------------------------------
--- parse - Create a DateObect from a string in format "yyyy-mm-dd hh:mm"
-- @param str the string to parse
-- @return a new object set to today
function DateTime:parse( str )
        --Turbine.Shell.WriteLine( "DateTime:parse = [" .. str .. "]" );

        -- validate input parameters
        if( "string" ~= type(str) ) then
                error("Parameter expected to be a string, not " .. type(str), 2 );
        end
        
        o = DateTime:new(); -- create NEW object
        
        -- Parse the input string
        local position = nil;
        local positionEnd;
        -- try to parse a time designated as zulu time (zero UTC offset)
        position, positionEnd, o.Year, o.Month, o.Day, o.Hour, o.Minute, o.Second = string.find( 
                str, "(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)Z");
        if( position ) then
        --table.dump("o",o);
                o.Offset = 0;
        else
                -- try to parse with utcOffset
                local offsetS, offsetH, offsetM;
                position, positionEnd, o.Year, o.Month, o.Day, o.Hour, o.Minute, o.Second, offsetS, offsetH, offsetM = string.find( 
                        str, "(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)([+-])(%d%d):(%d%d)");
                if( position ) then
                        --Turbine.Shell.WriteLine( 
                        --      string.format("offsetS=[%s] offsetH=[%s] offsetM=[%s]",
                        --              offsetS, offsetH, offsetM) );
                        local sign = { 
                                ["\-"] = -1;
                                ["\+"] = 1;
                        };
                        o.Offset = sign[offsetS] * (tonumber(offsetH) * 60 + tonumber(offsetM));
                end
        end
        -- try to parse without utcOffset designation
        if( not position ) then                 
                position, positionEnd, o.Year, o.Month, o.Day, o.Hour, o.Minute, o.Second = string.find( 
                        str, "(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d):(%d%d)");
                if( position ) then
                        o.Offset = 0;
                end
        end
        --Turbine.Shell.WriteLine("position:" .. tostring(position) .. " positionEnd:" .. tostring(positionEnd) );
        if( nil == position ) then
                error(string.format("Parameter [%s] did not match expected format: %s",
                            str,
                            'yyyy-mm-ddThh:mm:ss[+-]hh:mm'), 
              2 );
        end

        -- Convert captured strings to numbers
        for k,v in pairs(expectedTypeMap) do
                local expectedType = expectedTypeMap[k];
                if( "number" == expectedType ) then
                        o[k] = tonumber(o[k]);
                elseif( "boolean" == expectedType ) then
                        stringtoboolean={ ["true"]=true, ["false"]=false };
                        o[k] = stringtoboolean[o[k]];
                end
        end
        
        --o.isDST = ?; -- TODO, how to compute correct value for this?

        -- validate!
        o:validate(3);
        --local status, err = pcall( DateTime.validate, self, 3);
        --if( not status ) then
        --      Turbine.Shell.WriteLine(string.format("validate[%s] - error = [%s]",self:tostring(),err));
        --      return null
        --end
        --if( pcall( DateTime.validate, self, 3) ) then
        --      Turbine.Shell.WriteLine("validate - INVALID DATE");
        --      return nil;
        --end
        --function testValidate()
        --      self:validate(3);
        --end
        --function myerrorhandler( err )
        --      --print( "ERROR:", err )
        --      Turbine.Shell.WriteLine("TRAPERROR: " .. err);
        --end   
        --if( xpcall( testValidate, myerrorhandler) ) then
        --      Turbine.Shell.WriteLine("validate - INVALID DATE");
        --      return nil;
        --end
        
        return o;
end

--------------------------------------------------------------------------
--- set - apply a table of field values to the current datetime object
-- Can specify multiple fields such as "set({Hour=0,Minute=0})"
-- @param mods a table of modifications
-- @return the modified object
function DateTime:set( mods )
        -- validate input parameters
        if( "table" ~= type(mods) ) then
                error("Parameter expected to be a table of modification, not " .. type(other), 2 );
        end

        -- iterate over mods provided and *copy* values to this object
        for k,v in pairs(mods) do
                --Turbine.Shell.WriteLine("set [" .. k .. "]=[" .. tostring(v) .. "]");
                -- ensure keys of mods are valid
                if( nil == DateTime[k] ) then
                        error( "Invalid key in modification [" .. k .. "]", 2);
                end
                if( "number" ~= type(mods[k]) ) then
                        error( "Modification value for key " .. k .. " expected number not " .. type(mods[k]), 2);
                end
                self[k] = v;
        end
        
        -- This is less than ideal, because it validates the changes *AFTER* 
        -- the object has already been changed.
        -- But - since invalid changes are fatal... ignoring for now

        -- validate!
        self:validate(3);
        
        -- return modified object, so that you can assign result to object
        --      local d = DateTime:now():set({Hour=0,Minute=0});
        return self;
end

--------------------------------------------------------------------------
--- add - adds the specified amount the current datetime object
-- Can specify multiple fields such as "set({Hour=30,Minute=90})"
-- Will carry over amounts into higher fields as needed
-- Will accept negative values
-- Adding months adds a varying amount depending on the days of
-- the month involved
-- Limitation: This simplistic algorithm does NOT adjust for leap-seconds
-- @param mods a table of fields to add
-- @return the modified object
function DateTime:add( mods )
        -- validate input parameters
        if( "table" ~= type(mods) ) then
                error("Parameter expected to be a table of modification, not " .. type(other), 2 );
        end

        -- validate input mod table field
        for k,v in pairs(mods) do
                --Turbine.Shell.WriteLine("add [" .. k .. "]=[" .. tostring(v) .. "]");
                -- ensure keys of mods are valid
                if( nil == DateTime[k] ) then
                        error( "Invalid key in modification [" .. k .. "]", 2);
                end
                if( "number" ~= type(mods[k]) ) then
                        error( "Modification value for key " .. k .. " expected number not " .. type(mods[k]), 2);
                end
        end
        
        -- Apply changes in this order to properly handle rollover
        local orderToApply = {
                "Offset"; -- Timezone offset
                "Second";
                "Minute";
                "Hour";
                "Year";
                "Day";
                "Month";
        };
        
        -- if other provided, then apply mods to this object
        --   Note: accepts negative values as well
        for i,f in ipairs(orderToApply) do
                if( mods[f] ) then
                        local v = mods[f];
                        --Turbine.Shell.WriteLine(string.format(
                        --      "before [%s] add [%s]=[%s]",
                        --      self:tostring(),
                        --      f, 
                        --      tostring(v)
                        --      ));
                        self[f] = self[f] + v; -- adjust specified field
                        --Turbine.Shell.WriteLine(string.format(
                        --      "after [%s]",
                        --      self:tostring()));
                        
                        -- re-normalize field values due to rollover
                        
                        -- adjust Hour from Minute as needed
                        local minAdjustment = math.floor( self.Second / 60 ); 
                        if( 0 ~= minAdjustment ) then
                                --Turbine.Shell.WriteLine("minAdjustment [" .. minAdjustment .. "]");
                                self.Minute = self.Minute + minAdjustment;
                                self.Second = self.Second % 60;
                        end
                        -- adjust Hour from Minute as needed
                        local hourAdjustment = math.floor( self.Minute / 60 ); 
                        if( 0 ~= hourAdjustment ) then
                                --Turbine.Shell.WriteLine("hourAdjustment [" .. hourAdjustment .. "]");
                                self.Hour = self.Hour + hourAdjustment;
                                self.Minute = self.Minute % 60;
                        end
                        -- adjust Day from Hour as needed
                        local dayAdjustment = math.floor( self.Hour / 24 );
                        if( 0 ~= dayAdjustment ) then
                                --Turbine.Shell.WriteLine("dayAdjustment [" .. dayAdjustment .. "]");
                                self.Day = self.Day + dayAdjustment;
                                self.Hour = self.Hour % 24;
                        end
                        -- adjusting years from months
                        local yearAdjustment = math.floor( (self.Month-1) / 12 ); -- adjust Day from Hour
                        --Turbine.Shell.WriteLine(string.format(
                        --      "self.Month [%d] yearAdjustment [%d]",
                        --      self.Month, yearAdjustment) );
                        if( 0 ~= yearAdjustment ) then
                                --Turbine.Shell.WriteLine("yearAdjustment [" .. yearAdjustment .. "]");
                                self.Year = self.Year + yearAdjustment;
                                self.Month = ((self.Month-1) % 12)+1;
                        end
                        -- adjusting Months from Days
                        -- - iterate over months as needed due to varying days of the month
                        local daysInMonth = self:daysInMonth(); -- days in current month
                        --Turbine.Shell.WriteLine("daysInMonth [" .. tostring(daysInMonth) .. "]");
                        while( self.Day > daysInMonth ) do
                                --Turbine.Shell.WriteLine( string.format("+ month=[%d] day=[%d]",self.Month,self.Day));
                                self.Day = self.Day - self:daysInMonth();
                                self.Month = self.Month + 1;
                                --Turbine.Shell.WriteLine(string.format(
                                --      "rollover to month=[%d] day=[%d]",
                                --      self.Month,self.Day));
                                if( self.Month > 12 ) then
                                        self.Year = self.Year + 1;
                                        --Turbine.Shell.WriteLine("rollover to year [" .. tostring(self.Year) .. "]");
                                        self.Month = 1;
                                end
                                daysInMonth = self:daysInMonth(); -- days in current month
                                --Turbine.Shell.WriteLine("daysInMonth [" .. tostring(daysInMonth) .. "]");
                        end
                        while( self.Day < 1 ) do
                                --Turbine.Shell.WriteLine( string.format("- month=[%d] day=[%d]",self.Month,self.Day));
                                self.Month = self.Month - 1;
                                if( 0 == self.Month ) then
                                        self.Year = self.Year - 1;
                                        --Turbine.Shell.WriteLine("rollback to year [" .. tostring(self.Year) .. "]");
                                        self.Month = 12;
                                end
                                --Turbine.Shell.WriteLine("rollback to month [" .. tostring(self.Month) .. "]");
                                --self.Month = ((self.Month-1-1) % 12)+1; -- complicated due to range 1 thru 12
                                local dayAdjustment = self:daysInMonth();  -- days in prior month
                                --Turbine.Shell.WriteLine("adding days [" .. tostring(dayAdjustment) .. "]");
                                self.Day = self.Day + dayAdjustment;
                        end
                        
                end
        end

        -- return modified object, so that you can assign result to object
        --      local d = DateTime:now():set({Hour=0,Minute=0});
        return self;
end

--------------------------------------------------------------------------
--- isLeapYear (Gregorian calendar)
-- determine if year is a leap year (assumes AD 4 is a leap year)
-- @return true if the specified year is a leap year, false otherwise
function DateTime:isLeapYear()
        assert( type(self.Year) == "number", 
                        "expected number for year, not " .. type(self.Year) );
        local isLeapYear = (math.fmod(self.Year,4)==0 and math.fmod(self.Year,100) ~= 0) or math.fmod(self.Year,400)==0;
        return isLeapYear;
end

--------------------------------------------------------------------------
--- daysInMonth - Returns the number of days in the specified month & year 
-- (Gregorian calendar)
-- @eturn the number of days in the specified month & year 
function DateTime:daysInMonth()

        -- validate input parameters
        assert( type(self.Month) == "number", 
                "Unexpected type for month: " .. type(self.Month));
        assert( (self.Month >= 1) and (self.Month <= 12),
                "Invalid Month [" .. self.Month .. "]" );
        assert( type(self.Year) == "number", 
                "Unexpected type for Year: " .. type(self.Year));
        
        -- Thirty days hath September, April, June, and November,
        -- All the rest have thirty-one, 
        -- Except February, twenty-eight days clear, 
        -- And twenty-nine in each leap year. 
    local daysPerMonth = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
    if ( (self.Month == 2) and self:isLeapYear()) then
                return 29;
        end 
        local days = daysPerMonth[ self.Month ];
        --Turbine.Shell.WriteLine( "daysInMonth " .. 
        --      " Month=" .. tostring(self.Month) ..
        --      " Year=" .. tostring(self.Year) ..
        --      " retval=" .. tostring(days) );
        return days;
end

--------------------------------------------------------------------------
--- dayOfWeek - Returns the day of the week as an integer
-- 
-- @eturn the days of the week as a number 1 (Sunday) to 7 (Saturday).
function DateTime:dayOfWeek()

        self:validate(3);
        
    local a = math.floor((14 - self.Month) / 12);       -- anchor day
    local y = self.Year - a;
    local m = self.Month + 12*a - 2;
    return math.fmod(math.floor(self.Day + y + math.floor(y/4) - math.floor(y/100) + math.floor(y/400) + (31*m)/12),7) + 1;
end

--------------------------------------------------------------------------
--- daysSince - Returns the number of days between the two dates
-- Limitation: ignores leap seconds
-- @return the number of days between the two dates
function DateTime:daysSinceEpoch()

        local epoch = DateTime:epoch();

        local days = 0;
        
        -- iterate over years accumulating days per year
        local year = epoch.Year;
        while( year < self.Year ) do
                if( CalTools:isLeapYear(year) ) then
                        days = days + 366;
                else
                        days = days + 365;
                end
                year = year + 1;
        end
        -- iterate over months, accumulating days per month
        local month = epoch.Month;
        while( month < self.Month ) do
                days = days + CalTools:daysInMonth(month, self.Year);
                month = month+1;
        end
        -- add remaining days
        days = days + (self.Day - epoch.Day);
        
        return days;
end

--------------------------------------------------------------------------
--- daysSince - Returns the number of days between the two dates
-- @eturn the number of days between the two dates
function DateTime:DaysSince(start)
        -- validate input parameters
        if( getmetatable(start) ~= self.getmetatable() ) then
                error("Parameter expected to be a DateTime not " .. type(other), 2 );
        end
        start:validate(3);
        --if( not start:validate(3) ) then
        --      error("Parameter expected to be a valid DateTime", 2 );
        --end
        
        local daysSinceEpochEnd = self:daysSinceEpoch();
        local daysSinceEpochStart = start:daysSinceEpoch();
        local days = daysSinceEpochEnd - daysSinceEpochStart;
        return days;
end

--------------------------------------------------------------------------
--- MinutesSince - Returns the number of minutes between the two dates
-- Limitation: ignores leap seconds
-- @return the number of minutes between the two dates
function DateTime:MinutesSince(other)

        local MINS_PER_HR = 60;
        local MINS_PER_DAY = 1440;

        local days = self:DaysSince(other);
        local thisMinOfDay = self.Hour * 60 + self.Minute;
        local otherMinOfDay = other.Hour * 60 + other.Minute;

        local minutes = days * MINS_PER_DAY + 
                (thisMinOfDay - otherMinOfDay);
        
        return minutes;
end

--------------------------------------------------------------------------
--- ymd - Returns the year-month-day 
-- @return a string of corresponding year-month-day
function DateTime:ymd()
        local str = string.format("%04d-%02d-%02d",
                self.Year,
                self.Month,
                self.Day);
        return str;
end

--------------------------------------------------------------------------
--- time - Returns the hour-minute
-- @return a string of corresponding hour-minute
function DateTime:time()
        local str = string.format("%02d:%02d",
                self.Hour,
                self.Minute);
        return str;
end

--------------------------------------------------------------------------
--- utcOffset - Returns the utcOffset
-- @return a string of corresponding utcOffset
function DateTime:utcOffset()
        local str;
        if( 0 == self.Offset ) then
                str = "Z";      -- zulu time
        else
                local s = (self.Offset > 0) and "+" or "-"; -- ternary
                local hh = math.floor( math.abs(self.Offset) / 60); -- number of hours
                local mm = self.Offset % 60;  -- mod (always positive)
                str = string.format("%s%02d:%02d", s, hh, mm);
        end
        return str;
end

--------------------------------------------------------------------------
--- tostring - Returns a string representing the date object in ISO 8601
--    format, including the UTC offset
-- @return a string of form 'yyyy-mm-ddThh:dd:ss[+-]hh:mm
function DateTime:tostring()
        --local str = string.format("%s %s%s", 
        --      self:ymd(), 
        --      self:time(), 
        --      self:utcOffset() );
    local str = self:strftime("%Y-%m-%dT%H:%M:%S%z");
        return str;
end

--------------------------------------------------------------------------
--- format - Returns a string representing the date object in
-- the requested format
-- @param a format string of the output that can contain directives
--        as seen with strftime()
-- @return a string of form 'yyyy-mm-dd hh:dd:ss[+-]hh:mm
function DateTime:strftime(fmt)
        if( "string" ~= type(fmt) ) then
                error("Parameter expected to be a string not " .. type(fmt), 2 );
        end

        local output = fmt;     -- make working copy of string
        
        -- %a     Abbreviated name of the day of the week
        -- %A     Full name of the day of the week
        -- %b     Abbreviated month name
        -- %h     Equivalent to %b
        output = string.gsub(output, '%%h', '%%b');
        -- %B     Full month name
        -- %c     Preferred date and time representation for the current locale
        -- %D     Equivalent to %m/%d/%y
        output = string.gsub(output, '%%D', '%%m/%%d/%%y');
        -- %F     Equivalent to %Y-%m-%d (ISO 8601 date format)
        output = string.gsub(output, '%%F', '%%Y-%%m-%%d');
        -- %R     The time in 24-hour notation (%H:%M)
        output = string.gsub(output, '%%R', '%%H:%%M');
    -- %r     The time in a.m. or p.m. notation
        output = string.gsub(output, '%%r', '%%I:%%M:%%S %%p');
        -- %T     The time in 24-hour notation (%H:%M:%S)

        -- %C     Century number (year/100) as a 2-digit integer
        output = string.gsub(output, '%%C', string.sub(tostring(self.Year),1,2));
        -- %y     The year without a century (range 00 to 99)
        output = string.gsub(output, '%%y', string.sub(tostring(self.Year),3,4));
        -- %Y     The year including the century
        output = string.gsub(output, '%%Y', self.Year);
        -- %m     The month as a decimal number (01 to 12)
        output = string.gsub(output, '%%m', string.format("%02d",self.Month));
        -- %d     Day of the month as a decimal number (01 thru 31)
        output = string.gsub(output, '%%d', string.format("%02d",self.Day));
        -- %e     like %d, but leading zero replaced by space
        output = string.gsub(output, '%%e', string.format("%d",self.Day));

        -- %H     The hour using a 24-hour clock (00 to 23)
        output = string.gsub(output, '%%H', string.format("%02d",self.Hour));
        -- %k     Like %H but single digits preceded by blank (0 to 23)
        output = string.gsub(output, '%%k', string.format("%d",self.Hour));
        -- %I     The hour using a 12-hour clock (01 to 12)
        output = string.gsub(output, '%%I', string.format("%02d",self.Hour % 12));
        -- %l     Like %I but single digits preceded by a blank (1 to 12); 
        output = string.gsub(output, '%%l', string.format("%d",self.Hour % 12));
        -- %M     The minute as a decimal number (range 00 to 59)
        output = string.gsub(output, '%%M', string.format("%02d",self.Minute));
        -- %S     The second as a decimal number (range 00 to 60)
        output = string.gsub(output, '%%S', string.format("%02d",self.Second));
        -- %p     Either "AM" or "PM" according to the given time value
        output = string.gsub(output, '%%p', string.format("%s", (self.Hour>12) and "PM" or "AM" ));    
        -- %P     Like %p but in lowercase: "am" or "pm"
        output = string.gsub(output, '%%P', string.format("%s", (self.Hour>12) and "pm" or "am" ));    
    

        -- %G     ISO 8601 week-based year
        -- %g     Like %G, but without century (e.g. 2 digit year)

        -- %O     Modifier: use alternative numeric symbols
        -- %s     The number of seconds since the Epoch (1970-01-01 00:00:00Z)
        -- %j     The day of the year (001 to 366)
        output = string.gsub(output, '%%j', string.format("%03d",self.DayOfYear));
        -- %w     The day of the week, range 0 to 6, Sunday being 0
        output = string.gsub(output, '%%w', string.format("%d",self:dayOfWeek()-1));
        -- %u     The day of the week as a decimal, range 1 to 7 (Monday=1)
        --output = string.gsub(output, '%%u', string.format("%d",self:dayOfWeek()));
        -- %W     The week number of the current year (00 to 53) first Monday as first day of week 1
        -- %U     The week number of the current year (00 to 53) first Sunday as first day of week 1
        -- %V     The ISO 8601 week number
        -- %x     The preferred date representation for the current locale
        -- %X     The preferred time representation for the current locale
        -- %z     The +hhmm or -hhmm numeric timezone
        output = string.gsub(output, '%%z', self:utcOffset());
        -- %Z     The timezone name or abbreviation
        -- %+     The date and time in date(1) format
        -- %n     A newline character
        output = string.gsub(output, '%%n', "\n");
        -- %t     A tab character
        output = string.gsub(output, '%%t', "\t");
        -- %%     A literal '%' character
        output = string.gsub(output, '%%%%', '%%');
        
        -- use: string.match(), and string.gsub()

        --function expand (s)
    --  s = string.gsub(s, "$(%w+)", function (n)
    --        return _G[n]
    --      end)
    --  return s
    --end
    --name = "Lua"; status = "great"
    --print(expand("$name is $status, isn't it?"))
    --  --> Lua is great, isn't it?

        --local output = fmt; -- start with input format string
        --output = string.format("Hi %s buddy","there");
    --Turbine.Shell.WriteLine("output: " .. output);    
        --s = string.sub(output,5,8);
    --Turbine.Shell.WriteLine("s: " .. s);      
        ----s = string.sub(output,5,8);
        --s = string.gsub("I love tacos!", "tacos", "Roblox")
    --Turbine.Shell.WriteLine("s: " .. s);      

    --Turbine.Shell.WriteLine( string.format("output [%s]",output));    
        
        local unimplementedFormat = string.match(output,'%%.');
        if( unimplementedFormat ) then
                assert(false,string.format("not implemented yet [%s]",unimplementedFormat));            
        end
        return output;  
end

-- commented out so we can use 'tostring()' on object to print address
--function DateTime:__xtostring()
--      local str = self:tostring();
--      return str;
--end

-- comparators
DateTime.__eq = function (a,b)    -- equal
        return( a:tostring() == b:tostring() );
end

DateTime.__lt = function (a,b)    -- less-than
        return( a:tostring() < b:tostring() );
end
DateTime.__le = function (a,b)    -- less-than-or-equal
        return( a:tostring() <= b:tostring() );
end

--------------------------------------------------------------------------
--------------------------------------------------------------------------
-- Unit Tests
function DateTimeUnitTest()
    Turbine.Shell.WriteLine("\n\n>>> DateTimeUnitTest:  START ...");

    Turbine.Shell.WriteLine("LUA version: " .. _VERSION);       
        
        -- Verify new()
        local d1 = DateTime:new();
        assert(1970 == d1.Year, "wrong year " .. tostring(d1.Year) );
        assert(1 == d1.Month, "wrong month");
        assert(1 == d1.Day, "wrong day");
        assert(0 == d1.Hour, "wrong hour");
        assert(0 == d1.Minute, "wrong minute");
        assert(0 == d1.Offset, "wrong offset:" .. tostring(d1.Offset) );

        -- Verify tostring()
    Turbine.Shell.WriteLine("d1: " .. d1:tostring());
    local expected = "1970-01-01T00:00:00Z";
    local actual = d1:tostring();
        assert(expected == actual, string.format(
        "wrong datestring!\n  exp[%s]\n  act[%s]", 
        expected, 
        actual));
        
        -- Verify today()
        local now = Turbine.Engine.GetDate();
        local d2 = DateTime:now();
    Turbine.Shell.WriteLine("d2: " .. d2:tostring());
        assert(now.Year == d2.Year, "wrong year");
        assert(now.Month == d2.Month, "wrong month");
        assert(now.Day == d2.Day, "wrong day");
        assert(now.Hour == d2.Hour, "wrong hour");
        assert(now.Minute == d2.Minute, "wrong minute");
        assert(0 == d2.Offset, "wrong offset [" .. 
                tostring(0) .. "] != [" .. tostring(d2.Offset) .. "]");
        
        -- Verify getmetatable() - proves all object have same metatable - so are of same type
        --local mt = tostring(getmetatable(DateTime)); -- will be nil
        local d1mt = tostring(d1:getmetatable());
        local d2mt = tostring(d2:getmetatable());
    --Turbine.Shell.WriteLine("mt: " .. mt);
    Turbine.Shell.WriteLine("d1mt: " .. d1mt);
    Turbine.Shell.WriteLine("d2mt: " .. d2mt);
        --assert( mt == d1mt );
        assert( d1mt == d2mt, "metatables do not match" );
        
        -- an error handler to print trapped error messages
        function myerrorhandler( err )
                --print( "ERROR:", err )
                Turbine.Shell.WriteLine("TRAPERROR: " .. err);
        end     
        
        -- verify clone()
        Turbine.Shell.WriteLine("test: clone");
        local d3 = d2:clone();
    Turbine.Shell.WriteLine("d3: " .. d3:tostring());
        for k,v in pairs(DateTime) do
                assert(d2[k] == d3[k], "wrong value for [" .. k .. "]" );
        end     
    local d3addr = tostring(d3);
    local d2addr = tostring(d2);
        Turbine.Shell.WriteLine("d3addr: " .. d3addr);
        Turbine.Shell.WriteLine("d2addr: " .. d2addr);
        assert(d3addr ~= d2addr, "should be copies, not same object" );

        -- verify daysInMonth()
        Turbine.Shell.WriteLine("test: daysInMonth");
    local expectedDaysPerMonth = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
        for i,v in ipairs(expectedDaysPerMonth) do
                local d = DateTime:new():set({Month=i});
                local days = d:daysInMonth();
                Turbine.Shell.WriteLine(string.format("daysInMonth[%d]: %s => [%s]",
                        i, 
                        d:ymd(),
                        tostring(days)
                        ) );
                assert( v == days, "mismatch\n [" .. v .. "] != [" .. days .. "]");
        end
        local d = DateTime:new():set({Year=2020, Month=2}); -- leap year
        local days = d:daysInMonth();
        assert( 29 == days, "mismatch\n [" .. 29 .. "] != [" .. days .. "]");

        local badDayInMonthTests = {
                --function() local d = DateTime:now(); d:daysInMonth(); end, -- comment to varify test catches error
                function() local d = DateTime:now(); d.Month = "barf"; d:daysInMonth(); end, -- invalid month
                function() local d = DateTime:now(); d.Month = 0; d:daysInMonth(); end, -- invalid month
                function() local d = DateTime:now(); d.Month = 13; d:daysInMonth(); end, -- invalid month
                function() local d = DateTime:now(); d.Year = "barf"; d:daysInMonth(); end, -- invalid year
        };
        for i,f in ipairs(badDayInMonthTests) do
                Turbine.Shell.WriteLine("badDayInMonthTests[" .. i .. "]");
                assert( not xpcall(f,myerrorhandler ), "badValidateTests[" .. i .. "] should have failed");     
        end
        
        -- verify validate()
        Turbine.Shell.WriteLine("test: validate");
    Turbine.Shell.WriteLine("d1: " .. d1:tostring());
        d1:validate(1);
    Turbine.Shell.WriteLine("d2: " .. d2:tostring());
        d2:validate();
        local badValidateTests = {
                --function() d5.Year = 1980; d5:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Year = -1; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Year = 1969; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Month = -1; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Month = 0; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Month = 13; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Day = -1; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Day = 0; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Day = 32; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Hour = -1; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Hour = 25; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Minute = -1; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Minute = 61; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Second = -1; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Second = 61; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Offset = -11*60-1; d:validate(); end, -- invalid argument
                function() local d = DateTime:now(); d.Offset = 12*60+1; d:validate(); end, -- invalid argument
        };
        for i,f in ipairs(badValidateTests) do
                Turbine.Shell.WriteLine("badValidateTests[" .. i .. "]");
                --assert( not pcall(f), "badValidateTests[" .. i .. "] should have failed");    
                assert( not xpcall(f,myerrorhandler ), "badValidateTests[" .. i .. "] should have failed");     
        end

        -- verify utcOffset()
        Turbine.Shell.WriteLine("test: utcOffset");
        assert(0 == d3.Offset, "wrong offset:" .. tostring(d3.Offset) );
        assert("Z" == d3:utcOffset(), "wrong offset:" .. tostring(d3:utcOffset()) );

        -- verify set()
        Turbine.Shell.WriteLine("test: set");
        local d3str = d3:tostring(); -- save for later compare
        local d4 = d3:clone();
        d4:set({Hour=0,Minute=0});
    Turbine.Shell.WriteLine("d4: " .. d4:tostring());
        for k,v in pairs(DateTime) do
                if( ("Hour" == k) or ("Minute" == k) ) then
                        assert(0 == d4[k], "wrong value for [" .. k .. "]" );
                else
                        assert(d3[k] == d4[k], "wrong value for [" .. k .. "]");
                end
        end     
        assert(d3:tostring() == d3str, "original object should not have changed" );
    Turbine.Shell.WriteLine("d3: " .. d3:tostring());
        
        --d4:set({Hour=1,Minute=68}); -- success!
        local d5 = d3:clone():set({Hour=0,Minute=0,Second=0});
        assert(0 == d5.Hour, "wrong Hour:" .. tostring(d5.Hour) );
        assert(0 == d5.Minute, "wrong Minute:" .. tostring(d5.Minute) );
        assert(0 == d5.Minute, "wrong Second:" .. tostring(d5.Second) );
    Turbine.Shell.WriteLine("d5: " .. d5:tostring());
        local d6 = DateTime:now():set({Offset=-5*60});
        for k,v in pairs(DateTime) do
                if( "Offset" == k ) then
                        assert(-5*60 == d6[k], "wrong value for [" .. k .. "]" );
                else
                        -- d2 was set to now() above
                        assert(d2[k] == d6[k], "wrong value for [" .. k .. "]");
                end
        end     
    Turbine.Shell.WriteLine("d6: " .. d6:tostring());
        
        local badSetTests = {
                --function() local d = DateTime:now(); end, -- uncomment to test if detecting no error
                function() local d = DateTime:now():set({bad="stuff"}); end,
                function() local d = DateTime:now():set({Year = "x"}); end,
                function() local d = DateTime:now():set({Year = -1}); end,
                function() local d = DateTime:now():set({Year = 1969}); end,
                function() local d = DateTime:now():set({Month = "x"}); end,
                function() local d = DateTime:now():set({Month = -1}); end,
                function() local d = DateTime:now():set({Month = 0}); end,
                function() local d = DateTime:now():set({Month = 13}); end,
                function() local d = DateTime:now():set({Day = "x"}); end,
                function() local d = DateTime:now():set({Day = -1}); end,
                function() local d = DateTime:now():set({Day = 0}); end,
                function() local d = DateTime:now():set({Day = 32}); end,
                function() local d = DateTime:now():set({Hour = "x"}); end,
                function() local d = DateTime:now():set({Hour = -1}); end,
                function() local d = DateTime:now():set({Hour = 25}); end,
                function() local d = DateTime:now():set({Minute = "x"}); end,
                function() local d = DateTime:now():set({Minute = -1}); end,
                function() local d = DateTime:now():set({Minute = 61}); end,
                function() local d = DateTime:now():set({Second = "x"}); end,
                function() local d = DateTime:now():set({Second = -1}); end,
                function() local d = DateTime:now():set({Second = 61}); end,
                function() local d = DateTime:now():set({Offset = "Z"}); end,
                function() local d = DateTime:now():set({Offset = -11*60-1}); end,
                function() local d = DateTime:now():set({Offset = 12*60+1}); end,
        };
        for i,f in ipairs(badSetTests) do
                Turbine.Shell.WriteLine("badSetTests[" .. i .. "]");
                assert( not xpcall(f,myerrorhandler ), "badSetTests[" .. i .. "] should have failed");  
        end

        -- verify parse()
        Turbine.Shell.WriteLine("test: parse");
        local d6 = DateTime:parse("1981-02-03T13:45:23Z");
    Turbine.Shell.WriteLine("d6: " .. d6:tostring());
        assert(1981 == d6.Year, "wrong year");
        assert(2 == d6.Month, "wrong month");
        assert(3 == d6.Day, "wrong day");
        assert(13 == d6.Hour, "wrong hour");
        assert(45 == d6.Minute, "wrong minute");
        assert(23 == d6.Second, "wrong second");
        assert(0 == d6.Offset, "wrong offset");
        
        -- input dates
        local validDateStrings = {
                "1981-02-03T13:45:10Z",
                "2020-02-29T12:00:20Z", -- a leap year
                "2019-01-30T13:01:30-05:00", -- EST/EDT
        }
        for i,v in ipairs(validDateStrings) do
                Turbine.Shell.WriteLine("validDateStrings[" .. i .. "] = [" .. v .. "]");
                local d = DateTime:parse(v);
                local s = d:tostring();
                assert( v == d:tostring(), "mismatch\n [" .. v .. "] != [" .. s .. "]");
        end
        -- assumes zulu time
        local s = "2022-02-28T12:01:02";
        local d7 = DateTime:parse(s);
    Turbine.Shell.WriteLine("d7: " .. d7:tostring());
        assert( s .. "Z" == d7:tostring(), "mismatch\n [" .. s .. "] != [" .. d7:tostring() .. "]");
        
        -- negative tests
        local invalidDateStrings = {
                --"2022-02-28 12:00Z", -- is ok, uncomment to verify can trap no error
                nil,
                3,
                {bad=value},
                "barf", -- not valid date
                "1971-03-02 02:03:1", -- invalid second
                "1971-03-02 02:6:01", -- invalid minute
                "1971-03-02 2:06:01", -- invalid hour
                "1971-03-2 02:06:01", -- invalid day
                "1971-3-02 02:06:01", -- invalid month
                "71-03-02 02:06:01",  -- invalid year
                "1971-03-02 02:03:60", -- invalid second
                "1971-03-02 02:60:01", -- invalid minute
                "1971-03-02 02:60:01X", -- invalid zone
                "1971-03-02 24:00:01", -- invalid hour
                "1971-03-32 02:06:01", -- invalid day
                "1971-04-31 02:06:01", -- invalid day
                "1971-13-02 02:06:01", -- invalid month
                "1968-13-32 25:68:01", -- before 1970
                "2022-02-29 12:00:01", -- NOT a leap year
        }
        
        function testbadparse(s)
                local d = DateTime:parse(s);
        end
        for i,v in ipairs(invalidDateStrings) do
                s = invalidDateStrings[i];
                Turbine.Shell.WriteLine("invalidDateStrings[" .. i .. "] = [" .. tostring(s) .. "]");
                assert( not pcall(testbadparse,s), "invalidDateStrings[" .. i .. "] should have failed");       
                -- can't pass arguments to function via xpcall until LUA 5.2
                --assert( not xpcall(f,myerrorhandler ), "badSetTests[" .. i .. "] should have failed");        
        end

        -- verify dayOfWeek()   1=Sun => 7=Sat
        Turbine.Shell.WriteLine("test: dayOfWeek");
        local validDayOfWeekTests = {
                --   input            expected
                {"1970-01-01T00:00:00Z"; 5}; -- Thu

                {"2021-12-05T00:00:00Z"; 1}; -- Sun
                {"2021-12-06T00:00:00Z"; 2}; -- Mon
                {"2021-12-07T00:00:00Z"; 3}; -- Tue
                {"2021-12-08T00:00:00Z"; 4}; -- Wed
                {"2021-12-09T00:00:00Z"; 5}; -- Thu
                {"2021-12-10T00:00:00Z"; 6}; -- Fri
                {"2021-12-11T00:00:00Z"; 7}; -- Sat
                {"2022-02-26T22:04:00Z"; 7}; -- Saturday

                {"2016-02-29T00:00:00Z"; 2}; -- Mon : leap year
                {"2022-02-28T00:00:00Z"; 2}; -- Mon
        }
        for idx,t in ipairs(validDayOfWeekTests) do
                local input    = t[1];
                local expected = t[2];
                local d        = DateTime:parse(input);
                local actual   = d:dayOfWeek();
                Turbine.Shell.WriteLine( string.format(
                        "idx [%d]  input[%s]  exp[%s]  act[%s]", idx, input, expected, actual ) );
                assert( expected == actual, "wrong value [" .. actual .. "]" );
        end
        
        -- verify add() 
        Turbine.Shell.WriteLine("test: add");
        local validAddTests = {
                --   input            mod       expected
                {"1981-02-03T13:45:00Z"; {Minute=5};   "1981-02-03T13:50:00Z"};
                {"1981-02-03T13:45:00Z"; {Minute=-5};  "1981-02-03T13:40:00Z"};
                {"1981-02-03T13:45:00Z"; {Minute=30};  "1981-02-03T14:15:00Z"}; -- rollover +hours
                {"1981-02-03T13:15:00Z"; {Minute=-30}; "1981-02-03T12:45:00Z"}; -- rollover -hours
                {"1981-02-01T00:02:00Z"; {Minute=-3};  "1981-01-31T23:59:00Z"}; -- rollover -day

                {"1981-02-03T13:15:00Z"; {Hour=1};     "1981-02-03T14:15:00Z"};
                {"1981-02-03T13:15:00Z"; {Hour=-1};    "1981-02-03T12:15:00Z"};
                {"1981-02-03T23:15:00Z"; {Hour=1};     "1981-02-04T00:15:00Z"}; -- rollover +days
                {"1981-02-03T01:15:00Z"; {Hour=-2};    "1981-02-02T23:15:00Z"}; -- rollover -days

                {"2022-02-03T10:45:00Z"; {Day=1};      "2022-02-04T10:45:00Z"};
                {"2022-02-03T11:45:00Z"; {Day=-2};     "2022-02-01T11:45:00Z"};
                {"2022-02-03T12:45:00Z"; {Day=35};     "2022-03-10T12:45:00Z"}; -- rollover +month !!
                {"2022-02-03T13:45:00Z"; {Day=-5};     "2022-01-29T13:45:00Z"}; -- rollover -month
                {"2020-02-28T14:45:00Z"; {Day=1};      "2020-02-29T14:45:00Z"}; -- leap year
                {"2020-02-29T15:45:00Z"; {Day=2};      "2020-03-02T15:45:00Z"}; -- leap year rollover +month
                {"2020-03-02T16:45:00Z"; {Day=-3};     "2020-02-28T16:45:00Z"}; -- leap year rollover -month

                {"1981-02-03T10:45:00Z"; {Month=1};    "1981-03-03T10:45:00Z"};
                {"1981-02-03T11:45:00Z"; {Month=-1};   "1981-01-03T11:45:00Z"};
                {"2022-11-01T13:45:00Z"; {Month=1};    "2022-12-01T13:45:00Z"};
                {"2022-12-02T13:45:00Z"; {Month=1};    "2023-01-02T13:45:00Z"}; -- rollover +year
                {"1981-02-03T13:45:00Z"; {Month=13};   "1982-03-03T13:45:00Z"}; -- rollover +year
                {"1981-02-03T13:35:00Z"; {Month=-13};  "1980-01-03T13:35:00Z"}; -- rollover -year
                {"1981-01-03T13:05:00Z"; {Month=-1};   "1980-12-03T13:05:00Z"}; -- rollover -year

                {"1981-02-03T13:45:00Z"; {Year=2};     "1983-02-03T13:45:00Z"};
                {"1981-02-03T13:45:00Z"; {Year=-2};    "1979-02-03T13:45:00Z"};
                
                -- Compound updates
                {"2000-02-03T13:45:00Z"; {Minute=1,Hour=1,Day=1,Month=1,Year=1};   "2001-03-04T14:46:00Z"};
                {"1981-02-03T23:15:00Z"; {Minute=1*60};  "1981-02-04T00:15:00Z"}; -- rollover +days
                {"1981-02-03T13:45:00Z"; {Day=-35};      "1980-12-30T13:45:00Z"}; -- wrap -year
                {"1981-11-30T13:45:00Z"; {Day=40};       "1982-01-09T13:45:00Z"}; -- wrap +year

                {"1981-02-03T13:45:00Z"; {Day=367};      "1982-02-05T13:45:00Z"}; -- wrap +year
                {"1981-02-03T13:45:00Z"; {Day=-367};     "1980-02-02T13:45:00Z"}; -- wrap -year
        }
        for idx,t in ipairs(validAddTests) do
                local input = t[1];
                local mod   = t[2];
                local exp   = t[3];
                --Turbine.Shell.WriteLine( string.format(
                --      "idx [%d]  input[%s]  exp[%s]", idx, input, exp) );             
                local d = DateTime:parse(input);
                d:add(mod);
                Turbine.Shell.WriteLine( string.format(
                        "idx [%d]  input[%s]  exp[%s]  act[%s]", idx, input, exp, d:tostring() ) );
                assert( exp == d:tostring(), "wrong value [" .. d:tostring() .. "]" );
        end

        -- verify daysSinceEpoch()
        Turbine.Shell.WriteLine("test: daysSinceEpoch");
        local daysSinceEpochTests = {
                --   input            expected
                {"1970-01-01T00:00:00Z"; 0};
                {"1970-01-02T00:00:00Z"; 1};
                {"1971-01-01T00:00:00Z"; 365};
                {"1980-01-01T00:00:00Z"; 3652};
                {"2000-01-01T00:00:00Z"; 10957};
                {"2022-01-01T00:00:00Z"; 18993};
                {"2022-02-25T00:00:00Z"; 19048};
                {"2022-02-25T20:00:00Z"; 19048};
        }
        for idx,t in ipairs(daysSinceEpochTests) do
                local input    = t[1];
                local expected = t[2];
                local d        = DateTime:parse(input);
                local actual   = d:daysSinceEpoch();
                Turbine.Shell.WriteLine( string.format(
                        "idx [%d]  input[%s]  exp[%d]  act[%d]", idx, d:tostring(), expected, actual ) );
                assert( expected == actual, "wrong value [" .. actual .. "]" );
        end
        
        -- verify DaysSince()
        Turbine.Shell.WriteLine("test: daysSince");
        local daysSinceTests = {
                --   start            end                  expected
                {"1970-01-01T00:00:00Z"; "1970-01-01T00:00:00Z", 0};
                {"1970-01-01T00:00:00Z"; "1970-01-02T00:00:00Z", 1};
                {"1970-01-01T00:00:00Z"; "1970-03-01T00:00:00Z", 59};
                {"1970-01-01T00:00:00Z"; "1971-01-01T00:00:00Z", 365};
                {"1980-01-01T00:00:00Z"; "2000-01-01T00:00:00Z", 7305};
                {"2022-02-01T00:00:00Z"; "2022-02-01T00:00:00Z", 0};
                {"2022-02-01T00:00:00Z"; "2022-02-22T00:00:00Z", 21};
                {"2022-02-01T00:00:00Z"; "2022-02-25T20:08:00Z", 24};
                {"2021-06-25T00:00:00Z"; "2015-05-15T00:00:00Z", -2233};
                {"2021-02-23T00:00:00Z"; "2021-02-23T23:59:00Z", 0};
        }
        for idx,t in ipairs(daysSinceTests) do
                local startDate  = t[1];
                local endDate    = t[2];
                local expected   = t[3];
                local startDateTime = DateTime:parse(startDate);
                local endDateTime   = DateTime:parse(endDate);
                local actual   = endDateTime:DaysSince(startDateTime);
                Turbine.Shell.WriteLine( string.format(
                        "idx [%d]  start[%s] end[%s]  exp[%d]  act[%d]", 
                        idx, startDate, endDate, expected, actual ) );
                assert( expected == actual, "wrong value [" .. actual .. "]" );
        end
        
        -- verify comparators
        Turbine.Shell.WriteLine("test: comparators");
        local validComparatorTests = {
                -- A                      B                                op{lt,lte,gt,gte,eq};

                -- vary A
                {"1981-02-03T13:45:00Z"; "1981-02-03T13:50:00Z"; "<"};
                {"1981-02-03T13:55:00Z"; "1981-02-03T13:50:00Z"; ">"};
                {"1981-02-03T13:50:00Z"; "1981-02-03T13:50:00Z"; "=="};
                -- vary B
                {"1981-02-03T13:50:00Z"; "1981-02-03T13:55:00Z"; "<"};
                {"1981-02-03T13:50:00Z"; "1981-02-03T13:45:00Z"; ">"};
                {"1981-02-03T13:50:00Z"; "1981-02-03T13:50:00Z"; "=="};

                -- TODO: UTC conversion         
                --{"1981-02-03 13:45Z"; "1981-02-03 6:50+5:00"; "<"};
                --{"1981-02-03 13:55Z"; "1981-02-03 6:50+5:00"; ">"};
                --{"1981-02-03 13:50Z"; "1981-02-03 6:50+5:00"; "=="};
                --{"1981-02-03 13:50Z"; "1981-02-03 6:55+5:00"; "<"};
                --{"1981-02-03 13:50Z"; "1981-02-03 6:45+5:00"; ">"};
                --{"1981-02-03 13:50Z"; "1981-02-03 6:50+5:00"; "=="};
        }
        for idx,t in ipairs(validComparatorTests) do
                local a  = t[1];
                local b  = t[2];
                local op = t[3];
                Turbine.Shell.WriteLine( string.format(
                        "  idx [%d]  a[%s]  b[%s]  op[%s]", idx, a, b, op) );
                local dtA = DateTime:parse(a);
                local dtB = DateTime:parse(b);
                
                if( "op" == "<" ) then
                        assert( dtA < dtB, "failed <" );
                        assert( not( dtA > dtB), "failed >" );
                        assert( not( dtA <= dtB), "failed <=" );
                        assert( not( dtA >= dtB), "failed >=" );
                        assert( not( dtA == dtB), "failed ==" );
                elseif( "op" == ">" ) then
                        assert( not(dtA < dtB), "failed <" );
                        assert( ( dtA > dtB), "failed >" );
                        assert( not( dtA <= dtB), "failed <=" );
                        assert( not( dtA >= dtB), "failed >=" );
                        assert( not( dtA == dtB), "failed ==" );
                elseif( "op" == "==" ) then
                        assert( not(dtA < dtB), "failed <" );
                        assert( not( dtA > dtB), "failed >" );
                        assert( ( dtA <= dtB), "failed <=" );
                        assert( ( dtA >= dtB), "failed >=" );
                        assert( ( dtA == dtB), "failed ==" );
                end
        end
        
        -- verify MinutesSince
        Turbine.Shell.WriteLine("test: MinutesSince");
        local validMinutesSinceTests = {
                -- A                      B                                expected;
                {'2022-03-06T13:50:00Z'; '2022-03-06T13:45:00Z';      5},
        {'2022-03-06T13:45:00Z'; '2022-03-06T13:50:00Z';     -5},
        {'2022-03-06T14:45:00Z'; '2022-03-06T13:50:00Z';     55},
        {'2022-03-06T12:50:00Z'; '2022-03-06T13:45:00Z';    -55},
        {'2022-03-07T14:45:00Z'; '2022-03-06T13:50:00Z';   1495},
        {'2022-03-05T12:50:00Z'; '2022-03-06T13:45:00Z';  -1495},
        {'2022-03-01T15:00:00Z'; '2022-02-27T16:45:00Z';   2775},
        {'2022-02-27T15:00:00Z'; '2022-03-01T16:45:00Z';  -2985},
        {'2020-03-01T15:00:00Z'; '2020-02-27T16:45:00Z';   4215}, -- leap year
        {'2020-02-27T15:00:00Z'; '2020-03-01T16:45:00Z';  -4425}, -- leap year
        };
        for idx,t in ipairs(validMinutesSinceTests) do
                local thisDateTime = DateTime:parse(t[1]);
                local otherDateTime = DateTime:parse(t[2]);
                local expected   = t[3];
                local actual   = thisDateTime:MinutesSince(otherDateTime);
                Turbine.Shell.WriteLine( string.format(
                        "idx [%d]  this[%s] other[%s]  exp[%d]  act[%d]", 
                        idx, 
                        thisDateTime:tostring(), 
                        otherDateTime:tostring(), 
                        expected, 
                        actual ) );
                assert( expected == actual, "wrong value [" .. actual .. "]" );
        end
        
        -- verify format
        Turbine.Shell.WriteLine("test: strftime");
        local validFormatTests = {
                -- date                   fmt                      expected;
                {'2022-03-06T13:50:00Z'; '%Y-%m-%d %H:%M';  '2022-03-06 13:50'},
                {'2022-03-06T08:50:00Z'; '%Y-%m-%e %k:%M';  '2022-03-6 8:50'},
                {'2022-03-06T08:50:00Z'; '%m/%d/%y';  '03/06/22'},
                {'2022-03-06T08:50:00Z'; '%D';  '03/06/22'},
                {'2022-03-06T08:50:00Z'; '%F';  '2022-03-06'},
                {'2022-03-06T13:50:10Z'; '%H:%M:%S';     '13:50:10'},
                {'2022-03-06T13:50:10Z'; '%k:%M:%S';     '13:50:10'},
                {'2022-03-06T03:50:10Z'; '%k:%M:%S';     '3:50:10'},
                {'2022-03-06T03:50:10Z'; '%I:%M:%S %p';  '03:50:10 AM'},
                {'2022-03-06T13:50:10Z'; '%I:%M:%S %p';  '01:50:10 PM'},
                {'2022-03-06T03:50:10Z'; '%l:%M:%S %P';  '3:50:10 am'},
                {'2022-03-06T13:50:10Z'; '%l:%M:%S %P';  '1:50:10 pm'},
        
                {'2022-03-06T13:50:00Z'; '%R';      '13:50'},
                {'2022-03-06T13:50:00Z'; '%C';      '20'}, -- century
                {'2022-03-04T13:50:00Z'; '%w';      '5'}, -- day of week
                {'2022-03-06T13:50:00Z'; '%w';      '0'}, -- day of week (Sunday=0)
                --{'2022-03-06 08:50Z'; '%Y-%m-%e %k:%M %%';  '2022-03-6 8:50 %%'},
        };
        for idx,t in ipairs(validFormatTests) do
                local dt = DateTime:parse(t[1]);
                local actual   = dt:strftime(t[2]);
                local expected   = t[3];
                Turbine.Shell.WriteLine( string.format(
                        "idx [%d]  this[%s] fmt[%s]  exp[%s]  act[%s]", 
                        idx, 
                        t[1], 
                        t[2],
                        expected,
                        actual ) );
                assert( expected == actual, "wrong value [" .. actual .. "]" );
        end

        -- zone

        --assert(false,"ABORT");
        -- if you get this far - it passed!
    Turbine.Shell.WriteLine("\n>>> DateTimeUnitTest: -- PASS!\n.\n\n");
        assert(false, "ABORT");
        
end

-- Uncomment below to unit test
--DateTimeUnitTest();

Compare with Previous | Blame


All times are GMT -5. The time now is 04:01 PM.


Our Network
EQInterface | EQ2Interface | Minion | WoWInterface | ESOUI | LoTROInterface | MMOUI | Swtorui