Home | History | Annotate | Download | only in site_utils
      1 #!/usr/bin/env python
      2 # Copyright 2015 The Chromium OS Authors. All rights reserved.
      3 # Use of this source code is governed by a BSD-style license that can be
      4 # found in the LICENSE file.
      5 
      6 import itertools
      7 import logging
      8 import os
      9 import unittest
     10 
     11 import common
     12 from autotest_lib.site_utils import lab_inventory
     13 from autotest_lib.site_utils import status_history
     14 
     15 
     16 class _FakeHostHistory(object):
     17     """Class to mock `HostJobHistory` for testing."""
     18 
     19     def __init__(self, board, pool, status):
     20         self._board = board
     21         self._pool = pool
     22         self._status = status
     23 
     24 
     25     @property
     26     def host_board(self):
     27         """Return the recorded board."""
     28         return self._board
     29 
     30 
     31     @property
     32     def host_pool(self):
     33         """Return the recorded host."""
     34         return self._pool
     35 
     36 
     37     def last_diagnosis(self):
     38         """Return the recorded diagnosis."""
     39         return self._status, None
     40 
     41 
     42 class _FakeHostLocation(object):
     43     """Class to mock `HostJobHistory` for location sorting."""
     44 
     45     _HOSTNAME_FORMAT = 'chromeos%d-row%d-rack%d-host%d'
     46 
     47 
     48     def __init__(self, location):
     49         self.hostname = self._HOSTNAME_FORMAT % location
     50 
     51 
     52     @property
     53     def host(self):
     54         """Return a fake host object with a hostname."""
     55         return self
     56 
     57 
     58 # Status values that may be returned by `HostJobHistory`.
     59 #
     60 # _NON_WORKING_STATUS_LIST - The complete list (as of this writing)
     61 #     of status values that the lab_inventory module treats as
     62 #     "broken".
     63 # _WORKING - A value that counts as "working" for purposes
     64 #     of the lab_inventory module.
     65 # _BROKEN - A value that counts as "broken" for the lab_inventory
     66 #     module.  Since there's more than one valid choice here, we've
     67 #     picked one to stand for all of them.
     68 
     69 _NON_WORKING_STATUS_LIST = [
     70     status_history.UNUSED,
     71     status_history.BROKEN,
     72     status_history.UNKNOWN,
     73 ]
     74 
     75 _WORKING = status_history.WORKING
     76 _BROKEN = _NON_WORKING_STATUS_LIST[0]
     77 
     78 
     79 class PoolCountTests(unittest.TestCase):
     80     """Unit tests for class `_PoolCounts`.
     81 
     82     Coverage is quite basic:  mostly just enough to make sure every
     83     function gets called, and to make sure that the counting knows
     84     the difference between 0 and 1.
     85 
     86     The testing also ensures that all known status values that
     87     can be returned by `HostJobHistory` are counted as expected.
     88 
     89     """
     90 
     91     def setUp(self):
     92         super(PoolCountTests, self).setUp()
     93         self._pool_counts = lab_inventory._PoolCounts()
     94 
     95 
     96     def _add_host(self, status):
     97         fake = _FakeHostHistory(
     98                 None, lab_inventory._SPARE_POOL, status)
     99         self._pool_counts.record_host(fake)
    100 
    101 
    102     def _check_counts(self, working, broken):
    103         """Check that pool counts match expectations.
    104 
    105         Checks that `get_working()` and `get_broken()` return the
    106         given expected values.  Also check that `get_total()` is the
    107         sum of working and broken devices.
    108 
    109         @param working The expected total of working devices.
    110         @param broken  The expected total of broken devices.
    111 
    112         """
    113         self.assertEqual(self._pool_counts.get_working(), working)
    114         self.assertEqual(self._pool_counts.get_broken(), broken)
    115         self.assertEqual(self._pool_counts.get_total(),
    116                          working + broken)
    117 
    118 
    119     def test_empty(self):
    120         """Test counts when there are no DUTs recorded."""
    121         self._check_counts(0, 0)
    122 
    123 
    124     def test_non_working(self):
    125         """Test counting for all non-working status values."""
    126         count = 0
    127         for status in _NON_WORKING_STATUS_LIST:
    128             self._add_host(status)
    129             count += 1
    130             self._check_counts(0, count)
    131 
    132 
    133     def test_working_then_broken(self):
    134         """Test counts after adding a working and then a broken DUT."""
    135         self._add_host(_WORKING)
    136         self._check_counts(1, 0)
    137         self._add_host(_BROKEN)
    138         self._check_counts(1, 1)
    139 
    140 
    141     def test_broken_then_working(self):
    142         """Test counts after adding a broken and then a working DUT."""
    143         self._add_host(_BROKEN)
    144         self._check_counts(0, 1)
    145         self._add_host(_WORKING)
    146         self._check_counts(1, 1)
    147 
    148 
    149 class BoardCountTests(unittest.TestCase):
    150     """Unit tests for class `_BoardCounts`.
    151 
    152     Coverage is quite basic:  just enough to make sure every
    153     function gets called, and to make sure that the counting
    154     knows the difference between 0 and 1.
    155 
    156     The tests make sure that both individual pool counts and
    157     totals are counted correctly.
    158 
    159     """
    160 
    161     def setUp(self):
    162         super(BoardCountTests, self).setUp()
    163         self._board_counts = lab_inventory._BoardCounts()
    164 
    165 
    166     def _add_host(self, pool, status):
    167         fake = _FakeHostHistory(None, pool, status)
    168         self._board_counts.record_host(fake)
    169 
    170 
    171     def _check_all_counts(self, working, broken):
    172         """Check that total counts for all pools match expectations.
    173 
    174         Checks that `get_working()` and `get_broken()` return the
    175         given expected values when called without a pool specified.
    176         Also check that `get_total()` is the sum of working and
    177         broken devices.
    178 
    179         Additionally, call the various functions for all the pools
    180         individually, and confirm that the totals across pools match
    181         the given expectations.
    182 
    183         @param working The expected total of working devices.
    184         @param broken  The expected total of broken devices.
    185 
    186         """
    187         self.assertEqual(self._board_counts.get_working(), working)
    188         self.assertEqual(self._board_counts.get_broken(), broken)
    189         self.assertEqual(self._board_counts.get_total(),
    190                          working + broken)
    191         count_working = 0
    192         count_broken = 0
    193         count_total = 0
    194         for pool in lab_inventory._MANAGED_POOLS:
    195             count_working += self._board_counts.get_working(pool)
    196             count_broken += self._board_counts.get_broken(pool)
    197             count_total += self._board_counts.get_total(pool)
    198         self.assertEqual(count_working, working)
    199         self.assertEqual(count_broken, broken)
    200         self.assertEqual(count_total, working + broken)
    201 
    202 
    203     def _check_pool_counts(self, pool, working, broken):
    204         """Check that counts for a given pool match expectations.
    205 
    206         Checks that `get_working()` and `get_broken()` return the
    207         given expected values for the given pool.  Also check that
    208         `get_total()` is the sum of working and broken devices.
    209 
    210         @param pool    The pool to be checked.
    211         @param working The expected total of working devices.
    212         @param broken  The expected total of broken devices.
    213 
    214         """
    215         self.assertEqual(self._board_counts.get_working(pool),
    216                          working)
    217         self.assertEqual(self._board_counts.get_broken(pool),
    218                          broken)
    219         self.assertEqual(self._board_counts.get_total(pool),
    220                          working + broken)
    221 
    222 
    223     def test_empty(self):
    224         """Test counts when there are no DUTs recorded."""
    225         self._check_all_counts(0, 0)
    226         for pool in lab_inventory._MANAGED_POOLS:
    227             self._check_pool_counts(pool, 0, 0)
    228 
    229 
    230     def test_all_working_then_broken(self):
    231         """Test counts after adding a working and then a broken DUT.
    232 
    233         For each pool, add first a working, then a broken DUT.  After
    234         each DUT is added, check counts to confirm the correct values.
    235 
    236         """
    237         working = 0
    238         broken = 0
    239         for pool in lab_inventory._MANAGED_POOLS:
    240             self._add_host(pool, _WORKING)
    241             working += 1
    242             self._check_pool_counts(pool, 1, 0)
    243             self._check_all_counts(working, broken)
    244             self._add_host(pool, _BROKEN)
    245             broken += 1
    246             self._check_pool_counts(pool, 1, 1)
    247             self._check_all_counts(working, broken)
    248 
    249 
    250     def test_all_broken_then_working(self):
    251         """Test counts after adding a broken and then a working DUT.
    252 
    253         For each pool, add first a broken, then a working DUT.  After
    254         each DUT is added, check counts to confirm the correct values.
    255 
    256         """
    257         working = 0
    258         broken = 0
    259         for pool in lab_inventory._MANAGED_POOLS:
    260             self._add_host(pool, _BROKEN)
    261             broken += 1
    262             self._check_pool_counts(pool, 0, 1)
    263             self._check_all_counts(working, broken)
    264             self._add_host(pool, _WORKING)
    265             working += 1
    266             self._check_pool_counts(pool, 1, 1)
    267             self._check_all_counts(working, broken)
    268 
    269 
    270 class LocationSortTests(unittest.TestCase):
    271     """Unit tests for `_sort_by_location()`."""
    272 
    273     def setUp(self):
    274         super(LocationSortTests, self).setUp()
    275 
    276 
    277     def _check_sorting(self, *locations):
    278         """Test sorting a given list of locations.
    279 
    280         The input is an already ordered list of lists of tuples with
    281         row, rack, and host numbers.  The test converts the tuples
    282         to hostnames, preserving the original ordering.  Then it
    283         flattens and scrambles the input, runs it through
    284         `_sort_by_location()`, and asserts that the result matches
    285         the original.
    286 
    287         """
    288         lab = 0
    289         expected = []
    290         for tuples in locations:
    291             lab += 1
    292             expected.append(
    293                     [_FakeHostLocation((lab,) + t) for t in tuples])
    294         scrambled = [e for e in itertools.chain(*expected)]
    295         scrambled = [e for e in reversed(scrambled)]
    296         actual = lab_inventory._sort_by_location(scrambled)
    297         # The ordering of the labs in the output isn't guaranteed,
    298         # so we can't compare `expected` and `actual` directly.
    299         # Instead, we create a dictionary keyed on the first host in
    300         # each lab, and compare the dictionaries.
    301         self.assertEqual({l[0]: l for l in expected},
    302                          {l[0]: l for l in actual})
    303 
    304 
    305     def test_separate_labs(self):
    306         """Test that sorting distinguishes labs."""
    307         self._check_sorting([(1, 1, 1)], [(1, 1, 1)], [(1, 1, 1)])
    308 
    309 
    310     def test_separate_rows(self):
    311         """Test for proper sorting when only rows are different."""
    312         self._check_sorting([(1, 1, 1), (9, 1, 1), (10, 1, 1)])
    313 
    314 
    315     def test_separate_racks(self):
    316         """Test for proper sorting when only racks are different."""
    317         self._check_sorting([(1, 1, 1), (1, 9, 1), (1, 10, 1)])
    318 
    319 
    320     def test_separate_hosts(self):
    321         """Test for proper sorting when only hosts are different."""
    322         self._check_sorting([(1, 1, 1), (1, 1, 9), (1, 1, 10)])
    323 
    324 
    325     def test_diagonal(self):
    326         """Test for proper sorting when all parts are different."""
    327         self._check_sorting([(1, 1, 2), (1, 2, 1), (2, 1, 1)])
    328 
    329 
    330 class InventoryScoringTests(unittest.TestCase):
    331     """Unit tests for `_score_repair_set()`."""
    332 
    333     def setUp(self):
    334         super(InventoryScoringTests, self).setUp()
    335 
    336 
    337     def _make_buffer_counts(self, *counts):
    338         """Create a dictionary suitable as `buffer_counts`.
    339 
    340         @param counts List of tuples with board count data.
    341 
    342         """
    343         self._buffer_counts = dict(counts)
    344 
    345 
    346     def _make_history_list(self, repair_counts):
    347         """Create a list suitable as `repair_list`.
    348 
    349         @param repair_counts List of (board, count) tuples.
    350 
    351         """
    352         pool = lab_inventory._SPARE_POOL
    353         histories = []
    354         for board, count in repair_counts:
    355             for i in range(0, count):
    356                 histories.append(
    357                     _FakeHostHistory(board, pool, _BROKEN))
    358         return histories
    359 
    360 
    361     def _check_better(self, repair_a, repair_b):
    362         """Test that repair set A scores better than B.
    363 
    364         Contruct repair sets from `repair_a` and `repair_b`,
    365         and score both of them using the pre-existing
    366         `self._buffer_counts`.  Assert that the score for A is
    367         better than the score for B.
    368 
    369         @param repair_a Input data for repair set A
    370         @param repair_b Input data for repair set B
    371 
    372         """
    373         score_a = lab_inventory._score_repair_set(
    374                 self._buffer_counts,
    375                 self._make_history_list(repair_a))
    376         score_b = lab_inventory._score_repair_set(
    377                 self._buffer_counts,
    378                 self._make_history_list(repair_b))
    379         self.assertGreater(score_a, score_b)
    380 
    381 
    382     def _check_equal(self, repair_a, repair_b):
    383         """Test that repair set A scores the same as B.
    384 
    385         Contruct repair sets from `repair_a` and `repair_b`,
    386         and score both of them using the pre-existing
    387         `self._buffer_counts`.  Assert that the score for A is
    388         equal to the score for B.
    389 
    390         @param repair_a Input data for repair set A
    391         @param repair_b Input data for repair set B
    392 
    393         """
    394         score_a = lab_inventory._score_repair_set(
    395                 self._buffer_counts,
    396                 self._make_history_list(repair_a))
    397         score_b = lab_inventory._score_repair_set(
    398                 self._buffer_counts,
    399                 self._make_history_list(repair_b))
    400         self.assertEqual(score_a, score_b)
    401 
    402 
    403     def test_improve_worst_board(self):
    404         """Test that improving the worst board improves scoring.
    405 
    406         Construct a buffer counts dictionary with all boards having
    407         different counts.  Assert that it is both necessary and
    408         sufficient to improve the count of the worst board in order
    409         to improve the score.
    410 
    411         """
    412         self._make_buffer_counts(('lion', 0),
    413                                  ('tiger', 1),
    414                                  ('bear', 2))
    415         self._check_better([('lion', 1)], [('tiger', 1)])
    416         self._check_better([('lion', 1)], [('bear', 1)])
    417         self._check_better([('lion', 1)], [('tiger', 2)])
    418         self._check_better([('lion', 1)], [('bear', 2)])
    419         self._check_equal([('tiger', 1)], [('bear', 1)])
    420 
    421 
    422     def test_improve_worst_case_count(self):
    423         """Test that improving the number of worst cases improves the score.
    424 
    425         Construct a buffer counts dictionary with all boards having
    426         the same counts.  Assert that improving two boards is better
    427         than improving one.  Assert that improving any one board is
    428         as good as any other.
    429 
    430         """
    431         self._make_buffer_counts(('lion', 0),
    432                                  ('tiger', 0),
    433                                  ('bear', 0))
    434         self._check_better([('lion', 1), ('tiger', 1)], [('bear', 2)])
    435         self._check_equal([('lion', 2)], [('tiger', 1)])
    436         self._check_equal([('tiger', 1)], [('bear', 1)])
    437 
    438 
    439 class _InventoryTests(unittest.TestCase):
    440     """Parent class for tests relating to full Lab inventory.
    441 
    442     This class provides a `create_inventory()` method that allows
    443     construction of a complete `_LabInventory` object from a
    444     simplified input representation.  The input representation
    445     is a dictionary mapping board names to tuples of this form:
    446         `((critgood, critbad), (sparegood, sparebad))`
    447     where:
    448         `critgood` is a number of working DUTs in one critical pool.
    449         `critbad` is a number of broken DUTs in one critical pool.
    450         `sparegood` is a number of working DUTs in one critical pool.
    451         `sparebad` is a number of broken DUTs in one critical pool.
    452 
    453     A single 'critical pool' is arbitrarily chosen for purposes of
    454     testing; there's no coverage for testing arbitrary combinations
    455     in more than one critical pool.
    456 
    457     """
    458 
    459     _CRITICAL_POOL = lab_inventory._CRITICAL_POOLS[0]
    460     _SPARE_POOL = lab_inventory._SPARE_POOL
    461 
    462     def setUp(self):
    463         super(_InventoryTests, self).setUp()
    464         self.num_duts = 0
    465         self.inventory = None
    466 
    467 
    468     def create_inventory(self, data):
    469         """Initialize a `_LabInventory` instance for testing.
    470 
    471         @param data  Representation of Lab inventory data, as
    472                      described above.
    473 
    474         """
    475         histories = []
    476         self.num_duts = 0
    477         status_choices = (_WORKING, _BROKEN)
    478         pools = (self._CRITICAL_POOL, self._SPARE_POOL)
    479         for board, counts in data.items():
    480             for i in range(0, len(pools)):
    481                 for j in range(0, len(status_choices)):
    482                     for x in range(0, counts[i][j]):
    483                         history = _FakeHostHistory(board,
    484                                                    pools[i],
    485                                                    status_choices[j])
    486                         histories.append(history)
    487                         if board is not None:
    488                             self.num_duts += 1
    489         self.inventory = lab_inventory._LabInventory(histories)
    490 
    491 
    492 class LabInventoryTests(_InventoryTests):
    493     """Tests for the basic functions of `_LabInventory`.
    494 
    495     Contains basic coverage to show that after an inventory is
    496     created and DUTs with known status are added, the inventory
    497     counts match the counts of the added DUTs.
    498 
    499     Test inventory objects are created using the `create_inventory()`
    500     method from the parent class.
    501 
    502     """
    503 
    504     # _BOARD_LIST - A list of sample board names for use in testing.
    505 
    506     _BOARD_LIST = [
    507         'lion',
    508         'tiger',
    509         'bear',
    510         'aardvark',
    511         'platypus',
    512         'echidna',
    513         'elephant',
    514         'giraffe',
    515     ]
    516 
    517 
    518     def _check_inventory(self, data):
    519         """Create a test inventory, and confirm that it's correct.
    520 
    521         Tests these assertions:
    522           * The counts of working and broken devices for each
    523             board match the numbers from `data`.
    524           * That the set of returned boards in the inventory matches
    525             the set from `data`.
    526           * That the total number of DUTs matches the number from
    527             `data`.
    528           * That the total number of boards matches the number from
    529             `data`.
    530 
    531         @param data Inventory data as for `self.create_inventory()`.
    532 
    533         """
    534         working_total = 0
    535         broken_total = 0
    536         managed_boards = set()
    537         for b in self.inventory:
    538             c = self.inventory[b]
    539             calculated_counts = (
    540                 (c.get_working(self._CRITICAL_POOL),
    541                  c.get_broken(self._CRITICAL_POOL)),
    542                 (c.get_working(self._SPARE_POOL),
    543                  c.get_broken(self._SPARE_POOL)))
    544             self.assertEqual(data[b], calculated_counts)
    545             nworking = data[b][0][0] + data[b][1][0]
    546             nbroken = data[b][0][1] + data[b][1][1]
    547             self.assertEqual(nworking, len(c.get_working_list()))
    548             self.assertEqual(nbroken, len(c.get_broken_list()))
    549             working_total += nworking
    550             broken_total += nbroken
    551             ncritical = data[b][0][0] + data[b][0][1]
    552             nspare = data[b][1][0] + data[b][1][1]
    553             if ncritical != 0 and nspare != 0:
    554                 managed_boards.add(b)
    555         self.assertEqual(self.inventory.get_managed_boards(),
    556                          managed_boards)
    557         board_list = self.inventory.keys()
    558         self.assertEqual(set(board_list), set(data.keys()))
    559         self.assertEqual(self.inventory.get_num_duts(),
    560                          self.num_duts)
    561         self.assertEqual(self.inventory.get_num_boards(),
    562                          len(data))
    563 
    564 
    565     def test_empty(self):
    566         """Test counts when there are no DUTs recorded."""
    567         self.create_inventory({})
    568         self._check_inventory({})
    569 
    570 
    571     def test_missing_board(self):
    572         """Test handling when the board is `None`."""
    573         self.create_inventory({None: ((1, 1), (1, 1))})
    574         self._check_inventory({})
    575 
    576 
    577     def test_board_counts(self):
    578         """Test counts for various numbers of boards."""
    579         for nboards in [1, 2, len(self._BOARD_LIST)]:
    580             counts = ((1, 1), (1, 1))
    581             slice = self._BOARD_LIST[0 : nboards]
    582             inventory_data = {
    583                 board: counts for board in slice
    584             }
    585             self.create_inventory(inventory_data)
    586             self._check_inventory(inventory_data)
    587 
    588 
    589     def test_single_dut_counts(self):
    590         """Test counts when there is a single DUT per board."""
    591         testcounts = [
    592             ((1, 0), (0, 0)),
    593             ((0, 1), (0, 0)),
    594             ((0, 0), (1, 0)),
    595             ((0, 0), (0, 1)),
    596         ]
    597         for counts in testcounts:
    598             inventory_data = { self._BOARD_LIST[0]: counts }
    599             self.create_inventory(inventory_data)
    600             self._check_inventory(inventory_data)
    601 
    602 
    603 # _BOARD_MESSAGE_TEMPLATE -
    604 # This is a sample of the output text produced by
    605 # _generate_board_inventory_message().  This string is parsed by the
    606 # tests below to construct a sample inventory that should produce
    607 # the output, and then the output is generated and checked against
    608 # this original sample.
    609 #
    610 # Constructing inventories from parsed sample text serves two
    611 # related purposes:
    612 #   - It provides a way to see what the output should look like
    613 #     without having to run the script.
    614 #   - It helps make sure that a human being will actually look at
    615 #     the output to see that it's basically readable.
    616 # This should also help prevent test bugs caused by writing tests
    617 # that simply parrot the original output generation code.
    618 
    619 _BOARD_MESSAGE_TEMPLATE = '''
    620 Board                  Avail   Bad  Good Spare Total
    621 lion                      -1    13    11    12    24
    622 tiger                     -1     5     9     4    14
    623 bear                       0     7    10     7    17
    624 aardvark                   1     6     6     7    12
    625 platypus                   2     4    20     6    24
    626 echidna                    6     0    20     6    20
    627 '''
    628 
    629 
    630 class BoardInventoryTests(_InventoryTests):
    631     """Tests for `_generate_board_inventory_message()`.
    632 
    633     The tests create various test inventories designed to match the
    634     counts in `_BOARD_MESSAGE_TEMPLATE`, and asserts that the
    635     generated message text matches the original message text.
    636 
    637     Message text is represented as a list of strings, split on the
    638     `'\n'` separator.
    639 
    640     """
    641 
    642     def setUp(self):
    643         super(BoardInventoryTests, self).setUp()
    644         # The template string has leading and trailing '\n' that
    645         # won't be in the generated output; we strip them out here.
    646         message_lines = _BOARD_MESSAGE_TEMPLATE.split('\n')
    647         self._header = message_lines[1]
    648         self._board_lines = message_lines[2:-1]
    649         self._board_data = []
    650         for l in self._board_lines:
    651             items = l.split()
    652             board = items[0]
    653             good = int(items[3])
    654             bad = int(items[2])
    655             spare = int(items[4])
    656             self._board_data.append((board, (good, bad, spare)))
    657 
    658 
    659     def _make_minimum_spares(self, counts):
    660         """Create a counts tuple with as few spare DUTs as possible."""
    661         good, bad, spares = counts
    662         if spares > bad:
    663             return ((good + bad - spares, 0),
    664                     (spares - bad, bad))
    665         else:
    666             return ((good, bad - spares), (0, spares))
    667 
    668 
    669     def _make_maximum_spares(self, counts):
    670         """Create a counts tuple with as many spare DUTs as possible."""
    671         good, bad, spares = counts
    672         if good > spares:
    673             return ((good - spares, bad), (spares, 0))
    674         else:
    675             return ((0, good + bad - spares),
    676                     (good, spares - good))
    677 
    678 
    679     def _check_board_inventory(self, data):
    680         """Test that a test inventory creates the correct message.
    681 
    682         Create a test inventory from `data` using
    683         `self.create_inventory()`.  Then generate the board inventory
    684         output, and test that the output matches
    685         `_BOARD_MESSAGE_TEMPLATE`.
    686 
    687         The caller is required to produce data that matches the
    688         values in `_BOARD_MESSAGE_TEMPLATE`.
    689 
    690         @param data Inventory data as for `self.create_inventory()`.
    691 
    692         """
    693         self.create_inventory(data)
    694         message = lab_inventory._generate_board_inventory_message(
    695                 self.inventory).split('\n')
    696         self.assertIn(self._header, message)
    697         body = message[message.index(self._header) + 1 :]
    698         self.assertEqual(body, self._board_lines)
    699 
    700 
    701     def test_minimum_spares(self):
    702         """Test message generation when the spares pool is low."""
    703         data = {
    704             board: self._make_minimum_spares(counts)
    705                 for board, counts in self._board_data
    706         }
    707         self._check_board_inventory(data)
    708 
    709 
    710     def test_maximum_spares(self):
    711         """Test message generation when the critical pool is low."""
    712         data = {
    713             board: self._make_maximum_spares(counts)
    714                 for board, counts in self._board_data
    715         }
    716         self._check_board_inventory(data)
    717 
    718 
    719     def test_ignore_no_spares(self):
    720         """Test that messages ignore boards with no spare pool."""
    721         data = {
    722             board: self._make_maximum_spares(counts)
    723                 for board, counts in self._board_data
    724         }
    725         data['elephant'] = ((5, 4), (0, 0))
    726         self._check_board_inventory(data)
    727 
    728 
    729     def test_ignore_no_critical(self):
    730         """Test that messages ignore boards with no critical pools."""
    731         data = {
    732             board: self._make_maximum_spares(counts)
    733                 for board, counts in self._board_data
    734         }
    735         data['elephant'] = ((0, 0), (1, 5))
    736         self._check_board_inventory(data)
    737 
    738 
    739 # _POOL_MESSAGE_TEMPLATE -
    740 # This is a sample of the output text produced by
    741 # _generate_pool_inventory_message().  This string is parsed by the
    742 # tests below to construct a sample inventory that should produce
    743 # the output, and then the output is generated and checked against
    744 # this original sample.
    745 #
    746 # See the comments on _BOARD_MESSAGE_TEMPLATE above for the
    747 # rationale on using sample text in this way.
    748 
    749 _POOL_MESSAGE_TEMPLATE = '''
    750 Board                    Bad  Good Total
    751 lion                       5     6    11
    752 tiger                      4     5     9
    753 bear                       3     7    10
    754 aardvark                   2     0     2
    755 platypus                   1     1     2
    756 '''
    757 
    758 _POOL_ADMIN_URL = 'http://go/cros-manage-duts'
    759 
    760 
    761 
    762 class PoolInventoryTests(unittest.TestCase):
    763     """Tests for `_generate_pool_inventory_message()`.
    764 
    765     The tests create various test inventories designed to match the
    766     counts in `_POOL_MESSAGE_TEMPLATE`, and assert that the
    767     generated message text matches the format established in the
    768     original message text.
    769 
    770     The output message text is parsed against the following grammar:
    771         <message> -> <intro> <pool> { "blank line" <pool> }
    772         <intro> ->
    773             Instructions to depty mentioning the admin page URL
    774             A blank line
    775         <pool> ->
    776             <description>
    777             <header line>
    778             <message body>
    779         <description> ->
    780             Any number of lines describing one pool
    781         <header line> ->
    782             The header line from `_POOL_MESSAGE_TEMPLATE`
    783         <message body> ->
    784             Any number of non-blank lines
    785 
    786     After parsing messages into the parts described above, various
    787     assertions are tested against the parsed output, including
    788     that the message body matches the body from
    789     `_POOL_MESSAGE_TEMPLATE`.
    790 
    791     Parse message text is represented as a list of strings, split on
    792     the `'\n'` separator.
    793 
    794     """
    795 
    796     def setUp(self):
    797         message_lines = _POOL_MESSAGE_TEMPLATE.split('\n')
    798         self._header = message_lines[1]
    799         self._board_lines = message_lines[2:-1]
    800         self._board_data = []
    801         for l in self._board_lines:
    802             items = l.split()
    803             board = items[0]
    804             good = int(items[2])
    805             bad = int(items[1])
    806             self._board_data.append((board, (good, bad)))
    807         self._inventory = None
    808 
    809 
    810     def _create_histories(self, pools, board_data):
    811         """Return a list suitable to create a `_LabInventory` object.
    812 
    813         Creates a list of `_FakeHostHistory` objects that can be
    814         used to create a lab inventory.  `pools` is a list of strings
    815         naming pools, and `board_data` is a list of tuples of the
    816         form
    817             `(board, (goodcount, badcount))`
    818         where
    819             `board` is a board name.
    820             `goodcount` is the number of working DUTs in the pool.
    821             `badcount` is the number of broken DUTs in the pool.
    822 
    823         @param pools       List of pools for which to create
    824                            histories.
    825         @param board_data  List of tuples containing boards and DUT
    826                            counts.
    827         @return A list of `_FakeHostHistory` objects that can be
    828                 used to create a `_LabInventory` object.
    829 
    830         """
    831         histories = []
    832         status_choices = (_WORKING, _BROKEN)
    833         for pool in pools:
    834             for board, counts in board_data:
    835                 for status, count in zip(status_choices, counts):
    836                     for x in range(0, count):
    837                         histories.append(
    838                             _FakeHostHistory(board, pool, status))
    839         return histories
    840 
    841 
    842     def _parse_pool_summaries(self, histories):
    843         """Parse message output according to the grammar above.
    844 
    845         Create a lab inventory from the given `histories`, and
    846         generate the pool inventory message.  Then parse the message
    847         and return a dictionary mapping each pool to the message
    848         body parsed after that pool.
    849 
    850         Tests the following assertions:
    851           * Each <description> contains a mention of exactly one
    852             pool in the `_CRITICAL_POOLS` list.
    853           * Each pool is mentioned in exactly one <description>.
    854         Note that the grammar requires the header to appear once
    855         for each pool, so the parsing implicitly asserts that the
    856         output contains the header.
    857 
    858         @param histories  Input used to create the test
    859                           `_LabInventory` object.
    860         @return A dictionary mapping board names to the output
    861                 (a list of lines) for the board.
    862 
    863         """
    864         self._inventory = lab_inventory._LabInventory(histories)
    865         message = lab_inventory._generate_pool_inventory_message(
    866                 self._inventory).split('\n')
    867         poolset = set(lab_inventory._CRITICAL_POOLS)
    868         seen_url = False
    869         seen_intro = False
    870         description = ''
    871         board_text = {}
    872         current_pool = None
    873         for line in message:
    874             if not seen_url:
    875                 if _POOL_ADMIN_URL in line:
    876                     seen_url = True
    877             elif not seen_intro:
    878                 if not line:
    879                     seen_intro = True
    880             elif current_pool is None:
    881                 if line == self._header:
    882                     pools_mentioned = [p for p in poolset
    883                                            if p in description]
    884                     self.assertEqual(len(pools_mentioned), 1)
    885                     current_pool = pools_mentioned[0]
    886                     description = ''
    887                     board_text[current_pool] = []
    888                     poolset.remove(current_pool)
    889                 else:
    890                     description += line
    891             else:
    892                 if line:
    893                     board_text[current_pool].append(line)
    894                 else:
    895                     current_pool = None
    896         self.assertEqual(len(poolset), 0)
    897         return board_text
    898 
    899 
    900     def _check_inventory_no_shortages(self, text):
    901         """Test a message body containing no reported shortages.
    902 
    903         The input `text` was created for a pool containing no
    904         board shortages.  Assert that the text consists of a
    905         single line starting with '(' and ending with ')'.
    906 
    907         @param text  Message body text to be tested.
    908 
    909         """
    910         self.assertTrue(len(text) == 1 and
    911                             text[0][0] == '(' and
    912                             text[0][-1] == ')')
    913 
    914 
    915     def _check_inventory(self, text):
    916         """Test a message against `_POOL_MESSAGE_TEMPLATE`.
    917 
    918         Test that the given message text matches the parsed
    919         `_POOL_MESSAGE_TEMPLATE`.
    920 
    921         @param text  Message body text to be tested.
    922 
    923         """
    924         self.assertEqual(text, self._board_lines)
    925 
    926 
    927     def test_no_shortages(self):
    928         """Test correct output when no pools have shortages."""
    929         board_text = self._parse_pool_summaries([])
    930         for text in board_text.values():
    931             self._check_inventory_no_shortages(text)
    932 
    933 
    934     def test_one_pool_shortage(self):
    935         """Test correct output when exactly one pool has a shortage."""
    936         for pool in lab_inventory._CRITICAL_POOLS:
    937             histories = self._create_histories((pool,),
    938                                                self._board_data)
    939             board_text = self._parse_pool_summaries(histories)
    940             for checkpool in lab_inventory._CRITICAL_POOLS:
    941                 text = board_text[checkpool]
    942                 if checkpool == pool:
    943                     self._check_inventory(text)
    944                 else:
    945                     self._check_inventory_no_shortages(text)
    946 
    947 
    948     def test_all_pool_shortages(self):
    949         """Test correct output when all pools have a shortage."""
    950         histories = []
    951         for pool in lab_inventory._CRITICAL_POOLS:
    952             histories.extend(
    953                 self._create_histories((pool,),
    954                                        self._board_data))
    955         board_text = self._parse_pool_summaries(histories)
    956         for pool in lab_inventory._CRITICAL_POOLS:
    957             self._check_inventory(board_text[pool])
    958 
    959 
    960     def test_full_board_ignored(self):
    961         """Test that boards at full strength are not reported."""
    962         pool = lab_inventory._CRITICAL_POOLS[0]
    963         full_board = [('echidna', (5, 0))]
    964         histories = self._create_histories((pool,),
    965                                            full_board)
    966         text = self._parse_pool_summaries(histories)[pool]
    967         self._check_inventory_no_shortages(text)
    968         board_data = self._board_data + full_board
    969         histories = self._create_histories((pool,), board_data)
    970         text = self._parse_pool_summaries(histories)[pool]
    971         self._check_inventory(text)
    972 
    973 
    974     def test_spare_pool_ignored(self):
    975         """Test that reporting ignores the spare pool inventory."""
    976         spare_pool = lab_inventory._SPARE_POOL
    977         spare_data = self._board_data + [('echidna', (0, 5))]
    978         histories = self._create_histories((spare_pool,),
    979                                            spare_data)
    980         board_text = self._parse_pool_summaries(histories)
    981         for pool in lab_inventory._CRITICAL_POOLS:
    982             self._check_inventory_no_shortages(board_text[pool])
    983 
    984 
    985 class CommandParsingTests(unittest.TestCase):
    986     """Tests for command line argument parsing in `_parse_command()`."""
    987 
    988     _NULL_NOTIFY = ['--board-notify=', '--pool-notify=']
    989 
    990     def setUp(self):
    991         dirpath = '/usr/local/fubar'
    992         self._command_path = os.path.join(dirpath,
    993                                           'site_utils',
    994                                           'arglebargle')
    995         self._logdir = os.path.join(dirpath, lab_inventory._LOGDIR)
    996 
    997 
    998     def _parse_arguments(self, argv, notify=_NULL_NOTIFY):
    999         full_argv = [self._command_path] + argv + notify
   1000         return lab_inventory._parse_command(full_argv)
   1001 
   1002 
   1003     def _check_non_notify_defaults(self, notify_option):
   1004         arguments = self._parse_arguments([], notify=[notify_option])
   1005         self.assertEqual(arguments.duration,
   1006                          lab_inventory._DEFAULT_DURATION)
   1007         self.assertFalse(arguments.debug)
   1008         self.assertEqual(arguments.logdir, self._logdir)
   1009         self.assertEqual(arguments.boardnames, [])
   1010         return arguments
   1011 
   1012 
   1013     def test_empty_arguments(self):
   1014         """Test that an empty argument list is an error."""
   1015         arguments = self._parse_arguments([], notify=[])
   1016         self.assertIsNone(arguments)
   1017 
   1018 
   1019     def test_argument_defaults(self):
   1020         """Test that option defaults match expectations."""
   1021         arguments = self._check_non_notify_defaults(self._NULL_NOTIFY[0])
   1022         self.assertEqual(arguments.board_notify, [''])
   1023         self.assertEqual(arguments.pool_notify, [])
   1024         arguments = self._check_non_notify_defaults(self._NULL_NOTIFY[1])
   1025         self.assertEqual(arguments.board_notify, [])
   1026         self.assertEqual(arguments.pool_notify, [''])
   1027 
   1028 
   1029     def test_board_arguments(self):
   1030         """Test that non-option arguments are returned in `boardnames`."""
   1031         boardlist = ['aardvark', 'echidna']
   1032         arguments = self._parse_arguments(boardlist)
   1033         self.assertEqual(arguments.boardnames, boardlist)
   1034 
   1035 
   1036     def test_debug_option(self):
   1037         """Test parsing of the `--debug` option."""
   1038         arguments = self._parse_arguments(['--debug'])
   1039         self.assertTrue(arguments.debug)
   1040 
   1041 
   1042     def test_duration(self):
   1043         """Test parsing of the `--duration` option."""
   1044         arguments = self._parse_arguments(['--duration', '1'])
   1045         self.assertEqual(arguments.duration, 1)
   1046         arguments = self._parse_arguments(['--duration', '11'])
   1047         self.assertEqual(arguments.duration, 11)
   1048         arguments = self._parse_arguments(['-d', '1'])
   1049         self.assertEqual(arguments.duration, 1)
   1050         arguments = self._parse_arguments(['-d', '11'])
   1051         self.assertEqual(arguments.duration, 11)
   1052 
   1053 
   1054     def _check_email_option(self, option, getlist):
   1055         """Test parsing of e-mail address options.
   1056 
   1057         This is a helper function to test the `--board-notify` and
   1058         `--pool-notify` options.  It tests the following cases:
   1059           * `--option a1` gives the list [a1]
   1060           * `--option ' a1 '` gives the list [a1]
   1061           * `--option a1 --option a2` gives the list [a1, a2]
   1062           * `--option a1,a2` gives the list [a1, a2]
   1063           * `--option 'a1, a2'` gives the list [a1, a2]
   1064 
   1065         @param option  The option to be tested.
   1066         @param getlist A function to return the option's value from
   1067                        parsed command line arguments.
   1068 
   1069         """
   1070         a1 = 'mumble (at] mumbler.com'
   1071         a2 = 'bumble (at] bumbler.org'
   1072         arguments = self._parse_arguments([option, a1], notify=[])
   1073         self.assertEqual(getlist(arguments), [a1])
   1074         arguments = self._parse_arguments([option, ' ' + a1 + ' '],
   1075                                           notify=[])
   1076         self.assertEqual(getlist(arguments), [a1])
   1077         arguments = self._parse_arguments([option, a1, option, a2],
   1078                                           notify=[])
   1079         self.assertEqual(getlist(arguments), [a1, a2])
   1080         arguments = self._parse_arguments(
   1081                 [option, ','.join([a1, a2])], notify=[])
   1082         self.assertEqual(getlist(arguments), [a1, a2])
   1083         arguments = self._parse_arguments(
   1084                 [option, ', '.join([a1, a2])], notify=[])
   1085         self.assertEqual(getlist(arguments), [a1, a2])
   1086 
   1087 
   1088     def test_board_notify(self):
   1089         """Test parsing of the `--board-notify` option."""
   1090         self._check_email_option('--board-notify',
   1091                                  lambda a: a.board_notify)
   1092 
   1093 
   1094     def test_pool_notify(self):
   1095         """Test parsing of the `--pool-notify` option."""
   1096         self._check_email_option('--pool-notify',
   1097                                  lambda a: a.pool_notify)
   1098 
   1099 
   1100     def test_pool_logdir(self):
   1101         """Test parsing of the `--logdir` option."""
   1102         logdir = '/usr/local/whatsis/logs'
   1103         arguments = self._parse_arguments(['--logdir', logdir])
   1104         self.assertEqual(arguments.logdir, logdir)
   1105 
   1106 
   1107 if __name__ == '__main__':
   1108     # Some of the functions we test log messages.  Prevent those
   1109     # messages from showing up in test output.
   1110     logging.getLogger().setLevel(logging.CRITICAL)
   1111     unittest.main()
   1112