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
6import itertools
7import logging
8import os
9import unittest
10
11import common
12from autotest_lib.site_utils import lab_inventory
13from autotest_lib.site_utils import status_history
14
15
16class _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
42class _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
79class 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
149class 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
270class 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
330class 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
439class _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
492class 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 = '''
620Board                  Avail   Bad  Good Spare Total
621lion                      -1    13    11    12    24
622tiger                     -1     5     9     4    14
623bear                       0     7    10     7    17
624aardvark                   1     6     6     7    12
625platypus                   2     4    20     6    24
626echidna                    6     0    20     6    20
627'''
628
629
630class 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 = '''
750Board                    Bad  Good Total
751lion                       5     6    11
752tiger                      4     5     9
753bear                       3     7    10
754aardvark                   2     0     2
755platypus                   1     1     2
756'''
757
758_POOL_ADMIN_URL = 'http://go/cros-manage-duts'
759
760
761
762class 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
985class 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@mumbler.com'
1071        a2 = 'bumble@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
1107if __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