Autotest: Report idle devices for the Lab DUT inventory.

Idle DUTs that are typically locked or wedged, are currently classfied as the
same non-working DUT as broken DUTs.

This fixes two things:
    * For the board inventory, request repair for those `BROKEN` DUTs that have
      actually failed repair. Add a column that reports on idle DUTs to
      the message that is sent to englab-sys-cros.
    * For the pool inventory (sent to the deputies) add a column that reports
      on idle DUTs, and a detailed idle DUT list. Ask for deputy's attention.

BUG=chromium:590386
TEST=ran lab_inventory_unittest.py locally with --debug, add idle duts
in the test.

Change-Id: I564dd79de69092276aabca6b6714dc175e37dfda
Reviewed-on: https://chromium-review.googlesource.com/332191
Commit-Ready: Xixuan Wu <xixuan@chromium.org>
Tested-by: Xixuan Wu <xixuan@chromium.org>
Reviewed-by: Xixuan Wu <xixuan@chromium.org>
diff --git a/site_utils/lab_inventory.py b/site_utils/lab_inventory.py
index ba34fe0..cddb489 100755
--- a/site_utils/lab_inventory.py
+++ b/site_utils/lab_inventory.py
@@ -141,16 +141,18 @@
       * `get_working_list()`
       * `get_broken()`
       * `get_broken_list()`
+      * `get_idle()`
+      * `get_idle_list()`
     The first time any one of these methods is called, it causes
     multiple RPC calls with a relatively expensive set of database
     queries.  However, the results of the queries are cached in the
     individual `HostJobHistory` objects, so only the first call
     actually pays the full cost.
 
-    Additionally, `get_working_list()` and `get_broken_list()` both
-    cache their return values to avoid recalculating lists at every
-    call; this caching is separate from the caching of RPC results
-    described above.
+    Additionally, `get_working_list()`, `get_broken_list()` and
+    `get_idle_list()` cache their return values to avoid recalculating
+    lists at every call; this caching is separate from the caching of RPC
+    results described above.
 
     This class is deliberately constructed to delay the RPC cost
     until the accessor methods are called (rather than to query in
@@ -164,6 +166,7 @@
         self._histories = []
         self._working_list = None
         self._broken_list = None
+        self._idle_list = None
 
 
     def record_host(self, host_history):
@@ -175,6 +178,7 @@
         """
         self._working_list = None
         self._broken_list = None
+        self._idle_list = None
         self._histories.append(host_history)
 
 
@@ -204,7 +208,7 @@
         """Return a list of all broken DUTs in the pool.
 
         Filter `self._histories` for histories where the last
-        diagnosis is not `WORKING`.
+        diagnosis is `BROKEN`.
 
         Cache the result so that we only cacluate it once.
 
@@ -213,7 +217,7 @@
         """
         if self._broken_list is None:
             self._broken_list = [h for h in self._histories
-                    if h.last_diagnosis()[0] != status_history.WORKING]
+                    if h.last_diagnosis()[0] == status_history.BROKEN]
         return self._broken_list
 
 
@@ -222,6 +226,29 @@
         return len(self.get_broken_list())
 
 
+    def get_idle_list(self):
+        """Return a list of all idle DUTs in the pool.
+
+        Filter `self._histories` for histories where the last
+        diagnosis is `UNUSED` or `UNKNOWN`.
+
+        Cache the result so that we only cacluate it once.
+
+        @return A list of HostJobHistory objects.
+
+        """
+        idle_list = [status_history.UNUSED, status_history.UNKNOWN]
+        if self._idle_list is None:
+            self._idle_list = [h for h in self._histories
+                    if h.last_diagnosis()[0] in idle_list]
+        return self._idle_list
+
+
+    def get_idle(self):
+        """Return the number of idle DUTs in the pool."""
+        return len(self.get_idle_list())
+
+
     def get_total(self):
         """Return the total number of DUTs in the pool."""
         return len(self._histories)
@@ -311,8 +338,7 @@
         """Return a list of all broken DUTs for the board.
 
         Go through all HostJobHistory objects in the board's pools,
-        selecting the ones where the last diagnosis is not
-        `WORKING`.
+        selecting the ones where the last diagnosis is `BROKEN`.
 
         @return A list of HostJobHistory objects.
 
@@ -334,6 +360,38 @@
         return self._count_pool(_PoolCounts.get_broken, pool)
 
 
+    def get_idle_list(self, pool=None):
+        """Return a list of all idle DUTs for the board.
+
+        Go through all HostJobHistory objects in the board's pools,
+        selecting the ones where the last diagnosis is `UNUSED` or `UNKNOWN`.
+
+        @param pool: The pool to be counted. If `None`, return the total list
+                     across all pools.
+
+        @return A list of HostJobHistory objects.
+
+        """
+        if pool is None:
+            l = []
+            for p in self._pools.values():
+                l.extend(p.get_idle_list())
+            return l
+        else:
+            return _PoolCounts.get_idle_list(self._pools[pool])
+
+
+    def get_idle(self, pool=None):
+        """Return the number of idle DUTs in a pool.
+
+        @param pool: The pool to be counted. If `None`, return the total
+                     across all pools.
+
+        @return The total number of idle DUTs in the selected pool(s).
+        """
+        return self._count_pool(_PoolCounts.get_idle, pool)
+
+
     def get_spares_buffer(self):
         """Return the the nominal number of working spares.
 
@@ -664,6 +722,7 @@
     logging.debug('Creating board inventory')
     nworking = 0
     nbroken = 0
+    nidle = 0
     nbroken_boards = 0
     ntotal_boards = 0
     summaries = []
@@ -672,11 +731,12 @@
         counts = inventory[board]
         # Summary elements laid out in the same order as the text
         # headers:
-        #     Board Avail   Bad  Good Spare Total
-        #      e[0]  e[1]  e[2]  e[3]  e[4]  e[5]
+        #     Board Avail   Bad  Idle  Good  Spare Total
+        #      e[0]  e[1]  e[2]  e[3]  e[4]  e[5]  e[6]
         element = (board,
                    counts.get_spares_buffer(),
                    counts.get_broken(),
+                   counts.get_idle(),
                    counts.get_working(),
                    counts.get_total(_SPARE_POOL),
                    counts.get_total())
@@ -685,15 +745,18 @@
             nbroken_boards += 1
         ntotal_boards += 1
         nbroken += element[2]
-        nworking += element[3]
-    ntotal = nworking + nbroken
+        nidle += element[3]
+        nworking += element[4]
+    ntotal = nworking + nbroken + nidle
     summaries = sorted(summaries, key=lambda e: (e[1], -e[2]))
     broken_percent = int(round(100.0 * nbroken / ntotal))
-    working_percent = 100 - broken_percent
+    idle_percent = int(round(100.0 * nidle / ntotal))
+    working_percent = 100 - broken_percent - idle_percent
     message = ['Summary of DUTs in inventory:',
-               '%10s %10s %6s' % ('Bad', 'Good', 'Total'),
-               '%5d %3d%% %5d %3d%% %6d' % (
+               '%10s %10s %10s %6s' % ('Bad', 'Idle', 'Good', 'Total'),
+               '%5d %3d%% %5d %3d%% %5d %3d%% %6d' % (
                    nbroken, broken_percent,
+                   nidle, idle_percent,
                    nworking, working_percent,
                    ntotal),
                '',
@@ -701,11 +764,11 @@
                'Boards in inventory:  %d' % ntotal_boards,
                '', '',
                'Full board inventory:\n',
-               '%-22s %5s %5s %5s %5s %5s' % (
-                   'Board', 'Avail', 'Bad', 'Good',
+               '%-22s %5s %5s %5s %5s %5s %5s' % (
+                   'Board', 'Avail', 'Bad', 'Idle', 'Good',
                    'Spare', 'Total')]
     message.extend(
-            ['%-22s %5d %5d %5d %5d %5d' % e for e in summaries])
+            ['%-22s %5d %5d %5d %5d %5d %5d' % e for e in summaries])
     return '\n'.join(message)
 
 
@@ -741,28 +804,69 @@
         message.append(
             '%sStatus for pool:%s, by board:' % (newline, pool))
         message.append(
-            '%-20s   %5s %5s %5s' % (
-                'Board', 'Bad', 'Good', 'Total'))
+            '%-20s   %5s %5s %5s %5s' % (
+                'Board', 'Bad', 'Idle', 'Good', 'Total'))
         data_list = []
         for board, counts in inventory.items():
             logging.debug('Counting inventory for %s, %s',
                           board, pool)
             broken = counts.get_broken(pool)
-            if broken == 0:
+            idle = counts.get_idle(pool)
+            # boards at full strength are not reported
+            if broken == 0 and idle == 0:
                 continue
             working = counts.get_working(pool)
             total = counts.get_total(pool)
-            data_list.append((board, broken, working, total))
+            data_list.append((board, broken, idle, working, total))
         if data_list:
             data_list = sorted(data_list, key=lambda d: -d[1])
             message.extend(
-                ['%-20s   %5d %5d %5d' % t for t in data_list])
+                ['%-20s   %5d %5d %5d %5d' % t for t in data_list])
         else:
             message.append('(All boards at full strength)')
         newline = '\n'
     return '\n'.join(message)
 
 
+_IDLE_INVENTORY_HEADER = '''\
+Notice to Infrastructure deputies:  The hosts shown below haven't
+run any jobs for at least 24 hours. Please check each host; locked
+hosts should normally be unlocked; stuck jobs should normally be
+aborted.
+'''
+
+
+def _generate_idle_inventory_message(inventory):
+    """Generate the "idle inventory" e-mail message.
+
+    The idle inventory is a host list with corresponding pool and board,
+    where the hosts are idle (`UNKWOWN` or `UNUSED`).
+
+    N.B. For sample output text format as users can expect to
+    see it in e-mail and log files, refer to the unit tests.
+
+    @param inventory  _LabInventory object with the inventory to
+                      be reported on.
+    @return String with the inventory message to be sent.
+
+    """
+    logging.debug('Creating idle inventory')
+    message = [_IDLE_INVENTORY_HEADER]
+    message.append('Idle Host List:')
+    message.append('%-30s %-20s %s' % ('Hostname', 'Board', 'Pool'))
+    data_list = []
+    for pool in _MANAGED_POOLS:
+        for board, counts in inventory.items():
+            logging.debug('Counting inventory for %s, %s', board, pool)
+            data_list.extend([(dut.host.hostname, board, pool)
+                                  for dut in counts.get_idle_list(pool)])
+    if data_list:
+        message.extend(['%-30s %-20s %s' % t for t in data_list])
+    else:
+        message.append('(No idle DUTs)')
+    return '\n'.join(message)
+
+
 def _send_email(arguments, tag, subject, recipients, body):
     """Send an inventory e-mail message.
 
@@ -1036,11 +1140,13 @@
                         recommend_message + board_message)
 
         if arguments.pool_notify:
+            pool_message = _generate_pool_inventory_message(inventory)
+            idle_message = _generate_idle_inventory_message(inventory)
             _send_email(arguments,
                         'pools-%s.txt' % timestamp,
                         'DUT pool inventory %s' % timestamp,
                         arguments.pool_notify,
-                        _generate_pool_inventory_message(inventory))
+                        pool_message + '\n\n\n' + idle_message)
     except KeyboardInterrupt:
         pass
     except EnvironmentError as e:
diff --git a/site_utils/lab_inventory_unittest.py b/site_utils/lab_inventory_unittest.py
index cde911c..04fa980 100755
--- a/site_utils/lab_inventory_unittest.py
+++ b/site_utils/lab_inventory_unittest.py
@@ -13,13 +13,27 @@
 from autotest_lib.site_utils import status_history
 
 
+class _FakeHost(object):
+    """Class to mock `Host` in _FakeHostHistory for testing."""
+
+    def __init__(self, hostname):
+        self.hostname = hostname
+
+
 class _FakeHostHistory(object):
     """Class to mock `HostJobHistory` for testing."""
 
-    def __init__(self, board, pool, status):
+    def __init__(self, board, pool, status, hostname=''):
         self._board = board
         self._pool = pool
         self._status = status
+        self._host = _FakeHost(hostname)
+
+
+    @property
+    def host(self):
+        """Return the recorded host."""
+        return self._host
 
 
     @property
@@ -73,7 +87,9 @@
 ]
 
 _WORKING = status_history.WORKING
-_BROKEN = _NON_WORKING_STATUS_LIST[0]
+_UNUSED = _NON_WORKING_STATUS_LIST[0]
+_BROKEN = _NON_WORKING_STATUS_LIST[1]
+_UNKNOWN = _NON_WORKING_STATUS_LIST[2]
 
 
 class PoolCountTests(unittest.TestCase):
@@ -99,7 +115,7 @@
         self._pool_counts.record_host(fake)
 
 
-    def _check_counts(self, working, broken):
+    def _check_counts(self, working, broken, idle):
         """Check that pool counts match expectations.
 
         Checks that `get_working()` and `get_broken()` return the
@@ -112,38 +128,44 @@
         """
         self.assertEqual(self._pool_counts.get_working(), working)
         self.assertEqual(self._pool_counts.get_broken(), broken)
+        self.assertEqual(self._pool_counts.get_idle(), idle)
         self.assertEqual(self._pool_counts.get_total(),
-                         working + broken)
+                         working + broken + idle)
 
 
     def test_empty(self):
         """Test counts when there are no DUTs recorded."""
-        self._check_counts(0, 0)
+        self._check_counts(0, 0, 0)
 
 
-    def test_non_working(self):
-        """Test counting for all non-working status values."""
-        count = 0
-        for status in _NON_WORKING_STATUS_LIST:
-            self._add_host(status)
-            count += 1
-            self._check_counts(0, count)
+    def test_broken(self):
+        """Test counting for status: BROKEN."""
+        self._add_host(_BROKEN)
+        self._check_counts(0, 1, 0)
+
+
+    def test_idle(self):
+        """Testing counting for idle status values."""
+        self._add_host(_UNUSED)
+        self._check_counts(0, 0, 1)
+        self._add_host(_UNKNOWN)
+        self._check_counts(0, 0, 2)
 
 
     def test_working_then_broken(self):
         """Test counts after adding a working and then a broken DUT."""
         self._add_host(_WORKING)
-        self._check_counts(1, 0)
+        self._check_counts(1, 0, 0)
         self._add_host(_BROKEN)
-        self._check_counts(1, 1)
+        self._check_counts(1, 1, 0)
 
 
     def test_broken_then_working(self):
         """Test counts after adding a broken and then a working DUT."""
         self._add_host(_BROKEN)
-        self._check_counts(0, 1)
+        self._check_counts(0, 1, 0)
         self._add_host(_WORKING)
-        self._check_counts(1, 1)
+        self._check_counts(1, 1, 0)
 
 
 class BoardCountTests(unittest.TestCase):
@@ -474,7 +496,7 @@
         """
         histories = []
         self.num_duts = 0
-        status_choices = (_WORKING, _BROKEN)
+        status_choices = (_WORKING, _BROKEN, _UNUSED)
         pools = (self._CRITICAL_POOL, self._SPARE_POOL)
         for board, counts in data.items():
             for i in range(0, len(pools)):
@@ -533,23 +555,29 @@
         """
         working_total = 0
         broken_total = 0
+        idle_total = 0
         managed_boards = set()
         for b in self.inventory:
             c = self.inventory[b]
             calculated_counts = (
                 (c.get_working(self._CRITICAL_POOL),
-                 c.get_broken(self._CRITICAL_POOL)),
+                 c.get_broken(self._CRITICAL_POOL),
+                 c.get_idle(self._CRITICAL_POOL)),
                 (c.get_working(self._SPARE_POOL),
-                 c.get_broken(self._SPARE_POOL)))
+                 c.get_broken(self._SPARE_POOL),
+                 c.get_idle(self._SPARE_POOL)))
             self.assertEqual(data[b], calculated_counts)
             nworking = data[b][0][0] + data[b][1][0]
             nbroken = data[b][0][1] + data[b][1][1]
+            nidle = data[b][0][2] + data[b][1][2]
             self.assertEqual(nworking, len(c.get_working_list()))
             self.assertEqual(nbroken, len(c.get_broken_list()))
+            self.assertEqual(nidle, len(c.get_idle_list()))
             working_total += nworking
             broken_total += nbroken
-            ncritical = data[b][0][0] + data[b][0][1]
-            nspare = data[b][1][0] + data[b][1][1]
+            idle_total += nidle
+            ncritical = data[b][0][0] + data[b][0][1] + data[b][0][2]
+            nspare = data[b][1][0] + data[b][1][1] + data[b][1][2]
             if ncritical != 0 and nspare != 0:
                 managed_boards.add(b)
         self.assertEqual(self.inventory.get_managed_boards(),
@@ -570,14 +598,14 @@
 
     def test_missing_board(self):
         """Test handling when the board is `None`."""
-        self.create_inventory({None: ((1, 1), (1, 1))})
+        self.create_inventory({None: ((1, 1, 1), (1, 1, 1))})
         self._check_inventory({})
 
 
     def test_board_counts(self):
         """Test counts for various numbers of boards."""
         for nboards in [1, 2, len(self._BOARD_LIST)]:
-            counts = ((1, 1), (1, 1))
+            counts = ((1, 1, 1), (1, 1, 1))
             slice = self._BOARD_LIST[0 : nboards]
             inventory_data = {
                 board: counts for board in slice
@@ -589,10 +617,12 @@
     def test_single_dut_counts(self):
         """Test counts when there is a single DUT per board."""
         testcounts = [
-            ((1, 0), (0, 0)),
-            ((0, 1), (0, 0)),
-            ((0, 0), (1, 0)),
-            ((0, 0), (0, 1)),
+            ((1, 0, 0), (0, 0, 0)),
+            ((0, 1, 0), (0, 0, 0)),
+            ((0, 0, 0), (1, 0, 0)),
+            ((0, 0, 0), (0, 1, 0)),
+            ((0, 0, 1), (0, 0, 0)),
+            ((0, 0, 0), (0, 0, 1)),
         ]
         for counts in testcounts:
             inventory_data = { self._BOARD_LIST[0]: counts }
@@ -617,12 +647,12 @@
 # that simply parrot the original output generation code.
 
 _BOARD_MESSAGE_TEMPLATE = '''
-Board                  Avail   Bad  Good Spare Total
-lion                      -1    13    11    12    24
-tiger                     -1     5     9     4    14
-bear                       0     7    10     7    17
-aardvark                   1     6     6     7    12
-platypus                   2     4    20     6    24
+Board                  Avail   Bad  Idle  Good Spare Total
+lion                      -1    13     2    11    12    26
+tiger                     -1     5     2     9     4    16
+bear                       0     5     2    10     5    17
+platypus                   4     2     2    20     6    24
+aardvark                   7     2     2     6     9    10
 '''
 
 
@@ -649,30 +679,36 @@
         for l in self._board_lines:
             items = l.split()
             board = items[0]
-            good = int(items[3])
             bad = int(items[2])
-            spare = int(items[4])
-            self._board_data.append((board, (good, bad, spare)))
+            idle = int(items[3])
+            good = int(items[4])
+            spare = int(items[5])
+            self._board_data.append((board, (good, bad, idle, spare)))
 
 
     def _make_minimum_spares(self, counts):
         """Create a counts tuple with as few spare DUTs as possible."""
-        good, bad, spares = counts
-        if spares > bad:
-            return ((good + bad - spares, 0),
-                    (spares - bad, bad))
+        good, bad, idle, spares = counts
+        if spares > bad + idle:
+            return ((good + bad +idle - spares, 0, 0),
+                    (spares - bad - idle, bad, idle))
+        elif spares < bad:
+            return ((good, bad - spares, idle), (0, spares, 0))
         else:
-            return ((good, bad - spares), (0, spares))
+            return ((good, 0, idle + bad - spares), (0, bad, spares - bad))
 
 
     def _make_maximum_spares(self, counts):
         """Create a counts tuple with as many spare DUTs as possible."""
-        good, bad, spares = counts
+        good, bad, idle, spares = counts
         if good > spares:
-            return ((good - spares, bad), (spares, 0))
+            return ((good - spares, bad, idle), (spares, 0, 0))
+        elif good + bad > spares:
+            return ((0, good + bad - spares, idle),
+                    (good, spares - good, 0))
         else:
-            return ((0, good + bad - spares),
-                    (good, spares - good))
+            return ((0, 0, good + bad + idle - spares),
+                    (good, bad, spares - good - bad))
 
 
     def _check_board_inventory(self, data):
@@ -721,7 +757,7 @@
             board: self._make_maximum_spares(counts)
                 for board, counts in self._board_data
         }
-        data['elephant'] = ((5, 4), (0, 0))
+        data['elephant'] = ((5, 4, 0), (0, 0, 0))
         self._check_board_inventory(data)
 
 
@@ -731,7 +767,7 @@
             board: self._make_maximum_spares(counts)
                 for board, counts in self._board_data
         }
-        data['elephant'] = ((0, 0), (1, 5))
+        data['elephant'] = ((0, 0, 0), (1, 5, 1))
         self._check_board_inventory(data)
 
 
@@ -741,10 +777,53 @@
             board: self._make_maximum_spares(counts)
                 for board, counts in self._board_data
         }
-        data['elephant'] = ((5, 0), (5, 0))
+        data['elephant'] = ((5, 0, 1), (5, 0, 1))
         self._check_board_inventory(data)
 
 
+class _PoolInventoryTestBase(unittest.TestCase):
+    """Parent class for tests relating to generating pool inventory messages.
+
+    Func `setUp` in the class parses a given |message_template| to obtain
+    header and body.
+    """
+    def _read_template(self, message_template):
+        """Read message template for PoolInventoryTest and IdleInventoryTest.
+
+        @param message_template: the input template to be parsed into: header
+        and content (report_lines).
+
+        """
+        message_lines = message_template.split('\n')
+        self._header = message_lines[1]
+        self._report_lines = message_lines[2:-1]
+
+
+    def _check_report_no_info(self, text):
+        """Test a message body containing no reported info.
+
+        The input `text` was created from a query to an inventory, which has
+        no objects meet the query and leads to an `empty` return. Assert that
+        the text consists of a single line starting with '(' and ending with ')'.
+
+        @param text: Message body text to be tested.
+
+        """
+        self.assertTrue(len(text) == 1 and
+                            text[0][0] == '(' and
+                            text[0][-1] == ')')
+
+
+    def _check_report(self, text):
+        """Test a message against the passed |expected_content|.
+
+        @param text: Message body text to be tested.
+        @param expected_content: The ground-truth content to be compared with.
+
+        """
+        self.assertEqual(text, self._report_lines)
+
+
 # _POOL_MESSAGE_TEMPLATE -
 # This is a sample of the output text produced by
 # _generate_pool_inventory_message().  This string is parsed by the
@@ -756,19 +835,18 @@
 # rationale on using sample text in this way.
 
 _POOL_MESSAGE_TEMPLATE = '''
-Board                    Bad  Good Total
-lion                       5     6    11
-tiger                      4     5     9
-bear                       3     7    10
-aardvark                   2     0     2
-platypus                   1     1     2
+Board                    Bad  Idle  Good Total
+lion                       5     2     6    13
+tiger                      4     1     5    10
+bear                       3     0     7    10
+aardvark                   2     0     0     2
+platypus                   1     1     1     3
 '''
 
 _POOL_ADMIN_URL = 'http://go/cros-manage-duts'
 
 
-
-class PoolInventoryTests(unittest.TestCase):
+class PoolInventoryTests(_PoolInventoryTestBase):
     """Tests for `_generate_pool_inventory_message()`.
 
     The tests create various test inventories designed to match the
@@ -801,19 +879,16 @@
     the `'\n'` separator.
 
     """
-
     def setUp(self):
-        message_lines = _POOL_MESSAGE_TEMPLATE.split('\n')
-        self._header = message_lines[1]
-        self._board_lines = message_lines[2:-1]
+        super(PoolInventoryTests, self)._read_template(_POOL_MESSAGE_TEMPLATE)
         self._board_data = []
-        for l in self._board_lines:
+        for l in self._report_lines:
             items = l.split()
             board = items[0]
-            good = int(items[2])
             bad = int(items[1])
-            self._board_data.append((board, (good, bad)))
-        self._inventory = None
+            idle = int(items[2])
+            good = int(items[3])
+            self._board_data.append((board, (good, bad, idle)))
 
 
     def _create_histories(self, pools, board_data):
@@ -838,7 +913,7 @@
 
         """
         histories = []
-        status_choices = (_WORKING, _BROKEN)
+        status_choices = (_WORKING, _BROKEN, _UNUSED)
         for pool in pools:
             for board, counts in board_data:
                 for status, count in zip(status_choices, counts):
@@ -870,9 +945,9 @@
                 (a list of lines) for the board.
 
         """
-        self._inventory = lab_inventory._LabInventory(histories)
+        inventory = lab_inventory._LabInventory(histories)
         message = lab_inventory._generate_pool_inventory_message(
-                self._inventory).split('\n')
+                inventory).split('\n')
         poolset = set(lab_inventory._CRITICAL_POOLS)
         seen_url = False
         seen_intro = False
@@ -906,38 +981,11 @@
         return board_text
 
 
-    def _check_inventory_no_shortages(self, text):
-        """Test a message body containing no reported shortages.
-
-        The input `text` was created for a pool containing no
-        board shortages.  Assert that the text consists of a
-        single line starting with '(' and ending with ')'.
-
-        @param text  Message body text to be tested.
-
-        """
-        self.assertTrue(len(text) == 1 and
-                            text[0][0] == '(' and
-                            text[0][-1] == ')')
-
-
-    def _check_inventory(self, text):
-        """Test a message against `_POOL_MESSAGE_TEMPLATE`.
-
-        Test that the given message text matches the parsed
-        `_POOL_MESSAGE_TEMPLATE`.
-
-        @param text  Message body text to be tested.
-
-        """
-        self.assertEqual(text, self._board_lines)
-
-
     def test_no_shortages(self):
         """Test correct output when no pools have shortages."""
         board_text = self._parse_pool_summaries([])
         for text in board_text.values():
-            self._check_inventory_no_shortages(text)
+            self._check_report_no_info(text)
 
 
     def test_one_pool_shortage(self):
@@ -949,9 +997,9 @@
             for checkpool in lab_inventory._CRITICAL_POOLS:
                 text = board_text[checkpool]
                 if checkpool == pool:
-                    self._check_inventory(text)
+                    self._check_report(text)
                 else:
-                    self._check_inventory_no_shortages(text)
+                    self._check_report_no_info(text)
 
 
     def test_all_pool_shortages(self):
@@ -963,32 +1011,115 @@
                                        self._board_data))
         board_text = self._parse_pool_summaries(histories)
         for pool in lab_inventory._CRITICAL_POOLS:
-            self._check_inventory(board_text[pool])
+            self._check_report(board_text[pool])
 
 
     def test_full_board_ignored(self):
         """Test that boards at full strength are not reported."""
         pool = lab_inventory._CRITICAL_POOLS[0]
-        full_board = [('echidna', (5, 0))]
+        full_board = [('echidna', (5, 0, 0))]
         histories = self._create_histories((pool,),
                                            full_board)
         text = self._parse_pool_summaries(histories)[pool]
-        self._check_inventory_no_shortages(text)
+        self._check_report_no_info(text)
         board_data = self._board_data + full_board
         histories = self._create_histories((pool,), board_data)
         text = self._parse_pool_summaries(histories)[pool]
-        self._check_inventory(text)
+        self._check_report(text)
 
 
     def test_spare_pool_ignored(self):
         """Test that reporting ignores the spare pool inventory."""
         spare_pool = lab_inventory._SPARE_POOL
-        spare_data = self._board_data + [('echidna', (0, 5))]
+        spare_data = self._board_data + [('echidna', (0, 5, 0))]
         histories = self._create_histories((spare_pool,),
                                            spare_data)
         board_text = self._parse_pool_summaries(histories)
         for pool in lab_inventory._CRITICAL_POOLS:
-            self._check_inventory_no_shortages(board_text[pool])
+            self._check_report_no_info(board_text[pool])
+
+
+_IDLE_MESSAGE_TEMPLATE = '''
+Hostname                       Board                Pool
+chromeos4-row12-rack4-host7    tiger                bvt
+chromeos1-row3-rack1-host2     lion                 bvt
+chromeos3-row2-rack2-host5     lion                 cq
+chromeos2-row7-rack3-host11    platypus             suites
+'''
+
+
+class IdleInventoryTests(_PoolInventoryTestBase):
+    """Tests for `_generate_idle_inventory_message()`.
+
+    The tests create idle duts that match the counts and pool in
+    `_IDLE_MESSAGE_TEMPLATE`. In test, it asserts that the generated
+    idle message text matches the format established in
+    `_IDLE_MESSAGE_TEMPLATE`.
+
+    Parse message text is represented as a list of strings, split on
+    the `'\n'` separator.
+
+    """
+
+    def setUp(self):
+        super(IdleInventoryTests, self)._read_template(_IDLE_MESSAGE_TEMPLATE)
+        self._host_data = []
+        for h in self._report_lines:
+            items = h.split()
+            hostname = items[0]
+            board = items[1]
+            pool = items[2]
+            self._host_data.append((hostname, board, pool))
+        self._histories = []
+        self._histories.append(_FakeHostHistory('echidna', 'bvt', _BROKEN))
+        self._histories.append(_FakeHostHistory('lion', 'bvt', _WORKING))
+
+
+    def _add_idles(self):
+        """Add idle duts from `_IDLE_MESSAGE_TEMPLATE`."""
+        idle_histories = [_FakeHostHistory(
+                board, pool, _UNUSED, hostname=hostname)
+                        for hostname, board, pool in self._host_data]
+        self._histories.extend(idle_histories)
+
+
+    def _check_header(self, text):
+        """Check whether header in the template `_IDLE_MESSAGE_TEMPLATE` is in
+        passed text."""
+        self.assertIn(self._header, text)
+
+
+    def _get_idle_message(self, histories):
+        """Generate idle inventory and obtain its message.
+
+        @param histories: Used to create lab inventory.
+
+        @return the generated idle message.
+
+        """
+        inventory = lab_inventory._LabInventory(histories)
+        message = lab_inventory._generate_idle_inventory_message(
+                inventory).split('\n')
+        return message
+
+
+    def test_check_idle_inventory(self):
+        """Test that reporting all the idle DUTs for every pool, sorted by
+        lab_inventory._MANAGED_POOLS.
+        """
+        self._add_idles()
+
+        message = self._get_idle_message(self._histories)
+        self._check_header(message)
+        self._check_report(message[message.index(self._header) + 1 :])
+
+
+    def test_no_idle_inventory(self):
+        """Test that reporting no idle DUTs."""
+        message = self._get_idle_message(self._histories)
+        self._check_header(message)
+        self._check_report_no_info(
+                message[message.index(self._header) + 1 :])
 
 
 class CommandParsingTests(unittest.TestCase):