Repository: ESMCI/cime

Issue:
Run a test on Theta with a walltime less than the default queue min walltime
According to https://alcf.anl.gov/support-center/theta/job-scheduling-policy-theta:
On Theta, there is a minimum job time of thirty (00:30:00) minutes for the default queue.

This can be confirmed with the following commands on a login node of Theta:
```
qsub -A ClimateEnergy_4 -t 00:15:00 -n 128 -q default -I
Connecting to thetamom3 for interactive qsub...
Job routed to queue "default".
No handlers could be found for logger "Proxy"
<Fault 1001: "Walltime less than the 'default' queue min walltime of 00:30:00\n">
Connection to thetamom3 closed.
```
Run the following test on Theta with an explicit --walltime argument which is less than 30 mins
`create_test ERP_Ld3.ne30_oECv3.A_WCYCL1850S.theta_intel.allactive-pioroot1 --project ClimateEnergy_4 --walltime 00:15:00`
The error messages will be something like:
```
        CIME.utils.CIMEError: ERROR: Command: '/projects/ccsm/acme/tools/cobalt/dsub -A ClimateEnergy_4 -t 00:15:00 -n 129 -q default --mode script --env ARGS_FOR_SCRIPT='--skip-preview-namelist' .case.test' failed with error 'Job routed to queue "default".
        No handlers could be found for logger "Proxy"
        <Fault 1001: "Walltime less than the 'default' queue min walltime of 00:30:00\n">' from dir 'ERP_Ld3.ne30_oECv3.A_WCYCL1850S.theta_intel.allactive-pioroot1.20210609_041934_vpfsi6'
        
        During handling of the above exception, another exception occurred:
        
        Traceback (most recent call last):
          File "./case.submit", line 126, in <module>
            _main_func(__doc__)
          File "./case.submit", line 123, in _main_func
            mail_user=mail_user, mail_type=mail_type, batch_args=batch_args, workflow=workflow)
          File "E3SM/cime/scripts/Tools/../../scripts/lib/CIME/case/case_submit.py", line 208, in submit
            is_batch=is_batch)
          File "E3SM/cime/scripts/Tools/../../scripts/lib/CIME/utils.py", line 1811, in run_and_log_case_status
            custom_success_msg = custom_success_msg_functor(rv) if custom_success_msg_functor else None
          File "E3SM/cime/scripts/Tools/../../scripts/lib/CIME/case/case_submit.py", line 207, in <lambda>
            custom_success_msg_functor=lambda x: x.split(":")[-1],
        AttributeError: 'NoneType' object has no attribute 'split'
```
While "Fault 1001" returned by dsub command is as expected, there are two others issues to take a look at:
1) Before calling the job submission command, CIME might consider printing out a warning (or an error) on user-specified 15-min walltime, or resetting it to 30-min automatically. Actually, in E3SM/cime_config/machines/config_batch.xml, walltimemin is defined as 30-min:
```
    <batch_system MACH="theta" type="cobalt_theta">
      <queues>
        <queue walltimemax="01:00:00" nodemin="1" nodemax="8" strict="true">debug-cache-quad</queue>
        <queue walltimemin="00:30:00" walltimemax="03:00:00" nodemin="128" nodemax="255"  strict="true">default</queue>
        <queue walltimemin="00:30:00" walltimemax="06:00:00" nodemin="256" nodemax="383"  strict="true">default</queue>
        <queue walltimemin="00:30:00" walltimemax="09:00:00" nodemin="384" nodemax="639"  strict="true">default</queue>
        <queue walltimemin="00:30:00" walltimemax="12:00:00" nodemin="640" nodemax="801"  strict="true">default</queue>
        <queue walltimemin="00:30:00" walltimemax="24:00:00" nodemin="802" strict="true" default="true">default</queue>
      </queues>
    </batch_system>
```
It seems that this higher level check is missing: "-t 00:15:00" is still passed to the dsub command on Theta.

2) The Python exception (AttributeError: 'NoneType' object has no attribute 'split') might also be fixed when dsub command returns an error.



Discussion:
@dqwu Thanks for writing this up. I'll take a look and should have a fix soon.

Resolving patch:
diff --git a/scripts/lib/CIME/XML/env_batch.py b/scripts/lib/CIME/XML/env_batch.py
--- a/scripts/lib/CIME/XML/env_batch.py
+++ b/scripts/lib/CIME/XML/env_batch.py
@@ -227,7 +227,12 @@ def set_job_defaults(self, batch_jobs, case):
             logger.info("job is {} USER_REQUESTED_WALLTIME {} USER_REQUESTED_QUEUE {} WALLTIME_FORMAT {}".format(job, walltime, force_queue, walltime_format))
             task_count = int(jsect["task_count"]) if "task_count" in jsect else case.total_tasks
 
-            walltime = jsect["walltime"] if ("walltime" in jsect and walltime is None) else walltime
+            if "walltime" in jsect and walltime is None:
+                walltime = jsect["walltime"]
+
+                logger.debug("Using walltime {!r} from batch job "
+                             "spec".format(walltime))
+
             if "task_count" in jsect:
                 # job is using custom task_count, need to compute a node_count based on this
                 node_count = int(math.ceil(float(task_count)/float(case.tasks_per_node)))
@@ -240,7 +245,7 @@ def set_job_defaults(self, batch_jobs, case):
                 queue = self.select_best_queue(node_count, task_count, name=force_queue, walltime=None, job=job)
                 if queue is not None:
                     # It was, override the walltime if a test, otherwise just warn the user
-                    new_walltime = self.get_queue_specs(queue)[3]
+                    new_walltime = self.get_queue_specs(queue)[4]
                     expect(new_walltime is not None, "Should never make it here")
                     logger.warning("WARNING: Requested walltime '{}' could not be matched by any {} queue".format(walltime, force_queue))
                     if case.get_value("TEST"):
@@ -252,18 +257,61 @@ def set_job_defaults(self, batch_jobs, case):
             if queue is None:
                 logger.warning("WARNING: No queue on this system met the requirements for this job. Falling back to defaults")
                 queue = self.get_default_queue()
-                walltime = self.get_queue_specs(queue)[3]
+                walltime = self.get_queue_specs(queue)[4]
 
             specs = self.get_queue_specs(queue)
+
             if walltime is None:
                 # Figure out walltime
                 if specs is None:
                     # Queue is unknown, use specs from default queue
                     walltime = self.get(self.get_default_queue(), "walltimemax")
+
+                    logger.debug("Using walltimemax {!r} from default "
+                                 "queue {!r}".format(
+                                     walltime, self.text(queue)))
                 else:
-                    walltime = specs[3]
+                    walltime = specs[4]
+
+                    logger.debug("Using walltimemax {!r} from queue "
+                                 "{!r}".format(walltime, self.text(queue)))
+
+                if walltime is None:
+                    walltime = self._default_walltime
+
+                    logger.debug("Last resort using default walltime "
+                                 "{!r}".format(walltime))
 
-                walltime = self._default_walltime if walltime is None else walltime # last-chance fallback
+            # only enforce when not running a test
+            if not case.get_value("TEST"):
+                walltimemin = specs[3]
+                walltimemax = specs[4]
+
+                walltime_seconds = convert_to_seconds(walltime)
+
+                # walltime must not be less than walltimemin
+                if walltimemin is not None:
+                    walltimemin_seconds = convert_to_seconds(walltimemin)
+
+                    if walltime_seconds < walltimemin_seconds:
+                        logger.warning("WARNING: Job {!r} walltime "
+                                       "{!r} is less than queue "
+                                       "{!r} minimum walltime "
+                                       "{!r}, job might fail".format(
+                                       job, walltime, self.text(queue),
+                                           walltimemin))
+
+                # walltime must not be more than walltimemax
+                if walltimemax is not None:
+                    walltimemax_seconds = convert_to_seconds(walltimemax)
+
+                    if walltime_seconds > walltimemax_seconds:
+                        logger.warning("WARNING: Job {!r} walltime "
+                                       "{!r} is more than queue "
+                                       "{!r} maximum walltime "
+                                       "{!r}, job might fail". format(
+                                       job, walltime, self.text(queue),
+                                           walltimemax))
 
             walltime_format = self.get_value("walltime_format")
             if walltime_format:
@@ -748,7 +796,8 @@ def get_job_id(self, output):
     def queue_meets_spec(self, queue, num_nodes, num_tasks, walltime=None, job=None):
         specs = self.get_queue_specs(queue)
 
-        nodemin, nodemax, jobname, walltimemax, jobmin, jobmax, strict = specs
+        nodemin, nodemax, jobname, _, walltimemax, jobmin, \
+            jobmax, strict = specs
 
         # A job name match automatically meets spec
         if job is not None and jobname is not None:
@@ -779,10 +828,17 @@ def _get_all_queue_names(self):
         return queue_names
 
     def select_best_queue(self, num_nodes, num_tasks, name=None, walltime=None, job=None):
+        logger.debug("Selecting best queue with criteria nodes={!r}, "
+                     "tasks={!r}, name={!r}, walltime={!r}, job={!r}".format(
+                       num_nodes, num_tasks, name, walltime, job
+                     ))
+
         # Make sure to check default queue first.
         qnodes = self.get_all_queues(name=name)
         for qnode in qnodes:
             if self.queue_meets_spec(qnode, num_nodes, num_tasks, walltime=walltime, job=job):
+                logger.debug("Selected queue {!r}".format(self.text(qnode)))
+
                 return qnode
 
         return None
@@ -807,10 +863,12 @@ def get_queue_specs(self, qnode):
         expect( nodemax is None or jobmax is None, "Cannot specify both nodemax and jobmax for a queue")
 
         jobname = self.get(qnode, "jobname")
+        walltimemin = self.get(qnode, "walltimemin")
         walltimemax = self.get(qnode, "walltimemax")
         strict = self.get(qnode, "strict") == "true"
 
-        return nodemin, nodemax, jobname, walltimemax, jobmin, jobmax, strict
+        return nodemin, nodemax, jobname, walltimemin, walltimemax, jobmin, \
+            jobmax, strict
 
     def get_default_queue(self):
         bs_nodes = self.get_children("batch_system")
diff --git a/scripts/lib/CIME/case/case.py b/scripts/lib/CIME/case/case.py
--- a/scripts/lib/CIME/case/case.py
+++ b/scripts/lib/CIME/case/case.py
@@ -84,8 +84,8 @@ def __init__(self, case_root=None, read_only=True, record=False):
 
         if case_root is None:
             case_root = os.getcwd()
-
-        expect(not os.path.isdir(case_root) or os.path.isfile(os.path.join(case_root,"env_case.xml")), "Directory {} does not appear to be a valid case directory".format(case_root))
+        if not (in_doctest() or in_unit_test()):
+            expect(not os.path.isdir(case_root) or os.path.isfile(os.path.join(case_root,"env_case.xml")), "Directory {} does not appear to be a valid case directory".format(case_root))
 
         self._caseroot = case_root
         logger.debug("Initializing Case.")
@@ -1669,7 +1669,7 @@ def record_cmd(self, cmd=None, init=False):
 
             # Ensure program path is absolute
             cmd[0] = re.sub("^./", "{}/scripts/".format(cimeroot), cmd[0])
-        else:
+        elif not (in_doctest()  or in_unit_test()):
             expect(caseroot and os.path.isdir(caseroot) and os.path.isfile(os.path.join(caseroot,"env_case.xml")), "Directory {} does not appear to be a valid case directory".format(caseroot))
 
         cmd = " ".join(cmd)
@@ -1810,3 +1810,28 @@ def preview_run(self, write, job):
                 write("    MPIRUN (job={}):".format(job_id))
                 write ("      {}".format(self.get_resolved_value(overrides["mpirun"])))
                 write("")
+
+def in_doctest():
+    """
+    Determined by observation
+    """
+    if '_pytest.doctest' in sys.modules:
+        return True
+    ##
+    if hasattr(sys.modules['__main__'], '_SpoofOut'):
+        return True
+    ##
+    if sys.modules['__main__'].__dict__.get('__file__', '').endswith('/pytest'):
+        return True
+    ##
+    return False
+
+import inspect
+
+def in_unit_test():
+    current_stack = inspect.stack()
+    for stack_frame in current_stack:
+        for program_line in stack_frame[4]:    # This element of the stack frame contains
+            if "unittest" in program_line:       # some contextual program lines
+                return True
+    return False
diff --git a/scripts/lib/CIME/utils.py b/scripts/lib/CIME/utils.py
--- a/scripts/lib/CIME/utils.py
+++ b/scripts/lib/CIME/utils.py
@@ -1321,6 +1321,8 @@ def convert_to_babylonian_time(seconds):
 
     >>> convert_to_babylonian_time(3661)
     '01:01:01'
+    >>> convert_to_babylonian_time(360000)
+    '100:00:00'
     """
     hours = int(seconds / 3600)
     seconds %= 3600
@@ -1793,7 +1795,7 @@ def verbatim_success_msg(return_val):
 CASE_FAILURE = "error"
 def run_and_log_case_status(func, phase, caseroot='.',
                             custom_starting_msg_functor=None,
-                            custom_success_msg_functor=None, 
+                            custom_success_msg_functor=None,
                             is_batch=False):
     starting_msg = None
 
@@ -1808,7 +1810,8 @@ def run_and_log_case_status(func, phase, caseroot='.',
     try:
         rv = func()
     except BaseException:
-        custom_success_msg = custom_success_msg_functor(rv) if custom_success_msg_functor else None
+        custom_success_msg = custom_success_msg_functor(rv) \
+            if custom_success_msg_functor and rv is not None else None
         if phase == "case.submit" and is_batch:
             append_case_status(phase, "starting", msg=custom_success_msg,
                             caseroot=caseroot)
@@ -1817,11 +1820,12 @@ def run_and_log_case_status(func, phase, caseroot='.',
                            caseroot=caseroot)
         raise
     else:
-        custom_success_msg = custom_success_msg_functor(rv) if custom_success_msg_functor else None
+        custom_success_msg = custom_success_msg_functor(rv) \
+            if custom_success_msg_functor else None
         if phase == "case.submit" and is_batch:
             append_case_status(phase, "starting", msg=custom_success_msg,
                             caseroot=caseroot)
-        append_case_status(phase, CASE_SUCCESS, msg=custom_success_msg, 
+        append_case_status(phase, CASE_SUCCESS, msg=custom_success_msg,
                            caseroot=caseroot)
 
     return rv


Test patch:
diff --git a/scripts/lib/CIME/tests/test_utils.py b/scripts/lib/CIME/tests/test_utils.py
--- a/scripts/lib/CIME/tests/test_utils.py
+++ b/scripts/lib/CIME/tests/test_utils.py
@@ -1,8 +1,10 @@
 #!/usr/bin/env python
 
-import sys
 import os
+import sys
+
 import unittest
+from unittest import mock
 from CIME.utils import indent_string, run_and_log_case_status
 
 from . import utils
@@ -82,9 +84,9 @@ def assertMatchAllLines(self, tempdir, test_lines):
             error.extend([x.rstrip("\n") for x in missing])
             error.extend(["", "Tempfile contents", ""])
             error.extend([x.rstrip("\n") for x in data])
-            
+
         self.assertTrue(result, msg="\n".join(error))
-    
+
     def test_run_and_log_case_status(self):
         test_lines = [
             "00:00:00 default starting \n",
@@ -141,17 +143,47 @@ def test_run_and_log_case_status_custom_msg(self):
             "00:00:00 default success success extra\n",
         ]
 
-        starting_func = lambda *args: "starting extra"
-        success_func = lambda *args: "success extra"
+        starting_func = mock.MagicMock(return_value="starting extra")
+        success_func = mock.MagicMock(return_value="success extra")
+
+        def normal_func():
+            return "data"
 
         with utils.TemporaryDirectory() as tempdir, MockTime():
-            run_and_log_case_status(self.base_func, "default", 
+            run_and_log_case_status(normal_func, "default",
                                     custom_starting_msg_functor=starting_func,
                                     custom_success_msg_functor=success_func,
                                     caseroot=tempdir)
 
             self.assertMatchAllLines(tempdir, test_lines)
 
+        starting_func.assert_called_with()
+        success_func.assert_called_with("data")
+
+    def test_run_and_log_case_status_custom_msg_error_on_batch(self):
+        test_lines = [
+            "00:00:00 default starting starting extra\n",
+            "00:00:00 default success success extra\n",
+        ]
+
+        starting_func = mock.MagicMock(return_value="starting extra")
+        success_func = mock.MagicMock(return_value="success extra")
+
+        def error_func():
+            raise Exception("Error")
+
+        with utils.TemporaryDirectory() as tempdir, MockTime(), \
+                self.assertRaises(Exception):
+            run_and_log_case_status(error_func, "default",
+                                    custom_starting_msg_functor=starting_func,
+                                    custom_success_msg_functor=success_func,
+                                    caseroot=tempdir)
+
+            self.assertMatchAllLines(tempdir, test_lines)
+
+        starting_func.assert_called_with()
+        success_func.assert_not_called()
+
     def test_run_and_log_case_status_error(self):
         test_lines = [
             "00:00:00 default starting \n",
diff --git a/scripts/lib/CIME/tests/test_xml_env_batch.py b/scripts/lib/CIME/tests/test_xml_env_batch.py
new file mode 100644
--- /dev/null
+++ b/scripts/lib/CIME/tests/test_xml_env_batch.py
@@ -0,0 +1,271 @@
+import unittest
+from unittest import mock
+
+from CIME.XML.env_batch import EnvBatch
+
+# pylint: disable=unused-argument
+
+class TestXMLEnvBatch(unittest.TestCase):
+
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get")
+    def test_get_queue_specs(self, get):
+        node = mock.MagicMock()
+
+        batch = EnvBatch()
+
+        get.side_effect = [
+            "1", "1", None, None, "case.run", "05:00:00", "12:00:00", "false",
+        ]
+
+        nodemin, nodemax, jobname, walltimemin, walltimemax, jobmin, jobmax, \
+            strict = batch.get_queue_specs(node)
+
+        self.assertTrue(nodemin == 1)
+        self.assertTrue(nodemax == 1)
+        self.assertTrue(jobname == "case.run")
+        self.assertTrue(walltimemin == "05:00:00")
+        self.assertTrue(walltimemax == "12:00:00")
+        self.assertTrue(jobmin == None)
+        self.assertTrue(jobmax == None)
+        self.assertFalse(strict)
+
+    @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default")
+    # nodemin, nodemax, jobname, walltimemin, walltimemax, jobmin, jobmax, strict
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_queue_specs", return_value=[
+        1, 1, "case.run", "08:00:00", "12:00:00", 1, 1, False,
+    ])
+    @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue")
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue")
+    def test_set_job_defaults_honor_walltimemax(self, get_default_queue, select_best_queue,
+                              get_queue_specs, text):
+        case = mock.MagicMock()
+
+        batch_jobs = [
+            ("case.run", {
+                "template": "template.case.run",
+                "prereq": "$BUILD_COMPLETE and not $TEST"
+            })
+        ]
+
+        def get_value(*args, **kwargs):
+            if args[0] == "USER_REQUESTED_WALLTIME":
+                return "20:00:00"
+
+            return mock.MagicMock()
+
+        case.get_value = get_value
+
+        case.get_env.return_value.get_jobs.return_value = ["case.run"]
+
+        batch = EnvBatch()
+
+        batch.set_job_defaults(batch_jobs, case)
+
+        env_workflow = case.get_env.return_value
+
+        env_workflow.set_value.assert_any_call("JOB_QUEUE", "default",
+                                               subgroup="case.run",
+                                               ignore_type=False)
+        env_workflow.set_value.assert_any_call("JOB_WALLCLOCK_TIME", "20:00:00",
+                                               subgroup="case.run")
+
+    @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default")
+    # nodemin, nodemax, jobname, walltimemin, walltimemax, jobmin, jobmax, strict
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_queue_specs", return_value=[
+        1, 1, "case.run", "08:00:00", "12:00:00", 1, 1, False,
+    ])
+    @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue")
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue")
+    def test_set_job_defaults_honor_walltimemin(self, get_default_queue, select_best_queue,
+                              get_queue_specs, text):
+        case = mock.MagicMock()
+
+        batch_jobs = [
+            ("case.run", {
+                "template": "template.case.run",
+                "prereq": "$BUILD_COMPLETE and not $TEST"
+            })
+        ]
+
+        def get_value(*args, **kwargs):
+            if args[0] == "USER_REQUESTED_WALLTIME":
+                return "05:00:00"
+
+            return mock.MagicMock()
+
+        case.get_value = get_value
+
+        case.get_env.return_value.get_jobs.return_value = ["case.run"]
+
+        batch = EnvBatch()
+
+        batch.set_job_defaults(batch_jobs, case)
+
+        env_workflow = case.get_env.return_value
+
+        env_workflow.set_value.assert_any_call("JOB_QUEUE", "default",
+                                               subgroup="case.run",
+                                               ignore_type=False)
+        env_workflow.set_value.assert_any_call("JOB_WALLCLOCK_TIME", "05:00:00",
+                                               subgroup="case.run")
+
+    @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default")
+    # nodemin, nodemax, jobname, walltimemax, jobmin, jobmax, strict
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_queue_specs", return_value=[
+        1, 1, "case.run", "08:00:00", "12:00:00", 1, 1, False,
+    ])
+    @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue")
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue")
+    def test_set_job_defaults_user_walltime(self, get_default_queue,
+                                            select_best_queue,
+                                            get_queue_specs, text):
+        case = mock.MagicMock()
+
+        batch_jobs = [
+            ("case.run", {
+                "template": "template.case.run",
+                "prereq": "$BUILD_COMPLETE and not $TEST"
+            })
+        ]
+
+        def get_value(*args, **kwargs):
+            if args[0] == "USER_REQUESTED_WALLTIME":
+                return "10:00:00"
+
+            return mock.MagicMock()
+
+        case.get_value = get_value
+
+        case.get_env.return_value.get_jobs.return_value = ["case.run"]
+
+        batch = EnvBatch()
+
+        batch.set_job_defaults(batch_jobs, case)
+
+        env_workflow = case.get_env.return_value
+
+        env_workflow.set_value.assert_any_call("JOB_QUEUE", "default",
+                                               subgroup="case.run",
+                                               ignore_type=False)
+        env_workflow.set_value.assert_any_call("JOB_WALLCLOCK_TIME", "10:00:00",
+                                               subgroup="case.run")
+
+    @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default")
+    # nodemin, nodemax, jobname, walltimemax, jobmin, jobmax, strict
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_queue_specs", return_value=[
+        1, 1, "case.run", "05:00:00", None, 1, 1, False,
+    ])
+    @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue")
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue")
+    def test_set_job_defaults_walltimemax_none(self, get_default_queue, select_best_queue,
+                              get_queue_specs, text):
+        case = mock.MagicMock()
+
+        batch_jobs = [
+            ("case.run", {
+                "template": "template.case.run",
+                "prereq": "$BUILD_COMPLETE and not $TEST"
+            })
+        ]
+
+        def get_value(*args, **kwargs):
+            if args[0] == "USER_REQUESTED_WALLTIME":
+                return "08:00:00"
+
+            return mock.MagicMock()
+
+        case.get_value = get_value
+
+        case.get_env.return_value.get_jobs.return_value = ["case.run"]
+
+        batch = EnvBatch()
+
+        batch.set_job_defaults(batch_jobs, case)
+
+        env_workflow = case.get_env.return_value
+
+        env_workflow.set_value.assert_any_call("JOB_QUEUE", "default",
+                                               subgroup="case.run",
+                                               ignore_type=False)
+        env_workflow.set_value.assert_any_call("JOB_WALLCLOCK_TIME", "08:00:00",
+                                               subgroup="case.run")
+
+    @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default")
+    # nodemin, nodemax, jobname, walltimemax, jobmin, jobmax, strict
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_queue_specs", return_value=[
+        1, 1, "case.run", None, "12:00:00", 1, 1, False,
+    ])
+    @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue")
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue")
+    def test_set_job_defaults_walltimemin_none(self, get_default_queue, select_best_queue,
+                              get_queue_specs, text):
+        case = mock.MagicMock()
+
+        batch_jobs = [
+            ("case.run", {
+                "template": "template.case.run",
+                "prereq": "$BUILD_COMPLETE and not $TEST"
+            })
+        ]
+
+        def get_value(*args, **kwargs):
+            if args[0] == "USER_REQUESTED_WALLTIME":
+                return "08:00:00"
+
+            return mock.MagicMock()
+
+        case.get_value = get_value
+
+        case.get_env.return_value.get_jobs.return_value = ["case.run"]
+
+        batch = EnvBatch()
+
+        batch.set_job_defaults(batch_jobs, case)
+
+        env_workflow = case.get_env.return_value
+
+        env_workflow.set_value.assert_any_call("JOB_QUEUE", "default",
+                                               subgroup="case.run",
+                                               ignore_type=False)
+        env_workflow.set_value.assert_any_call("JOB_WALLCLOCK_TIME", "08:00:00",
+                                               subgroup="case.run")
+
+    @mock.patch("CIME.XML.env_batch.EnvBatch.text", return_value="default")
+    # nodemin, nodemax, jobname, walltimemax, jobmin, jobmax, strict
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_queue_specs", return_value=[
+        1, 1, "case.run", "08:00:00", "12:00:00", 1, 1, False,
+    ])
+    @mock.patch("CIME.XML.env_batch.EnvBatch.select_best_queue")
+    @mock.patch("CIME.XML.env_batch.EnvBatch.get_default_queue")
+    def test_set_job_defaults(self, get_default_queue, select_best_queue,
+                              get_queue_specs, text):
+        case = mock.MagicMock()
+
+        batch_jobs = [
+            ("case.run", {
+                "template": "template.case.run",
+                "prereq": "$BUILD_COMPLETE and not $TEST"
+            })
+        ]
+
+        def get_value(*args, **kwargs):
+            if args[0] == "USER_REQUESTED_WALLTIME":
+                return None
+
+            return mock.MagicMock()
+
+        case.get_value = get_value
+
+        case.get_env.return_value.get_jobs.return_value = ["case.run"]
+
+        batch = EnvBatch()
+
+        batch.set_job_defaults(batch_jobs, case)
+
+        env_workflow = case.get_env.return_value
+
+        env_workflow.set_value.assert_any_call("JOB_QUEUE", "default",
+                                               subgroup="case.run",
+                                               ignore_type=False)
+        env_workflow.set_value.assert_any_call("JOB_WALLCLOCK_TIME", "12:00:00",
+                                               subgroup="case.run")