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 collections
7import itertools
8import logging
9import os
10import unittest
11
12import common
13from autotest_lib.frontend.afe.json_rpc import proxy
14from autotest_lib.server.lib import status_history
15from autotest_lib.site_utils import lab_inventory
16
17
18# _FAKE_TIME - an arbitrary but plausible time_t value.
19# You can make your own with `date +%s`.
20
21_FAKE_TIME = 1537457599
22
23
24class _FakeHost(object):
25    """Class to mock `Host` in _FakeHostHistory for testing."""
26
27    def __init__(self, hostname):
28        self.hostname = hostname
29
30
31class _FakeHostEvent(object):
32    def __init__(self, time):
33        self.start_time = time
34        self.end_time = time + 1
35
36
37class _FakeHostHistory(object):
38    """Class to mock `HostJobHistory` for testing."""
39
40    def __init__(self, model, pool, status, hostname=''):
41        self.host_model = model
42        self.host_board = model + '_board'
43        self.host_pool = pool
44        self.status = status
45        self.host = _FakeHost(hostname)
46        self.hostname = hostname
47        self.start_time = _FAKE_TIME
48        self.end_time = _FAKE_TIME + 20
49        self.fake_task = _FakeHostEvent(_FAKE_TIME + 5)
50        self.exception = None
51
52    def last_diagnosis(self):
53        """Return the recorded diagnosis."""
54        if self.exception:
55            raise self.exception
56        else:
57            return self.status, self.fake_task
58
59
60class _FakeHostLocation(object):
61    """Class to mock `HostJobHistory` for location sorting."""
62
63    _HOSTNAME_FORMAT = 'chromeos%d-row%d-rack%d-host%d'
64
65    def __init__(self, location):
66        self.hostname = self._HOSTNAME_FORMAT % location
67
68    @property
69    def host(self):
70        """Return a fake host object with a hostname."""
71        return self
72
73
74# Status values that may be returned by `HostJobHistory`.
75#
76# These merely rename the corresponding values in `status_history`
77# for convenience.
78
79_WORKING = status_history.WORKING
80_UNUSED = status_history.UNUSED
81_BROKEN = status_history.BROKEN
82_UNKNOWN = status_history.UNKNOWN
83
84
85class GetStatusTestCase(unittest.TestCase):
86    """Tests for `_get_diagnosis()`."""
87
88    def _get_diagnosis_status(self, history):
89        return lab_inventory._get_diagnosis(history).status
90
91    def test_working_and_in_range(self):
92        """Test WORKING when task times are in the history range."""
93        history = _FakeHostHistory('', '', _WORKING)
94        history.fake_task = _FakeHostEvent(history.start_time + 1)
95        self.assertEqual(self._get_diagnosis_status(history), _WORKING)
96
97    def test_broken_and_in_range(self):
98        """Test BROKEN when task times are in the history range."""
99        history = _FakeHostHistory('', '', _BROKEN)
100        history.fake_task = _FakeHostEvent(history.start_time + 1)
101        self.assertEqual(self._get_diagnosis_status(history), _BROKEN)
102
103    def test_broken_and_straddles(self):
104        """Test BROKEN when task time straddles the history start point."""
105        history = _FakeHostHistory('', '', _BROKEN)
106        history.fake_task = _FakeHostEvent(history.start_time - 1)
107        self.assertEqual(self._get_diagnosis_status(history), _BROKEN)
108
109    def test_broken_and_out_of_range(self):
110        """Test BROKEN when task times are before the history range."""
111        history = _FakeHostHistory('', '', _BROKEN)
112        history.fake_task = _FakeHostEvent(history.start_time - 2)
113        self.assertEqual(self._get_diagnosis_status(history), _UNUSED)
114
115    def test_exception(self):
116        """Test exceptions raised by `last_diagnosis()`."""
117        history = _FakeHostHistory('', '', _BROKEN)
118        history.exception = proxy.JSONRPCException('exception for testing')
119        self.assertIsNone(self._get_diagnosis_status(history))
120
121
122class HostSetInventoryTestCase(unittest.TestCase):
123    """Unit tests for class `_HostSetInventory`.
124
125    Coverage is quite basic:  mostly just enough to make sure every
126    function gets called, and to make sure that the counting knows
127    the difference between 0 and 1.
128
129    The testing also ensures that all known status values that can be
130    returned by `HostJobHistory` are counted as expected.
131    """
132
133    def setUp(self):
134        super(HostSetInventoryTestCase, self).setUp()
135        self.histories = lab_inventory._HostSetInventory()
136
137    def _add_host(self, status):
138        fake = _FakeHostHistory('zebra', lab_inventory.SPARE_POOL, status)
139        self.histories.record_host(fake)
140
141    def _check_counts(self, working, broken, idle):
142        """Check that pool counts match expectations.
143
144        Asserts that `get_working()`, `get_broken()`, and `get_idle()`
145        return the given expected values.  Also assert that
146        `get_total()` is the sum of all counts.
147
148        @param working The expected total of working devices.
149        @param broken  The expected total of broken devices.
150        @param idle  The expected total of idle devices.
151        """
152        self.assertEqual(self.histories.get_working(), working)
153        self.assertEqual(self.histories.get_broken(), broken)
154        self.assertEqual(self.histories.get_idle(), idle)
155        self.assertEqual(self.histories.get_total(),
156                         working + broken + idle)
157
158    def test_empty(self):
159        """Test counts when there are no DUTs recorded."""
160        self._check_counts(0, 0, 0)
161
162    def test_broken(self):
163        """Test counting for broken DUTs."""
164        self._add_host(_BROKEN)
165        self._check_counts(0, 1, 0)
166
167    def test_working(self):
168        """Test counting for working DUTs."""
169        self._add_host(_WORKING)
170        self._check_counts(1, 0, 0)
171
172    def test_idle(self):
173        """Testing counting for idle status values."""
174        self._add_host(_UNUSED)
175        self._check_counts(0, 0, 1)
176        self._add_host(_UNKNOWN)
177        self._check_counts(0, 0, 2)
178
179    def test_working_then_broken(self):
180        """Test counts after adding a working and then a broken DUT."""
181        self._add_host(_WORKING)
182        self._add_host(_BROKEN)
183        self._check_counts(1, 1, 0)
184
185    def test_broken_then_working(self):
186        """Test counts after adding a broken and then a working DUT."""
187        self._add_host(_BROKEN)
188        self._add_host(_WORKING)
189        self._check_counts(1, 1, 0)
190
191
192class PoolSetInventoryTestCase(unittest.TestCase):
193    """Unit tests for class `_PoolSetInventory`.
194
195    Coverage is quite basic:  just enough to make sure every function
196    gets called, and to make sure that the counting knows the difference
197    between 0 and 1.
198
199    The tests make sure that both individual pool counts and totals are
200    counted correctly.
201    """
202
203    _POOL_SET = ['humpty', 'dumpty']
204
205    def setUp(self):
206        super(PoolSetInventoryTestCase, self).setUp()
207        self._pool_histories = lab_inventory._PoolSetInventory(self._POOL_SET)
208
209    def _add_host(self, pool, status):
210        fake = _FakeHostHistory('zebra', pool, status)
211        self._pool_histories.record_host(fake)
212
213    def _check_all_counts(self, working, broken):
214        """Check that total counts for all pools match expectations.
215
216        Checks that `get_working()` and `get_broken()` return the
217        given expected values when called without a pool specified.
218        Also check that `get_total()` is the sum of working and
219        broken devices.
220
221        Additionally, call the various functions for all the pools
222        individually, and confirm that the totals across pools match
223        the given expectations.
224
225        @param working The expected total of working devices.
226        @param broken  The expected total of broken devices.
227        """
228        self.assertEqual(self._pool_histories.get_working(), working)
229        self.assertEqual(self._pool_histories.get_broken(), broken)
230        self.assertEqual(self._pool_histories.get_total(),
231                         working + broken)
232        count_working = 0
233        count_broken = 0
234        count_total = 0
235        for pool in self._POOL_SET:
236            count_working += self._pool_histories.get_working(pool)
237            count_broken += self._pool_histories.get_broken(pool)
238            count_total += self._pool_histories.get_total(pool)
239        self.assertEqual(count_working, working)
240        self.assertEqual(count_broken, broken)
241        self.assertEqual(count_total, working + broken)
242
243    def _check_pool_counts(self, pool, working, broken):
244        """Check that counts for a given pool match expectations.
245
246        Checks that `get_working()` and `get_broken()` return the
247        given expected values for the given pool.  Also check that
248        `get_total()` is the sum of working and broken devices.
249
250        @param pool    The pool to be checked.
251        @param working The expected total of working devices.
252        @param broken  The expected total of broken devices.
253        """
254        self.assertEqual(self._pool_histories.get_working(pool),
255                         working)
256        self.assertEqual(self._pool_histories.get_broken(pool),
257                         broken)
258        self.assertEqual(self._pool_histories.get_total(pool),
259                         working + broken)
260
261    def test_empty(self):
262        """Test counts when there are no DUTs recorded."""
263        self._check_all_counts(0, 0)
264        for pool in self._POOL_SET:
265            self._check_pool_counts(pool, 0, 0)
266
267    def test_all_working_then_broken(self):
268        """Test counts after adding a working and then a broken DUT.
269
270        For each pool, add first a working, then a broken DUT.  After
271        each DUT is added, check counts to confirm the correct values.
272        """
273        working = 0
274        broken = 0
275        for pool in self._POOL_SET:
276            self._add_host(pool, _WORKING)
277            working += 1
278            self._check_pool_counts(pool, 1, 0)
279            self._check_all_counts(working, broken)
280            self._add_host(pool, _BROKEN)
281            broken += 1
282            self._check_pool_counts(pool, 1, 1)
283            self._check_all_counts(working, broken)
284
285    def test_all_broken_then_working(self):
286        """Test counts after adding a broken and then a working DUT.
287
288        For each pool, add first a broken, then a working DUT.  After
289        each DUT is added, check counts to confirm the correct values.
290        """
291        working = 0
292        broken = 0
293        for pool in self._POOL_SET:
294            self._add_host(pool, _BROKEN)
295            broken += 1
296            self._check_pool_counts(pool, 0, 1)
297            self._check_all_counts(working, broken)
298            self._add_host(pool, _WORKING)
299            working += 1
300            self._check_pool_counts(pool, 1, 1)
301            self._check_all_counts(working, broken)
302
303
304class LocationSortTests(unittest.TestCase):
305    """Unit tests for `_sort_by_location()`."""
306
307    def setUp(self):
308        super(LocationSortTests, self).setUp()
309
310    def _check_sorting(self, *locations):
311        """Test sorting a given list of locations.
312
313        The input is an already ordered list of lists of tuples with
314        row, rack, and host numbers.  The test converts the tuples
315        to hostnames, preserving the original ordering.  Then it
316        flattens and scrambles the input, runs it through
317        `_sort_by_location()`, and asserts that the result matches
318        the original.
319        """
320        lab = 0
321        expected = []
322        for tuples in locations:
323            lab += 1
324            expected.append(
325                    [_FakeHostLocation((lab,) + t) for t in tuples])
326        scrambled = [e for e in itertools.chain(*expected)]
327        scrambled = [e for e in reversed(scrambled)]
328        actual = lab_inventory._sort_by_location(scrambled)
329        # The ordering of the labs in the output isn't guaranteed,
330        # so we can't compare `expected` and `actual` directly.
331        # Instead, we create a dictionary keyed on the first host in
332        # each lab, and compare the dictionaries.
333        self.assertEqual({l[0]: l for l in expected},
334                         {l[0]: l for l in actual})
335
336    def test_separate_labs(self):
337        """Test that sorting distinguishes labs."""
338        self._check_sorting([(1, 1, 1)], [(1, 1, 1)], [(1, 1, 1)])
339
340    def test_separate_rows(self):
341        """Test for proper sorting when only rows are different."""
342        self._check_sorting([(1, 1, 1), (9, 1, 1), (10, 1, 1)])
343
344    def test_separate_racks(self):
345        """Test for proper sorting when only racks are different."""
346        self._check_sorting([(1, 1, 1), (1, 9, 1), (1, 10, 1)])
347
348    def test_separate_hosts(self):
349        """Test for proper sorting when only hosts are different."""
350        self._check_sorting([(1, 1, 1), (1, 1, 9), (1, 1, 10)])
351
352    def test_diagonal(self):
353        """Test for proper sorting when all parts are different."""
354        self._check_sorting([(1, 1, 2), (1, 2, 1), (2, 1, 1)])
355
356
357class InventoryScoringTests(unittest.TestCase):
358    """Unit tests for `_score_repair_set()`."""
359
360    def setUp(self):
361        super(InventoryScoringTests, self).setUp()
362
363    def _make_buffer_counts(self, *counts):
364        """Create a dictionary suitable as `buffer_counts`.
365
366        @param counts List of tuples with model count data.
367        """
368        self._buffer_counts = dict(counts)
369
370    def _make_history_list(self, repair_counts):
371        """Create a list suitable as `repair_list`.
372
373        @param repair_counts List of (model, count) tuples.
374        """
375        pool = lab_inventory.SPARE_POOL
376        histories = []
377        for model, count in repair_counts:
378            for i in range(0, count):
379                histories.append(
380                    _FakeHostHistory(model, pool, _BROKEN))
381        return histories
382
383    def _check_better(self, repair_a, repair_b):
384        """Test that repair set A scores better than B.
385
386        Contruct repair sets from `repair_a` and `repair_b`,
387        and score both of them using the pre-existing
388        `self._buffer_counts`.  Assert that the score for A is
389        better than the score for B.
390
391        @param repair_a Input data for repair set A
392        @param repair_b Input data for repair set B
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.assertGreater(score_a, score_b)
401
402    def _check_equal(self, repair_a, repair_b):
403        """Test that repair set A scores the same as B.
404
405        Contruct repair sets from `repair_a` and `repair_b`,
406        and score both of them using the pre-existing
407        `self._buffer_counts`.  Assert that the score for A is
408        equal to the score for B.
409
410        @param repair_a Input data for repair set A
411        @param repair_b Input data for repair set B
412        """
413        score_a = lab_inventory._score_repair_set(
414                self._buffer_counts,
415                self._make_history_list(repair_a))
416        score_b = lab_inventory._score_repair_set(
417                self._buffer_counts,
418                self._make_history_list(repair_b))
419        self.assertEqual(score_a, score_b)
420
421    def test_improve_worst_model(self):
422        """Test that improving the worst model improves scoring.
423
424        Construct a buffer counts dictionary with all models having
425        different counts.  Assert that it is both necessary and
426        sufficient to improve the count of the worst model in order
427        to improve the score.
428        """
429        self._make_buffer_counts(('lion', 0),
430                                 ('tiger', 1),
431                                 ('bear', 2))
432        self._check_better([('lion', 1)], [('tiger', 1)])
433        self._check_better([('lion', 1)], [('bear', 1)])
434        self._check_better([('lion', 1)], [('tiger', 2)])
435        self._check_better([('lion', 1)], [('bear', 2)])
436        self._check_equal([('tiger', 1)], [('bear', 1)])
437
438    def test_improve_worst_case_count(self):
439        """Test that improving the number of worst cases improves the score.
440
441        Construct a buffer counts dictionary with all models having
442        the same counts.  Assert that improving two models is better
443        than improving one.  Assert that improving any one model is
444        as good as any other.
445        """
446        self._make_buffer_counts(('lion', 0),
447                                 ('tiger', 0),
448                                 ('bear', 0))
449        self._check_better([('lion', 1), ('tiger', 1)], [('bear', 2)])
450        self._check_equal([('lion', 2)], [('tiger', 1)])
451        self._check_equal([('tiger', 1)], [('bear', 1)])
452
453
454# Each item is the number of DUTs in that status.
455STATUS_CHOICES = (_WORKING, _BROKEN, _UNUSED)
456StatusCounts = collections.namedtuple('StatusCounts', ['good', 'bad', 'idle'])
457# Each item is a StatusCounts tuple specifying the number of DUTs per status in
458# the that pool.
459CRITICAL_POOL = lab_inventory.CRITICAL_POOLS[0]
460SPARE_POOL = lab_inventory.SPARE_POOL
461POOL_CHOICES = (CRITICAL_POOL, SPARE_POOL)
462PoolStatusCounts = collections.namedtuple('PoolStatusCounts',
463                                          ['critical', 'spare'])
464
465def create_inventory(data):
466    """Create a `_LabInventory` instance for testing.
467
468    This function allows the construction of a complete `_LabInventory`
469    object from a simplified input representation.
470
471    A single 'critical pool' is arbitrarily chosen for purposes of
472    testing; there's no coverage for testing arbitrary combinations
473    in more than one critical pool.
474
475    @param data: dict {key: PoolStatusCounts}.
476    @returns: lab_inventory._LabInventory object.
477    """
478    histories = []
479    for model, counts in data.iteritems():
480        for p, pool in enumerate(POOL_CHOICES):
481            for s, status in enumerate(STATUS_CHOICES):
482                fake_host = _FakeHostHistory(model, pool, status)
483                histories.extend([fake_host] * counts[p][s])
484    inventory = lab_inventory._LabInventory(
485            histories, lab_inventory.MANAGED_POOLS)
486    return inventory
487
488
489class LabInventoryTests(unittest.TestCase):
490    """Tests for the basic functions of `_LabInventory`.
491
492    Contains basic coverage to show that after an inventory is created
493    and DUTs with known status are added, the inventory counts match the
494    counts of the added DUTs.
495    """
496
497    _MODEL_LIST = ['lion', 'tiger', 'bear'] # Oh, my!
498
499    def _check_inventory_counts(self, inventory, data, msg=None):
500        """Check that all counts in the inventory match `data`.
501
502        This asserts that the actual counts returned by the various
503        accessor functions for `inventory` match the values expected for
504        the given `data` that created the inventory.
505
506        @param inventory: _LabInventory object to check.
507        @param data Inventory data to check against. Same type as
508                `create_inventory`.
509        """
510        self.assertEqual(set(inventory.keys()), set(data.keys()))
511        for model, histories in inventory.iteritems():
512            expected_counts = data[model]
513            actual_counts = PoolStatusCounts(
514                    StatusCounts(
515                            histories.get_working(CRITICAL_POOL),
516                            histories.get_broken(CRITICAL_POOL),
517                            histories.get_idle(CRITICAL_POOL),
518                    ),
519                    StatusCounts(
520                            histories.get_working(SPARE_POOL),
521                            histories.get_broken(SPARE_POOL),
522                            histories.get_idle(SPARE_POOL),
523                    ),
524            )
525            self.assertEqual(actual_counts, expected_counts, msg)
526
527            self.assertEqual(len(histories.get_working_list()),
528                             sum([p.good for p in expected_counts]),
529                             msg)
530            self.assertEqual(len(histories.get_broken_list()),
531                             sum([p.bad for p in expected_counts]),
532                             msg)
533            self.assertEqual(len(histories.get_idle_list()),
534                             sum([p.idle for p in expected_counts]),
535                             msg)
536
537    def test_empty(self):
538        """Test counts when there are no DUTs recorded."""
539        inventory = create_inventory({})
540        self.assertEqual(inventory.get_num_duts(), 0)
541        self.assertEqual(inventory.get_boards(), set())
542        self._check_inventory_counts(inventory, {})
543        self.assertEqual(inventory.get_num_models(), 0)
544
545    def _check_model_count(self, model_count):
546        """Parameterized test for testing a specific number of models."""
547        msg = '[model: %d]' % (model_count,)
548        models = self._MODEL_LIST[:model_count]
549        data = {
550                m: PoolStatusCounts(
551                        StatusCounts(1, 1, 1),
552                        StatusCounts(1, 1, 1),
553                )
554                for m in models
555        }
556        inventory = create_inventory(data)
557        self.assertEqual(inventory.get_num_duts(), 6 * model_count, msg)
558        self.assertEqual(inventory.get_num_models(), model_count, msg)
559        for pool in [CRITICAL_POOL, SPARE_POOL]:
560            self.assertEqual(set(inventory.get_pool_models(pool)),
561                             set(models))
562        self._check_inventory_counts(inventory, data, msg=msg)
563
564    def test_model_counts(self):
565        """Test counts for various numbers of models."""
566        self.longMessage = True
567        for model_count in range(0, len(self._MODEL_LIST)):
568            self._check_model_count(model_count)
569
570    def _check_single_dut_counts(self, critical, spare):
571        """Parmeterized test for single dut counts."""
572        self.longMessage = True
573        counts = PoolStatusCounts(critical, spare)
574        model = self._MODEL_LIST[0]
575        data = {model: counts}
576        msg = '[data: %s]' % (data,)
577        inventory = create_inventory(data)
578        self.assertEqual(inventory.get_num_duts(), 1, msg)
579        self.assertEqual(inventory.get_num_models(), 1, msg)
580        self._check_inventory_counts(inventory, data, msg=msg)
581
582    def test_single_dut_counts(self):
583        """Test counts when there is a single DUT per board, and it is good."""
584        status_100 = StatusCounts(1, 0, 0)
585        status_010 = StatusCounts(0, 1, 0)
586        status_001 = StatusCounts(0, 0, 1)
587        status_null = StatusCounts(0, 0, 0)
588        self._check_single_dut_counts(status_100, status_null)
589        self._check_single_dut_counts(status_010, status_null)
590        self._check_single_dut_counts(status_001, status_null)
591        self._check_single_dut_counts(status_null, status_100)
592        self._check_single_dut_counts(status_null, status_010)
593        self._check_single_dut_counts(status_null, status_001)
594
595
596# MODEL_MESSAGE_TEMPLATE -
597# This is a sample of the output text produced by
598# _generate_model_inventory_message().  This string is parsed by the
599# tests below to construct a sample inventory that should produce
600# the output, and then the output is generated and checked against
601# this original sample.
602#
603# Constructing inventories from parsed sample text serves two
604# related purposes:
605#   - It provides a way to see what the output should look like
606#     without having to run the script.
607#   - It helps make sure that a human being will actually look at
608#     the output to see that it's basically readable.
609# This should also help prevent test bugs caused by writing tests
610# that simply parrot the original output generation code.
611
612_MODEL_MESSAGE_TEMPLATE = '''
613Model                  Avail   Bad  Idle  Good Spare Total
614lion                      -1    13     2    11    12    26
615tiger                     -1     5     2     9     4    16
616bear                       0     5     2    10     5    17
617platypus                   4     2     2    20     6    24
618aardvark                   7     2     2     6     9    10
619'''
620
621
622class PoolSetInventoryTests(unittest.TestCase):
623    """Tests for `_generate_model_inventory_message()`.
624
625    The tests create various test inventories designed to match the
626    counts in `_MODEL_MESSAGE_TEMPLATE`, and asserts that the
627    generated message text matches the original message text.
628
629    Message text is represented as a list of strings, split on the
630    `'\n'` separator.
631    """
632
633    def setUp(self):
634        self.maxDiff = None
635        lines = [x.strip() for x in _MODEL_MESSAGE_TEMPLATE.split('\n') if
636                 x.strip()]
637        self._header, self._model_lines = lines[0], lines[1:]
638        self._model_data = []
639        for l in self._model_lines:
640            items = l.split()
641            model = items[0]
642            bad, idle, good, spare = [int(x) for x in items[2:-1]]
643            self._model_data.append((model, (good, bad, idle, spare)))
644
645    def _make_minimum_spares(self, counts):
646        """Create a counts tuple with as few spare DUTs as possible."""
647        good, bad, idle, spares = counts
648        if spares > bad + idle:
649            return PoolStatusCounts(
650                    StatusCounts(good + bad +idle - spares, 0, 0),
651                    StatusCounts(spares - bad - idle, bad, idle),
652            )
653        elif spares < bad:
654            return PoolStatusCounts(
655                    StatusCounts(good, bad - spares, idle),
656                    StatusCounts(0, spares, 0),
657            )
658        else:
659            return PoolStatusCounts(
660                    StatusCounts(good, 0, idle + bad - spares),
661                    StatusCounts(0, bad, spares - bad),
662            )
663
664    def _make_maximum_spares(self, counts):
665        """Create a counts tuple with as many spare DUTs as possible."""
666        good, bad, idle, spares = counts
667        if good > spares:
668            return PoolStatusCounts(
669                    StatusCounts(good - spares, bad, idle),
670                    StatusCounts(spares, 0, 0),
671            )
672        elif good + bad > spares:
673            return PoolStatusCounts(
674                    StatusCounts(0, good + bad - spares, idle),
675                    StatusCounts(good, spares - good, 0),
676            )
677        else:
678            return PoolStatusCounts(
679                    StatusCounts(0, 0, good + bad + idle - spares),
680                    StatusCounts(good, bad, spares - good - bad),
681            )
682
683    def _check_message(self, message):
684        """Checks that message approximately matches expected string."""
685        message = [x.strip() for x in message.split('\n') if x.strip()]
686        self.assertIn(self._header, message)
687        body = message[message.index(self._header) + 1:]
688        self.assertEqual(body, self._model_lines)
689
690    def test_minimum_spares(self):
691        """Test message generation when the spares pool is low."""
692        data = {
693            model: self._make_minimum_spares(counts)
694                for model, counts in self._model_data
695        }
696        inventory = create_inventory(data)
697        message = lab_inventory._generate_model_inventory_message(inventory)
698        self._check_message(message)
699
700    def test_maximum_spares(self):
701        """Test message generation when the critical pool is low."""
702        data = {
703            model: self._make_maximum_spares(counts)
704                for model, counts in self._model_data
705        }
706        inventory = create_inventory(data)
707        message = lab_inventory._generate_model_inventory_message(inventory)
708        self._check_message(message)
709
710    def test_ignore_no_spares(self):
711        """Test that messages ignore models with no spare pool."""
712        data = {
713            model: self._make_maximum_spares(counts)
714                for model, counts in self._model_data
715        }
716        data['elephant'] = ((5, 4, 0), (0, 0, 0))
717        inventory = create_inventory(data)
718        message = lab_inventory._generate_model_inventory_message(inventory)
719        self._check_message(message)
720
721    def test_ignore_no_critical(self):
722        """Test that messages ignore models with no critical pools."""
723        data = {
724            model: self._make_maximum_spares(counts)
725                for model, counts in self._model_data
726        }
727        data['elephant'] = ((0, 0, 0), (1, 5, 1))
728        inventory = create_inventory(data)
729        message = lab_inventory._generate_model_inventory_message(inventory)
730        self._check_message(message)
731
732    def test_ignore_no_bad(self):
733        """Test that messages ignore models with no bad DUTs."""
734        data = {
735            model: self._make_maximum_spares(counts)
736                for model, counts in self._model_data
737        }
738        data['elephant'] = ((5, 0, 1), (5, 0, 1))
739        inventory = create_inventory(data)
740        message = lab_inventory._generate_model_inventory_message(inventory)
741        self._check_message(message)
742
743
744class _PoolInventoryTestBase(unittest.TestCase):
745    """Parent class for tests relating to generating pool inventory messages.
746
747    Func `setUp` in the class parses a given |message_template| to obtain
748    header and body.
749    """
750
751    def _read_template(self, message_template):
752        """Read message template for PoolInventoryTest and IdleInventoryTest.
753
754        @param message_template: the input template to be parsed into: header
755        and content (report_lines).
756        """
757        message_lines = message_template.split('\n')
758        self._header = message_lines[1]
759        self._report_lines = message_lines[2:-1]
760
761    def _check_report_no_info(self, text):
762        """Test a message body containing no reported info.
763
764        The input `text` was created from a query to an inventory, which
765        has no objects meet the query and leads to an `empty` return.
766        Assert that the text consists of a single line starting with '('
767        and ending with ')'.
768
769        @param text: Message body text to be tested.
770        """
771        self.assertTrue(len(text) == 1 and
772                            text[0][0] == '(' and
773                            text[0][-1] == ')')
774
775    def _check_report(self, text):
776        """Test a message against the passed |expected_content|.
777
778        @param text: Message body text to be tested.
779        @param expected_content: The ground-truth content to be compared with.
780        """
781        self.assertEqual(text, self._report_lines)
782
783
784# _POOL_MESSAGE_TEMPLATE -
785# This is a sample of the output text produced by
786# _generate_pool_inventory_message().  This string is parsed by the
787# tests below to construct a sample inventory that should produce
788# the output, and then the output is generated and checked against
789# this original sample.
790#
791# See the comments on _BOARD_MESSAGE_TEMPLATE above for the
792# rationale on using sample text in this way.
793
794_POOL_MESSAGE_TEMPLATE = '''
795Model                    Bad  Idle  Good Total
796lion                       5     2     6    13
797tiger                      4     1     5    10
798bear                       3     0     7    10
799aardvark                   2     0     0     2
800platypus                   1     1     1     3
801'''
802
803_POOL_ADMIN_URL = 'http://go/cros-manage-duts'
804
805
806class PoolInventoryTests(_PoolInventoryTestBase):
807    """Tests for `_generate_pool_inventory_message()`.
808
809    The tests create various test inventories designed to match the
810    counts in `_POOL_MESSAGE_TEMPLATE`, and assert that the
811    generated message text matches the format established in the
812    original message text.
813
814    The output message text is parsed against the following grammar:
815        <message> -> <intro> <pool> { "blank line" <pool> }
816        <intro> ->
817            Instructions to depty mentioning the admin page URL
818            A blank line
819        <pool> ->
820            <description>
821            <header line>
822            <message body>
823        <description> ->
824            Any number of lines describing one pool
825        <header line> ->
826            The header line from `_POOL_MESSAGE_TEMPLATE`
827        <message body> ->
828            Any number of non-blank lines
829
830    After parsing messages into the parts described above, various
831    assertions are tested against the parsed output, including
832    that the message body matches the body from
833    `_POOL_MESSAGE_TEMPLATE`.
834
835    Parse message text is represented as a list of strings, split on
836    the `'\n'` separator.
837    """
838
839    def setUp(self):
840        super(PoolInventoryTests, self)._read_template(_POOL_MESSAGE_TEMPLATE)
841        self._model_data = []
842        for l in self._report_lines:
843            items = l.split()
844            model = items[0]
845            bad = int(items[1])
846            idle = int(items[2])
847            good = int(items[3])
848            self._model_data.append((model, (good, bad, idle)))
849
850    def _create_histories(self, pools, model_data):
851        """Return a list suitable to create a `_LabInventory` object.
852
853        Creates a list of `_FakeHostHistory` objects that can be
854        used to create a lab inventory.  `pools` is a list of strings
855        naming pools, and `model_data` is a list of tuples of the
856        form
857            `(model, (goodcount, badcount))`
858        where
859            `model` is a model name.
860            `goodcount` is the number of working DUTs in the pool.
861            `badcount` is the number of broken DUTs in the pool.
862
863        @param pools       List of pools for which to create
864                           histories.
865        @param model_data  List of tuples containing models and DUT
866                           counts.
867        @return A list of `_FakeHostHistory` objects that can be
868                used to create a `_LabInventory` object.
869        """
870        histories = []
871        status_choices = (_WORKING, _BROKEN, _UNUSED)
872        for pool in pools:
873            for model, counts in model_data:
874                for status, count in zip(status_choices, counts):
875                    for x in range(0, count):
876                        histories.append(
877                            _FakeHostHistory(model, pool, status))
878        return histories
879
880    def _parse_pool_summaries(self, histories):
881        """Parse message output according to the grammar above.
882
883        Create a lab inventory from the given `histories`, and
884        generate the pool inventory message.  Then parse the message
885        and return a dictionary mapping each pool to the message
886        body parsed after that pool.
887
888        Tests the following assertions:
889          * Each <description> contains a mention of exactly one
890            pool in the `CRITICAL_POOLS` list.
891          * Each pool is mentioned in exactly one <description>.
892        Note that the grammar requires the header to appear once
893        for each pool, so the parsing implicitly asserts that the
894        output contains the header.
895
896        @param histories  Input used to create the test
897                          `_LabInventory` object.
898        @return A dictionary mapping model names to the output
899                (a list of lines) for the model.
900        """
901        inventory = lab_inventory._LabInventory(
902                histories, lab_inventory.MANAGED_POOLS)
903        message = lab_inventory._generate_pool_inventory_message(
904                inventory).split('\n')
905        poolset = set(lab_inventory.CRITICAL_POOLS)
906        seen_url = False
907        seen_intro = False
908        description = ''
909        model_text = {}
910        current_pool = None
911        for line in message:
912            if not seen_url:
913                if _POOL_ADMIN_URL in line:
914                    seen_url = True
915            elif not seen_intro:
916                if not line:
917                    seen_intro = True
918            elif current_pool is None:
919                if line == self._header:
920                    pools_mentioned = [p for p in poolset
921                                           if p in description]
922                    self.assertEqual(len(pools_mentioned), 1)
923                    current_pool = pools_mentioned[0]
924                    description = ''
925                    model_text[current_pool] = []
926                    poolset.remove(current_pool)
927                else:
928                    description += line
929            else:
930                if line:
931                    model_text[current_pool].append(line)
932                else:
933                    current_pool = None
934        self.assertEqual(len(poolset), 0)
935        return model_text
936
937    def test_no_shortages(self):
938        """Test correct output when no pools have shortages."""
939        model_text = self._parse_pool_summaries([])
940        for text in model_text.values():
941            self._check_report_no_info(text)
942
943    def test_one_pool_shortage(self):
944        """Test correct output when exactly one pool has a shortage."""
945        for pool in lab_inventory.CRITICAL_POOLS:
946            histories = self._create_histories((pool,),
947                                               self._model_data)
948            model_text = self._parse_pool_summaries(histories)
949            for checkpool in lab_inventory.CRITICAL_POOLS:
950                text = model_text[checkpool]
951                if checkpool == pool:
952                    self._check_report(text)
953                else:
954                    self._check_report_no_info(text)
955
956    def test_all_pool_shortages(self):
957        """Test correct output when all pools have a shortage."""
958        histories = []
959        for pool in lab_inventory.CRITICAL_POOLS:
960            histories.extend(
961                self._create_histories((pool,),
962                                       self._model_data))
963        model_text = self._parse_pool_summaries(histories)
964        for pool in lab_inventory.CRITICAL_POOLS:
965            self._check_report(model_text[pool])
966
967    def test_full_model_ignored(self):
968        """Test that models at full strength are not reported."""
969        pool = lab_inventory.CRITICAL_POOLS[0]
970        full_model = [('echidna', (5, 0, 0))]
971        histories = self._create_histories((pool,),
972                                           full_model)
973        text = self._parse_pool_summaries(histories)[pool]
974        self._check_report_no_info(text)
975        model_data = self._model_data + full_model
976        histories = self._create_histories((pool,), model_data)
977        text = self._parse_pool_summaries(histories)[pool]
978        self._check_report(text)
979
980    def test_spare_pool_ignored(self):
981        """Test that reporting ignores the spare pool inventory."""
982        spare_pool = lab_inventory.SPARE_POOL
983        spare_data = self._model_data + [('echidna', (0, 5, 0))]
984        histories = self._create_histories((spare_pool,),
985                                           spare_data)
986        model_text = self._parse_pool_summaries(histories)
987        for pool in lab_inventory.CRITICAL_POOLS:
988            self._check_report_no_info(model_text[pool])
989
990
991_IDLE_MESSAGE_TEMPLATE = '''
992Hostname                       Model                Pool
993chromeos4-row12-rack4-host7    tiger                bvt
994chromeos1-row3-rack1-host2     lion                 bvt
995chromeos3-row2-rack2-host5     lion                 cq
996chromeos2-row7-rack3-host11    platypus             suites
997'''
998
999
1000class IdleInventoryTests(_PoolInventoryTestBase):
1001    """Tests for `_generate_idle_inventory_message()`.
1002
1003    The tests create idle duts that match the counts and pool in
1004    `_IDLE_MESSAGE_TEMPLATE`. In test, it asserts that the generated
1005    idle message text matches the format established in
1006    `_IDLE_MESSAGE_TEMPLATE`.
1007
1008    Parse message text is represented as a list of strings, split on
1009    the `'\n'` separator.
1010    """
1011
1012    def setUp(self):
1013        super(IdleInventoryTests, self)._read_template(_IDLE_MESSAGE_TEMPLATE)
1014        self._host_data = []
1015        for h in self._report_lines:
1016            items = h.split()
1017            hostname = items[0]
1018            model = items[1]
1019            pool = items[2]
1020            self._host_data.append((hostname, model, pool))
1021        self._histories = []
1022        self._histories.append(_FakeHostHistory('echidna', 'bvt', _BROKEN))
1023        self._histories.append(_FakeHostHistory('lion', 'bvt', _WORKING))
1024
1025    def _add_idles(self):
1026        """Add idle duts from `_IDLE_MESSAGE_TEMPLATE`."""
1027        idle_histories = [_FakeHostHistory(
1028                model, pool, _UNUSED, hostname)
1029                        for hostname, model, pool in self._host_data]
1030        self._histories.extend(idle_histories)
1031
1032    def _check_header(self, text):
1033        """Check whether header in the template `_IDLE_MESSAGE_TEMPLATE` is in
1034        passed text."""
1035        self.assertIn(self._header, text)
1036
1037    def _get_idle_message(self, histories):
1038        """Generate idle inventory and obtain its message.
1039
1040        @param histories: Used to create lab inventory.
1041
1042        @return the generated idle message.
1043        """
1044        inventory = lab_inventory._LabInventory(
1045                histories, lab_inventory.MANAGED_POOLS)
1046        message = lab_inventory._generate_idle_inventory_message(
1047                inventory).split('\n')
1048        return message
1049
1050    def test_check_idle_inventory(self):
1051        """Test that reporting all the idle DUTs for every pool, sorted by
1052        lab_inventory.MANAGED_POOLS.
1053        """
1054        self._add_idles()
1055
1056        message = self._get_idle_message(self._histories)
1057        self._check_header(message)
1058        self._check_report(message[message.index(self._header) + 1 :])
1059
1060    def test_no_idle_inventory(self):
1061        """Test that reporting no idle DUTs."""
1062        message = self._get_idle_message(self._histories)
1063        self._check_header(message)
1064        self._check_report_no_info(
1065                message[message.index(self._header) + 1 :])
1066
1067
1068class CommandParsingTests(unittest.TestCase):
1069    """Tests for command line argument parsing in `_parse_command()`."""
1070
1071    # At least one of these options must be specified on every command
1072    # line; otherwise, the command line parsing will fail.
1073    _REPORT_OPTIONS = [
1074        '--model-notify=', '--pool-notify=', '--report-untestable'
1075    ]
1076
1077    def setUp(self):
1078        dirpath = '/usr/local/fubar'
1079        self._command_path = os.path.join(dirpath,
1080                                          'site_utils',
1081                                          'arglebargle')
1082        self._logdir = os.path.join(dirpath, lab_inventory._LOGDIR)
1083
1084    def _parse_arguments(self, argv):
1085        """Test parsing with explictly passed report options."""
1086        full_argv = [self._command_path] + argv
1087        return lab_inventory._parse_command(full_argv)
1088
1089    def _parse_non_report_arguments(self, argv):
1090        """Test parsing for non-report command-line options."""
1091        return self._parse_arguments(argv + self._REPORT_OPTIONS)
1092
1093    def _check_non_report_defaults(self, report_option):
1094        arguments = self._parse_arguments([report_option])
1095        self.assertEqual(arguments.duration,
1096                         lab_inventory._DEFAULT_DURATION)
1097        self.assertIsNone(arguments.recommend)
1098        self.assertFalse(arguments.debug)
1099        self.assertEqual(arguments.logdir, self._logdir)
1100        self.assertEqual(arguments.modelnames, [])
1101        return arguments
1102
1103    def test_empty_arguments(self):
1104        """Test that no reports requested is an error."""
1105        arguments = self._parse_arguments([])
1106        self.assertIsNone(arguments)
1107
1108    def test_argument_defaults(self):
1109        """Test that option defaults match expectations."""
1110        for report in self._REPORT_OPTIONS:
1111            arguments = self._check_non_report_defaults(report)
1112
1113    def test_model_notify_defaults(self):
1114        """Test defaults when `--model-notify` is specified alone."""
1115        arguments = self._parse_arguments(['--model-notify='])
1116        self.assertEqual(arguments.model_notify, [''])
1117        self.assertEqual(arguments.pool_notify, [])
1118        self.assertFalse(arguments.report_untestable)
1119
1120    def test_pool_notify_defaults(self):
1121        """Test defaults when `--pool-notify` is specified alone."""
1122        arguments = self._parse_arguments(['--pool-notify='])
1123        self.assertEqual(arguments.model_notify, [])
1124        self.assertEqual(arguments.pool_notify, [''])
1125        self.assertFalse(arguments.report_untestable)
1126
1127    def test_report_untestable_defaults(self):
1128        """Test defaults when `--report-untestable` is specified alone."""
1129        arguments = self._parse_arguments(['--report-untestable'])
1130        self.assertEqual(arguments.model_notify, [])
1131        self.assertEqual(arguments.pool_notify, [])
1132        self.assertTrue(arguments.report_untestable)
1133
1134    def test_model_arguments(self):
1135        """Test that non-option arguments are returned in `modelnames`."""
1136        modellist = ['aardvark', 'echidna']
1137        arguments = self._parse_non_report_arguments(modellist)
1138        self.assertEqual(arguments.modelnames, modellist)
1139
1140    def test_recommend_option(self):
1141        """Test parsing of the `--recommend` option."""
1142        for opt in ['-r', '--recommend']:
1143            for recommend in ['5', '55']:
1144                arguments = self._parse_non_report_arguments([opt, recommend])
1145                self.assertEqual(arguments.recommend, int(recommend))
1146
1147    def test_debug_option(self):
1148        """Test parsing of the `--debug` option."""
1149        arguments = self._parse_non_report_arguments(['--debug'])
1150        self.assertTrue(arguments.debug)
1151
1152    def test_duration(self):
1153        """Test parsing of the `--duration` option."""
1154        for opt in ['-d', '--duration']:
1155            for duration in ['1', '11']:
1156                arguments = self._parse_non_report_arguments([opt, duration])
1157                self.assertEqual(arguments.duration, int(duration))
1158
1159    def _check_email_option(self, option, getlist):
1160        """Test parsing of e-mail address options.
1161
1162        This is a helper function to test the `--model-notify` and
1163        `--pool-notify` options.  It tests the following cases:
1164          * `--option a1` gives the list [a1]
1165          * `--option ' a1 '` gives the list [a1]
1166          * `--option a1 --option a2` gives the list [a1, a2]
1167          * `--option a1,a2` gives the list [a1, a2]
1168          * `--option 'a1, a2'` gives the list [a1, a2]
1169
1170        @param option  The option to be tested.
1171        @param getlist A function to return the option's value from
1172                       parsed command line arguments.
1173        """
1174        a1 = 'mumble@mumbler.com'
1175        a2 = 'bumble@bumbler.org'
1176        arguments = self._parse_arguments([option, a1])
1177        self.assertEqual(getlist(arguments), [a1])
1178        arguments = self._parse_arguments([option, ' ' + a1 + ' '])
1179        self.assertEqual(getlist(arguments), [a1])
1180        arguments = self._parse_arguments([option, a1, option, a2])
1181        self.assertEqual(getlist(arguments), [a1, a2])
1182        arguments = self._parse_arguments(
1183                [option, ','.join([a1, a2])])
1184        self.assertEqual(getlist(arguments), [a1, a2])
1185        arguments = self._parse_arguments(
1186                [option, ', '.join([a1, a2])])
1187        self.assertEqual(getlist(arguments), [a1, a2])
1188
1189    def test_model_notify(self):
1190        """Test parsing of the `--model-notify` option."""
1191        self._check_email_option('--model-notify',
1192                                 lambda a: a.model_notify)
1193
1194    def test_pool_notify(self):
1195        """Test parsing of the `--pool-notify` option."""
1196        self._check_email_option('--pool-notify',
1197                                 lambda a: a.pool_notify)
1198
1199    def test_logdir_option(self):
1200        """Test parsing of the `--logdir` option."""
1201        logdir = '/usr/local/whatsis/logs'
1202        arguments = self._parse_non_report_arguments(['--logdir', logdir])
1203        self.assertEqual(arguments.logdir, logdir)
1204
1205
1206if __name__ == '__main__':
1207    # Some of the functions we test log messages.  Prevent those
1208    # messages from showing up in test output.
1209    logging.getLogger().setLevel(logging.CRITICAL)
1210    unittest.main()
1211