diff --git a/setup.py b/setup.py
index ef0bd7193b74d48b24dc179a256649a75077a388..7a0d08d83ee37bc234d1367a23cdb50f86d8c760 100644
--- a/setup.py
+++ b/setup.py
@@ -15,7 +15,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
 # beamer-slider
 setuptools.setup(
     name="coursebox",
-    version="0.1.18.0",
+    version="0.1.18.2",
     author="Tue Herlau",
     author_email="tuhe@dtu.dk",
     description="A course management system currently used at DTU",
diff --git a/src/coursebox.egg-info/PKG-INFO b/src/coursebox.egg-info/PKG-INFO
index 9a8c93d4ce89a230ecf22b5a8e35d95000ef8566..6c31fbb9b73e78b53fe8367d14d6c9e5d6a9aec1 100644
--- a/src/coursebox.egg-info/PKG-INFO
+++ b/src/coursebox.egg-info/PKG-INFO
@@ -1,6 +1,6 @@
 Metadata-Version: 2.1
 Name: coursebox
-Version: 0.1.17.12
+Version: 0.1.18.2
 Summary: A course management system currently used at DTU
 Home-page: https://lab.compute.dtu.dk/tuhe/coursebox
 Author: Tue Herlau
diff --git a/src/coursebox.egg-info/SOURCES.txt b/src/coursebox.egg-info/SOURCES.txt
index a52feee8aeb68fe04b31153f4833158250d6589b..4941d7b6511f261f67a98c8796a31787b27886c4 100644
--- a/src/coursebox.egg-info/SOURCES.txt
+++ b/src/coursebox.egg-info/SOURCES.txt
@@ -21,4 +21,6 @@ src/coursebox/core/projects_plagiarism.py
 src/coursebox/material/__init__.py
 src/coursebox/material/homepage_lectures_exercises.py
 src/coursebox/material/lecture_questions.py
-src/coursebox/material/snipper.py
\ No newline at end of file
+src/coursebox/material/snipper.py
+src/coursebox/testing/__init__.py
+src/coursebox/testing/testing.py
\ No newline at end of file
diff --git a/src/coursebox/__init__.py b/src/coursebox/__init__.py
index e5cdb65e6cd3e5dec9a640c4cbce3eebcfe51046..7648fd4ab812fe50071f72882a594bd36fdc5c5b 100644
--- a/src/coursebox/__init__.py
+++ b/src/coursebox/__init__.py
@@ -3,4 +3,6 @@
 # conf = "point to main caller file that defines year, semester, etc."
 
 from coursebox.setup_coursebox import setup_coursebox
-
+from coursebox.core.info_paths import get_paths
+from coursebox.core.info import class_information
+# from coursebox.core import info_paths
diff --git a/src/coursebox/core/info.py b/src/coursebox/core/info.py
index 5addb236bfa10febd2c67144b963ed9ba2943d3b..b541f5e2f171e5efb895b6312c989a8ee9e43237 100644
--- a/src/coursebox/core/info.py
+++ b/src/coursebox/core/info.py
@@ -431,7 +431,7 @@ def class_information(verbose=False,
         n = l['number']
         date = l['date']
 
-        dd = timedelta(days=l['show_solutions_after'])
+        dd = timedelta(days=l.get('show_solutions_after', 1))
         d['release_rules'][str(n)] = dict(start=date+dd, end=date+timedelta(days=2000))
 
     if update_with_core_conf:
diff --git a/src/coursebox/testing/__init__.py b/src/coursebox/testing/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/src/coursebox/testing/testing.py b/src/coursebox/testing/testing.py
new file mode 100644
index 0000000000000000000000000000000000000000..83de18f8612604004ce7e5412756f5bd4d1d3ea6
--- /dev/null
+++ b/src/coursebox/testing/testing.py
@@ -0,0 +1,344 @@
+import subprocess
+import time
+from collections import defaultdict
+import glob
+import tabulate
+import sys
+import warnings
+
+def check_by_grade_script(student_dir, module):
+    """
+    TH 2023: Must be runable on a minimal system, i.e. no unitgrade_private etc.
+
+    :param student_dir:
+    :param module:
+    :return:
+    """
+    import sys
+    # from unitgrade.utils import
+    # from unitgrade_private.run import run # Don't use tihs as it is not available during test.
+
+    cmd = f"cd {student_dir} && {sys.executable} -m {module}"
+
+    # output = subprocess.check_output(f"cd {student_dir} && {sys.executable} -m {module}", shell=True, check=False)
+    from subprocess import run
+    print("Running command", cmd)
+    p = run(cmd, capture_output=True, check=False, shell=True)
+    print('exit status:', p.returncode)
+    out = p.stdout.decode()
+    stderr = p.stderr.decode()
+    # process = run(cmd, print_output=False, check=False)
+    # stderr = process.stderr.getvalue().strip()
+    if p.returncode != 0 and not (stderr == "Killed" or stderr == ""):
+        # print(.stdout.getvalue())
+        print(out)
+        print("-"*50)
+        print(stderr)
+        print("-"*50)
+        raise Exception("Run command gave error: " + cmd)
+    # out = process.stdout.getvalue()
+    # s = out[out.rfind("handin"):out.rfind(".token")]
+    # try:
+    #     p = int(s.split("_")[1])
+    #     total = int(s.split("_")[-1])
+    # except Exception as e:
+    #     print("Encountered problem in", module)
+    #     print(out)
+    #     print(stderr)
+    #     print(s)
+    #     print(cmd)
+    #     raise e
+    # out = out.decode("utf-8")
+    # print(out)
+    s = out[out.rfind("handin"):out.rfind(".token")]
+    # try:
+    # p = int(s.split("_")[1])
+    # total = int(s.split("_")[-1])
+
+    a, _, b = s.split("_")[-3:]
+
+    points = int(a), int(b)
+
+    # total = [l for l in out.splitlines() if l.strip().startswith("Total")].pop()
+    # total.split(" ")[1].split("/")
+    # points = total.split(" ")[-1]
+    # a, b = points.split("/")
+    # points = int(a), int(b)
+    # points =
+    return points, out
+
+
+
+def check_py_script(student_dir, module):
+    cmd = f"cd {student_dir} && {sys.executable} -m {module} --unmute" # Don't hardcode 'python' bc. of 'python3' on mac/Linux.
+    try:
+        output = subprocess.check_output(cmd, shell=True)
+    except Exception as e:
+        print("command that failed was")
+        print(cmd)
+        # print("The std. out from the command was")
+        # print(output.decode("utf-8"))
+        raise e
+
+    out = output.decode("utf-8")
+    total = [l for l in out.splitlines() if l.strip().startswith("Total")].pop()
+    total.split(" ")[1].split("/")
+    points = total.split(" ")[-1]
+    a, b = points.split("/")
+    points = int(a), int(b)
+    return points, out
+
+# def check_by_grade_script(student_dir, module):
+#     output = subprocess.check_output(f"cd {student_dir} && {sys.executable}  -m {module}", shell=True)
+#     out = output.decode("utf-8")
+#     s = out[out.rfind("handin"):out.rfind(".token")]
+#     try:
+#         p = int(s.split("_")[1])
+#         total = int(s.split("_")[-1])
+#     except Exception as e:
+#         print("Encountered problem in", module)
+#         print(out)
+#         print(s)
+#         raise e
+#     return p, total, out
+
+def check_pyhon_documentation_in_student_repo(student_dir_complete=None, package="cp"):
+    from interrogate import coverage
+    if student_dir_complete is None:
+        # It is important to not import cp_box here during CI/CD. The coursebox packages is not/should not be installed.
+        from cp_box import cp_main
+        from coursebox.core.info_paths import get_paths
+        paths = get_paths()
+        student_dir_complete = paths['02450students'] + "_complete"
+        # At this point, also set up the studnets_complete repo.
+        from cp_box.material.build_documentation import deploy_students_complete
+        deploy_students_complete()
+
+    from pydocstyle.checker import check
+    n = 0
+    files_ = glob.glob(f"{student_dir_complete}/{package}/ex*/*.py", recursive=True) + glob.glob(f"{student_dir_complete}/{package}/project*/*.py", recursive=True)
+    files_ = [f for f in files_ if not f.endswith("_grade.py")]
+    files = []
+    for f in files_:
+        with open(f, "r") as ff:
+            s = ff.read().splitlines()
+        if len([l for l in s if l.startswith("class ") and "(Report):" in l]) > 0:
+            print("Skipping report", f)
+            continue
+        files.append(f)
+
+    def _darglint_check(filename):
+        import darglint
+        from darglint import analysis
+        from darglint.config import get_config
+        from darglint.config import DocstringStyle
+
+        config = get_config()
+        # config = get_config()
+        from darglint.driver import parser, get_error_report
+        args = parser.parse_args()
+        config.style = DocstringStyle.SPHINX
+        # pass
+
+        raise_errors_for_syntax = args.raise_syntax or False
+        # for filename in files:
+        # filename = student_dir_base + "/cp/project0/fruit_homework.py"
+        error_report = get_error_report(
+            filename,
+            args.verbosity,
+            raise_errors_for_syntax,
+            message_template=args.message_template,
+        )
+        # print(error_report)
+
+        return error_report.splitlines()
+
+
+
+
+    def _pydocstyle_check(filename):
+        all_errs = []
+        n = 0
+
+        o = check([filename])
+        errs = [k for k in o]
+        for err in errs:
+            # print(f"{f}> ", err)
+            all_errs.append(str(err))
+        n += len(errs)
+        # if n > 0:
+        #     print("Total problems", n)
+        return all_errs
+
+    print("="*80)
+    print("Summary of documentation style errors")
+    print("=" * 80)
+
+    n = 0
+    for file in files:
+
+        errs1 = _darglint_check(file)
+        errs2 = _pydocstyle_check(file)
+
+        if len(errs2) + len(errs1) > 0:
+            print(f"{file}> errors were:")
+            print(" - \n".join(errs1))
+            print(" * \n".join(errs2))
+        n += len(errs1) + len(errs2)
+    print("Total errors", n)
+    return n
+
+
+def _run_student_tests(student_dir_base=None, weeks=None, projects=None, fail_if_no_projects=True,
+                       fail_if_no_weeks=True):
+    """
+    TODO: Refactor this function to accept full module paths of tests as input, and move the cp.* specific stuff out. Possibly alternative is to automatically search for tests
+    using conventions. The function should ultimately be moved to coursebox.
+    """
+
+    # still got that common module. Eventually this should be an argument (probably).
+    from cp_box.common import projects_all
+    from cp_box.common import weeks_all
+
+    if projects is None:
+        projects = projects_all
+    else:
+        projects = {k: v for k, v in projects_all.items() if k in projects}
+
+    if weeks is None:
+        weeks = weeks_all
+    else:
+        weeks = {k: v for k, v in weeks_all.items() if k in weeks}
+
+    # if projects is None:
+    #     from coursebox.core.info_paths import core_conf
+    #     projects = list(core_conf['projects_all'].keys())
+
+    # if weeks is None:
+    #     weeks_all = core_conf['weeks_all']
+    #     weeks = weeks_all
+    # else:
+    #     pass
+    # weeks = {k: weeks_all[k] for k in weeks}
+
+    if student_dir_base is None:
+        """ Only import this sometimes to avoid messing up the paths """
+
+        from coursebox.core.info_paths import get_paths
+        paths = get_paths()
+        student_dir_base = paths['02450students']
+
+    bases = {k: projects[k]['module_public'] for k in
+             projects}  # f"cp.project{k}.project{k}_tests" for k in projects if True}
+
+    bases_weekly = [weeks[k]['module_public'] for k in weeks]  # f'cp.tests.tests_week{k:02d}' for k in weeks]
+
+    if fail_if_no_weeks and len(bases_weekly) == 0:
+        raise Exception("No weeks found. Bad configuration.")
+
+    if fail_if_no_projects and len(bases) == 0:
+        raise Exception("No projects found. Bad configuration.")
+
+    # bases_weekly, bases = get_test_imports(weeks, projects)
+    rs = {}
+    for censor_files in [False, True]:
+        # if not censor_files:
+        #     continue
+        student_dir = student_dir_base if censor_files else student_dir_base + "_complete"
+        for project_id, base in bases.items():
+            # student_dir = paths['02450students']
+            # print(">>> Checking tests...", base, "censor?", censor_files)
+            t0 = time.time()
+            (p1, t1), output1 = check_py_script(student_dir, base)
+            time1 = time.time() - t0
+            # print(">>> Checking grade script...")
+            t0 = time.time()
+            if base.endswith("_tests"):
+                base_grade = base[:-len("_tests")] + "_grade"
+            else:
+                base_grade = base + "_grade"
+
+            (p2, t2), output2 = check_by_grade_script(student_dir, base_grade)
+            time2 = time.time() - t0
+            if not censor_files:
+                tokens = glob.glob(student_dir + "/" + "/".join(base.split(".")[:-1]) + "/*.token")
+                assert len(tokens) == 1
+
+            rs[(base, censor_files)] = {'p1': p1, 'p2': p2, 't1': t1, 't2': t2, 'time1': time1, 'time2': time2}
+            """
+            These values reflect
+                p1: Obtained points by project.py-script
+                t1: total points by project.py-script
+
+                p2: obtained points by project_grade.py-script
+                t2: Total points by project_grade.py-script
+            """
+            if p1 != p2:
+                print(output1)
+                print("Obtained (i.e., from current code) points differ:", p1, p2)
+                assert False
+            if t1 != t2:
+                print("Total (obtainable) points differ:", t1, t2)
+                assert False
+
+            assert (t1 > 0)
+            if censor_files:
+                if p1 != 0:
+                    print("Ran tests with code missing. The student should get 0  points, but instead got: ", p1, "of",
+                          t1)
+                    print(rs[(base, censor_files)])
+
+                assert p1 == 0
+                assert p2 == 0
+            else:
+                if p1 != t1:
+                    print(base, "Wrong number of obtained points by regular check script. Output from script is:")
+                    print(p1, t1)
+                    print(output1)
+                    assert False
+                if p2 != t2:
+                    print(base, "Wrong number of obtained points by grade script. Output from grade script is:")
+                    print(p2, t2)
+                    print(output2)
+                    assert False
+
+        for base in bases_weekly:
+            # if not weekly_tests:
+            #     continue
+            # print(">>> Checking tests...", base)
+            t0 = time.time()
+            (p1, t1), output = check_py_script(student_dir, base)
+            time1 = time.time() - t0
+            rs[(base, censor_files)] = {'p1': p1, 't1': t1, 'time1': time1}
+            assert t1 > 0
+            if censor_files:
+                if p1 != 0:
+                    print(p1, t1, base, "censor_files =", censor_files)
+                    print(output)
+                assert p1 == 0
+            else:
+                if p1 != t1:
+                    # import warnings
+                    print("=" * 50)
+                    print("Check of student files when files are NOT censored.")
+                    print("p1, t1 are", p1, t1)
+                    print("Base is", base)
+                    print("Failed check for p1 == p2")
+                    print(output)
+                    print(p1, t1, base, censor_files)
+                    print("=" * 50)
+
+                assert p1 == t1
+
+    print("Main check completed")
+    dd = defaultdict(list)
+    for (k, mode), val in rs.items():
+        # print(k, mode, val)
+        dd['Test'].append(k)
+        dd['censored'].append(mode)
+        dd['Points obtained'].append(val['p1'])
+        dd['Points total'].append(val['t1'])
+        dd['Time taken'].append(int(val['time1']))
+
+    print(tabulate.tabulate(dd, headers='keys'))
+