############################################################################
#
#       File:     unittest.icn
#
#       Subject:  An object-oriented unit test framework based on Kent Beck's SUnit.
#
#       https://web.archive.org/web/20150315073817/http://www.xprogramming.com/testfram.htm
#
#       Author:   Ian Trudel
#
#       Date:     April 19, 2024
#
############################################################################

# TODO: assertions
#     - asserting objects
#     - asserting nested values (list, table, objects, etc.)
# TODO: Test Reporter
#     - add message to results
#     - add backtrace if possible
# TODO: there should be a way to discover and load tests dynamically
# TODO: implement concurrency, see tests/unicon/data/tester.icn
# TODO: how to catch stdin/stdout/stderr within tests, see capture().
# Summary line: __includeElapsedInSummary / summary() use 1 to print elapsed time
#     ("ran N tests in X.XXXXXXs") or 0 for stable output ("ran N tests" only).
# TODO: enable/disable summary.
# TODO: enable/disable verbosity.

package unittest

link ximage, printf

import lang

invocable all

class TestCase : Object(__status)
   $define STATUS_SUCCESS 0
   $define STATUS_FAIL 1

   method setupClass()
      # write(classname(self), "::setupClass()")
   end

   method teardownClass()
      # write(classname(self), "::teardownClass()")
   end

   method setup()
      # write(classname(self), "::setup()")
   end

   method teardown()
      # write(classname(self), "::teardown()")
   end

   method __reset()
      self.__status := STATUS_SUCCESS
   end

   method assert(expression)
      if not @expression[1] then {
         self.__status := STATUS_FAIL
         write("Assertion failed.")
         fail
      }

      return
   end

   method assertFail(expression)
      if not @expression[1] then return

      write("Assertion failed.")
      self.__status := STATUS_FAIL
      fail
   end

   method assertEqual(expected, actual)
      if type(expected) ~== type(actual) then {
         write("Assertion failed: expected value of type ", type(expected), ", but got ", type(actual))
         self.__status := STATUS_FAIL
         fail
      } else if expected ~== actual then {
         write("Assertion failed: expected ", expected, ", but got ", actual)
         self.__status := STATUS_FAIL
         fail
      }

      return
   end

   method assertNotEqual(unexpected, actual)
      if type(unexpected) === type(actual) & unexpected === actual then {
         write("Assertion failed: unexpected ", unexpected, ", but got ", actual)
         self.__status := STATUS_FAIL
         fail
      }

      return
   end

initially
   /__status := STATUS_SUCCESS
end

class TestSuite(__tests, __testReporter, __includeElapsedInSummary)
   $define EXIT_SUCCESS 0
   $define EXIT_FAILURE 1

   method add(testCase)
      if (classname(testCase) ~=== "unittest__TestCase") then {
         if \testCase.__m["Type"] then {
            every ctype := testCase.Type() do {
               if ctype == "unittest::TestCase" then {
                  put(__tests, testCase)
               }
            }
         }
      }
   end

   method run()
      local mname, summary, t0, t1

      t0 := gettimeofday()

      every test := !__tests do {
         cname := classname(test)

         test.setupClass()
         every image(!test.__m) ? {
            mname := (="procedure ", tab(0)) | next
            if mname ? (tab(*cname+1), ="_", match("test")) then {
               write(mname)
               test.__reset()
               test.setup()
               mname(test)
               test.teardown()

               self.__testReporter.result(cname, mname, test.__status)
               write()
            }
         }
         test.teardownClass()
      }

      t1 := gettimeofday()
      self.__testReporter.runtime(((t1.sec - t0.sec) * 1000000 + (t1.usec - t0.usec)) / 1000000.0)
      summary := self.__testReporter.summary(self.__includeElapsedInSummary)

      return if summary.failed > 0 then EXIT_FAILURE else EXIT_SUCCESS
   end

   method output()
      write(ximage(__tests))
   end

initially
   __tests := []
   /__testReporter := TestReporter()
   /__includeElapsedInSummary := 1
end

class TestReporter(__results, __summary)
   $define STATUS_SUCCESS 0
   $define STATUS_FAIL 1

   record total(passed, failed, runtime)

   method result(cname, mname, passed)
      put(self.__results, [cname, mname, passed])
      if passed == STATUS_SUCCESS then self.__summary.passed +:= 1 else self.__summary.failed +:= 1
   end

   method results()
      return self.__summary
   end

   method runtime(time)
      self.__summary.runtime := real(time)
   end

   method summary(includeElapsed)
      local runtime

      write("=== TEST SUMMARY ===")
      write("passed: " || self.__summary.passed || ", failed: " || self.__summary.failed)
      if includeElapsed = 0 then
         write("ran " || *self.__results || " tests")
      else {
         runtime := sprintf("%.6r", self.__summary.runtime)
         write("ran " || *self.__results || " tests in " || runtime || "s")
      }

      return self.__summary
   end

initially
   /__results := []
   /__summary := total(0, 0, 0.0)
end

class Mock(__expectations)
   method expect(mname, retval)
      self.__expectations[mname] := retval
   end

   method verify(mname, args)
      return member(self.__expectations, mname)
   end

   method invoke(mname, args)
      self.verify(mname, args) | fail
      return self.__expectations[mname]
   end

initially
   /__expectations := table()
end
