Commit Diff


commit - 886065ca5e2da9d4daac54161930e699e5499455
commit + 1e1d3706bb709ddff38380bab7893bd389a12d06
blob - /dev/null
blob + b0f71bb1962c66a04af096b7dccd8ddd0920b2fe (mode 644)
--- /dev/null
+++ tests/tarantool_datetime_new.lua
@@ -0,0 +1,358 @@
+--[[
+    https://web.archive.org/web/20150906092420/http://www.cs.tau.ac.il/~nachumd/horror.html
+    https://web.archive.org/web/20150908004245/http://www.merlyn.demon.co.uk/critdate.htm
+    https://en.wikipedia.org/wiki/Epoch#Notable_epoch_dates_in_computing
+    https://en.wikipedia.org/wiki/Time_formatting_and_storage_bugs
+    https://en.wikipedia.org/wiki/Leap_year_problem
+    https://medium.com/grandcentrix/property-based-test-driven-development-in-elixir-49cc70c7c3c4
+    https://groups.google.com/g/comp.dcom.telecom/c/Qq0lwZYG_fI
+
+    https://cs.opensource.google/go/go/+/refs/tags/go1.18.2:src/time/zoneinfo_test.go
+    https://github.com/dateutil/dateutil/blob/master/tests/test_tz.py
+    https://github.com/Zac-HD/stdlib-property-tests/blob/master/tests/test_datetime.py
+
+датой и временем - datetime. В этом типе таится много нюансов: високосные
+года и секунда, таймзоны, DST (перевод часов на зимнее и летнее время). Из-за
+високосной секунды может быть время 23:59:60 и даже 23:59:61, 29 февраля
+бывает только в високосный год, причем иногда переход на DST может
+происходить два раза в год. С этим типом связано какое-то невероятное
+количество ошибок. Я даже писал заметку про баг в Excel, там до сих пор для
+совместимости с предыдущими версиями 1900-й год считается високосным, хотя он
+не високосный. Одно время Microsoft выпускала музыкальный плеер Zune и там
+тоже была проблема расчёта високосного года - 31 декабря в високосный год все
+плееры выключились.
+
+С таким количеством деталей идея применить тестирование с помощью свойств
+кажется очень заманчивой - генерировать тестовые примеры на всём диапазоне и
+проверять соответствие спецификации. Но я пересмотрел тесты для реализации
+datetime в Python и Go и всё тестирование там строится вокруг конкретных
+примеров. Хотя нет, вру, автор Hypothesis писал в своём блоге про тестирование
+парсинга даты из строки с помощью roundtrip - если напечатать дату в заданном
+формате в строку и распарсить её с тем же форматом, то результат должен
+совпасть с первоначальной датой. Но roundtrip это слишком просто, хотя тот же
+автор Hypothesis нашел с таким тестом проблему в базовой библиотеке Python. И
+автор книги Software testing: Craftsman approach разбирал тестирование функции
+NextDate().
+
+Несколько типов багов: переполнение, неверная арифметика, парсинг дат из строк.
+Those that lead to error conditions, such as exceptions, error return codes,
+uninitialized variables, or endless loops
+Those that lead to incorrect data, such as off-by-one problems in range
+queries or aggregation
+
+"datetime.parse_date"
+"datetime.now"
+"datetime.interval"
+
+https://www.tarantool.io/en/doc/latest/reference/reference_lua/datetime/
+
+-- Property: Timezone changes when DST applied.
+-- Property: The day before Saturday is always Friday.
+-- Property: 29.02.YYYY + 1 day == 01.03.(YYYY + 1), where YYYY is a leap year.
+-- Property: 28.02.YYYY + 1 day == 01.03.(YYYY + 1), where YYYY is a non-leap year.
+
+-- Прибавление месяцев к времени даёт ту же дату в другом месяце, кроме
+-- случаев, когда в итоговом месяце меньше дней нежели в исходном. В этом
+-- случае получаем последний день.
+-- 31 января + 1 месяц = 28 или 29 февраля
+-- 30 января + 1 месяц = 28 или 29 февраля
+-- 29 февраля + 1 месяц = 29 марта
+-- 31 марта + 1 месяц = 30 апреля
+
+-- Прибавление месяцев к последнему дню месяца (требует обсуждения). При
+-- прибавлении месяцев к последнему дню месяца надо получать последний день
+-- месяца.
+-- 31 января + 1 месяц  = 28 или 29 февраля
+-- 29 февраля + 1 месяц = 31 марта
+-- 31 марта + 1 месяц = 30 апреля
+-- 30 апреля + 1 месяц = 31 мая
+-- 28 февраля 2001 + 1 месяц = 28 марта 2001
+-- 28 февраля 2004 + 1 месяц = 28 марта 2004
+]]
+
+local math = require("math")
+local datetime = require("datetime")
+local luzer = require("luzer")
+
+local MIN_DATE_YEAR = -5879610
+local MAX_DATE_YEAR = 5879611
+
+local function new_dt_fmt(fdp)
+	-- Field descriptors.
+  	local desc = {
+                '%a',
+                '%A',
+                '%b',
+                '%B',
+                '%h',
+                '%c',
+                '%C',
+                '%d',
+                '%e',
+                '%D',
+                '%H',
+                '%I',
+                '%j',
+                '%m',
+                '%M',
+                '%n',
+                '%p',
+                '%r',
+                '%R',
+                '%S',
+                '%t',
+                '%T',
+                '%U',
+                '%w',
+                '%W',
+                '%x',
+                '%X',
+                '%y',
+                '%Y',
+	}
+	local n = fdp:consume_integer(1, 5)
+	local fmt = ''
+	for i = 1, n do
+        local field_idx = fdp:consume_integer(1, #desc)
+        fmt = ("%s%s"):format(fmt, desc[field_idx])
+	end
+
+	return fmt
+end
+
+local function new_dt(fdp)
+    local tz_idx = fdp:consume_integer(1, #datetime.TZ)
+    local d = 0
+	--[[
+	Day number. Value range: 1 - 31. The special value -1 generates the last
+	day of a particular month.
+	]]
+    while d == 0 do
+        d = fdp:consume_integer(-1, 31)
+    end
+
+    return {
+        -- FIXME: only one of nsec, usec or msecs may be defined simultaneously.
+        -- TODO: usec
+        -- TODO: msec
+        nsec      = fdp:consume_integer(0, 1000000000),
+        sec       = fdp:consume_integer(0, 60),
+        min       = fdp:consume_integer(0, 59),
+        hour      = fdp:consume_integer(0, 23),
+        day       = d,
+        month     = fdp:consume_integer(1, 12),
+        year      = fdp:consume_integer(MIN_DATE_YEAR, MAX_DATE_YEAR),
+        tzoffset  = fdp:consume_integer(-720, 840),
+        tz        = datetime.TZ[tz_idx],
+    }
+end
+
+-- Minimum supported date - -5879610-06-22.
+local min_dt = {
+    nsec      = 0,
+    sec       = 0,
+    min       = 0,
+    hour      = 0,
+    day       = -1,
+    month     = 1,
+    year      = MIN_DATE_YEAR,
+    tzoffset  = -720,
+}
+
+-- Maximum supported date - 5879611-07-11.
+local max_dt = {
+    nsec      = 1000000000,
+    sec       = 60,
+    min       = 59,
+    hour      = 23,
+    day       = 31,
+    month     = 12,
+    year      = MAX_DATE_YEAR,
+    tzoffset  = 840,
+}
+
+-- https://docs.microsoft.com/en-us/office/troubleshoot/excel/determine-a-leap-year
+local function isLeapYear(year)
+    -- bool leap = st.wYear % 4 == 0 && (st.wYear % 100 != 0 || st.wYear % 400 == 0);
+    if year%4 ~= 0 then
+        return false
+    elseif year%100 ~= 0 then
+        return true
+    elseif year%400 ~= 0 then
+        return false
+    else
+        return true
+    end
+end
+
+local function getLeapYear(is_leap)
+    while true do
+        local y = math.random(MIN_DATE_YEAR, MAX_DATE_YEAR)
+        if isLeapYear(y) == is_leap then
+            return y
+        end
+    end
+end
+
+local function TestOneInput(buf)
+    local fdp = luzer.FuzzedDataProvider(buf)
+    local time_units1 = new_dt(fdp)
+    local time_units2 = new_dt(fdp)
+    local dt1, dt2
+
+    -- Sanity check.
+    if not pcall(datetime.new, time_units1) or
+       not pcall(datetime.new, time_units2) then
+        return
+    end
+
+    local datetime_fmt = new_dt_fmt(fdp)
+
+    -- Property: datetime.parse(dt:format(random_format)) == dt
+	--[[
+    dt1 = datetime.new(time_units1)
+    local dt1_str = dt1:format(datetime_fmt)
+    local dt_parsed = datetime.parse(dt1_str, { format = datetime_fmt })
+    assert(dt_parsed == dt1)
+	]]
+
+    -- Property: B - (B - A) == A
+    -- Blocked by: https://github.com/tarantool/tarantool/issues/7145
+    dt1 = datetime.new(time_units1)
+    dt2 = datetime.new(time_units2)
+    local sub_dt = dt1 - dt2
+    local add_dt = sub_dt + dt2
+    -- GH-7145 assert(add_dt == dt1, "(A - B) + B != A")
+
+    -- Property: A - A == B - B
+    -- https://github.com/tarantool/tarantool/issues/7144
+    dt1 = datetime.new(time_units1)
+    dt2 = datetime.new(time_units2)
+    assert(dt1 - dt1 == dt2 - dt2, "A - A != B - B")
+
+    -- Property: datetime.new(dt) == datetime.new():set(dt)
+    dt1 = datetime.new(time_units1)
+    assert(dt1 == datetime.new():set(time_units1), "new(dt) != new():set(dt)")
+
+    -- Property: dt == datetime.new(dt):totable()
+    dt1 = datetime.new(time_units1)
+    table.equals(dt1, dt1:totable())
+
+    -- Property: datetime.parse(tostring(dt)):format() == tostring(dt)
+    dt1 = datetime.new(time_units1)
+    dt2 = datetime.new(time_units2)
+    local dt_iso8601 = datetime.parse(tostring(dt1), {format = 'iso8601'}):format()
+    assert(dt_iso8601 == tostring(dt1), ('Parse roundtrip with iso8601 %s'):format(tostring(dt1)))
+    local dt_rfc3339 = datetime.parse(tostring(dt1), {format = 'rfc3339'}):format()
+    assert(dt_rfc3339 == tostring(dt1), ('Parse roundtrip with rfc3339 %s'):format(tostring(dt1)))
+
+    -- Property: in leap year last day in February is 29.
+    dt1 = datetime.new(time_units1)
+    dt1:set({
+        day = 01,
+        month = 02,
+        year = getLeapYear(true),
+    })
+    assert(dt1:set({ day = -1 }).day == 29, ("Last day in %s (leap year) is not a 29"):format(tostring(dt1)))
+
+    -- Property: in non-leap year last day in February is 28.
+    dt1 = datetime.new(time_units1)
+    dt1:set({
+        day = 01,
+        month = 02,
+        year = getLeapYear(false),
+    })
+    assert(dt1:set({ day = -1 }).day == 28, ("Last day in %s (non-leap year) is not a 28"):format(tostring(dt1)))
+
+    -- Property: Formatted datetime is the same as produced by os.date().
+    dt1 = datetime.new(time_units1)
+    -- Seems os.date() does not support negative epoch.
+    if dt1.epoch > 0 then
+        local msg = ('os.date("%s", %d) != dt:format("%s")'):format(datetime_fmt, dt1.epoch, datetime_fmt)
+        --assert(os.date(datetime_fmt, dt1.epoch) == dt1:format(datetime_fmt), msg)
+    end
+
+    -- Property: 28.02.YYYY + 1 year == 28.02.(YYYY + 1), where YYYY is a non-leap year.
+    local dt1 = datetime.new(time_units1)
+    dt1:set({
+        year = getLeapYear(false),
+        month = 02,
+        day = 28,
+    })
+    local dt_plus_1y = dt1:add({year = 1})
+    -- https://www.quora.com/When-did-using-a-leap-year-start
+    if dt_plus_1y.year > 1584 then
+        local msg = ('Non-leap year: 28.02.YYYY + 1Y != 28.02.(YYYY + 1): %s + 1y != %s '):format(dt1, dt_plus_1y)
+        -- TODO: assert(dt_plus_1y.day == 28, msg)
+    end
+
+    -- Property: 29.02.YYYY + 1 year == 28.02.(YYYY + 1), where YYYY is a leap year.
+    local dt1 = datetime.new(time_units1)
+    dt1:set({
+        year = getLeapYear(true),
+        month = 02,
+        day = 29,
+    })
+    dt_plus_1y = dt1:add({year = 1})
+    -- https://www.quora.com/When-did-using-a-leap-year-start
+    if dt_plus_1y.year > 1584 then
+        local msg = ('Leap year: 29.02.YYYY + 1Y != 28.02.(YYYY + 1): %s + 1y != %s'):format(dt1, dt_plus_1y)
+        assert(dt_plus_1y.day == 28, msg)
+    end
+
+    -- Property: 31.03.YYYY + 1 month == 30.04.YYYY
+    local dt1 = datetime.new(time_units1)
+    dt1:set({
+        month = 03,
+        day = 31,
+    })
+    local dt_plus_1m = dt1
+    dt_plus_1m:add({ month = 1 })
+    local msg = ('31.03.YYYY + 1m != 30.04.YYYY: %s + 1m != %s'):format(dt1, dt_plus_1m)
+    -- TODO: assert(dt_plus_1m.day == 30, msg)
+    msg = ('31.03.YYYY + 1m != 30.04.YYYY: %s + 1m != %s'):format(dt1, dt_plus_1m)
+    assert(dt_plus_1m.month == 04, msg)
+
+    -- Property: 31.12.YYYY + 1 day == 01.01.(YYYY + 1)
+    -- "February 29 is not the only day affected by the leap year. Another very
+    -- important date is December 31, because it is the 366th day of the year and
+    -- many applications mistakenly hard-code a year as 365 days."
+    -- Source: https://azure.microsoft.com/en-us/blog/is-your-code-ready-for-the-leap-year/
+    local dt1 = datetime.new(time_units1)
+    dt1:set({
+        day = 01,
+        month = 12,
+    })
+    dt1 = dt1:set({ day = -1 })
+    assert(dt1.day == 31)
+    dt1 = dt1:add({ day = 1})
+    -- TODO: assert(dt.day == 1, ('31 Dec + 1 day != 1 Jan (%s)'):format(dt))
+
+    -- Property: Difference of datetimes with leap and non-leap years is 1 second.
+    local leap_year = getLeapYear(true)
+    local non_leap_year = getLeapYear(false)
+    dt1 = datetime.new({ year = leap_year })
+    dt2 = datetime.new({ year = non_leap_year })
+    --local diff = datetime.new():set({ year = leap_year - non_leap_year, sec = 1 })
+    -- TODO: assert(dt1 - dt2 == single_sec, ('%s - %s != 1 sec (%s)'):format(dt1, dt2, dt1 - dt2))
+end
+
+if arg[1] then
+    local fh = io.open(arg[1])
+    local testcase = fh:read("*all")
+    TestOneInput(testcase)
+    os.exit()
+end
+
+local script_path = debug.getinfo(1).source:match("@?(.*/)")
+
+local args = {
+    max_len = 2048,
+    print_pcs = 1,
+    detect_leaks = 1,
+    corpus = script_path .. "tarantool-corpus/datetime_new",
+    artifact_prefix = "datetime_new_",
+    max_total_time = 60,
+    print_final_stats = 1,
+}
+luzer.Fuzz(TestOneInput, nil, args)