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