#!/usr/bin/python
# Copyright 2018 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""Tests for perf_device_trigger_unittest.py."""

import unittest

import perf_device_trigger

class Args(object):
  def __init__(self):
    self.shards = 1
    self.dump_json = ''
    self.multiple_trigger_configs = None
    self.multiple_dimension_script_verbose = False


class FakeTriggerer(perf_device_trigger.PerfDeviceTriggerer):
  def __init__(self, args, swarming_args, files):
    self._bot_statuses = []
    self._swarming_runs = []
    self._files = files
    self._temp_file_id = 0
    super(FakeTriggerer, self).__init__(args, swarming_args)


  def set_files(self, files):
    self._files = files

  def make_temp_file(self, prefix=None, suffix=None):
    result = prefix + str(self._temp_file_id) + suffix
    self._temp_file_id += 1
    return result

  def delete_temp_file(self, temp_file):
    pass

  def read_json_from_temp_file(self, temp_file):
    return self._files[temp_file]

  def read_encoded_json_from_temp_file(self, temp_file):
    return self._files[temp_file]

  def write_json_to_file(self, merged_json, output_file):
    self._files[output_file] = merged_json

  def run_swarming(self, args, verbose):
    del verbose #unused
    self._swarming_runs.append(args)


class UnitTest(unittest.TestCase):
  def setup_and_trigger(
      self, previous_task_assignment_map, alive_bots, dead_bots):
    args = Args()
    args.shards = len(previous_task_assignment_map)
    args.dump_json = 'output.json'
    swarming_args = [
        'trigger',
        '--swarming',
        'http://foo_server',
        '--auth-service-account-json',
        '/creds/test_service_account',
        '--dimension',
        'pool',
        'chrome-perf-fyi',
        '--dimension',
        'os',
        'windows',
        '--',
        'benchmark1',
      ]

    triggerer = FakeTriggerer(args, swarming_args,
        self.get_files(args.shards, previous_task_assignment_map,
                       alive_bots, dead_bots))
    triggerer.trigger_tasks(
      args,
      swarming_args)
    return triggerer

  def get_files(self, num_shards, previous_task_assignment_map,
                alive_bots, dead_bots):
    files = {}
    file_index = 0
    files['base_trigger_dimensions%d.json' % file_index] = (
        self.generate_list_of_eligible_bots_query_response(
            alive_bots, dead_bots))
    file_index = file_index + 1
    # Perf device trigger will call swarming n times:
    #   1. Once for all eligible bots
    #   2. once per shard to determine last bot run
    # Shard builders is a list of build ids that represents
    # the last build that ran the shard that corresponds to that
    # index.  If that shard hasn't been run before the entry
    # should be an empty string.
    for i in xrange(num_shards):
      bot_id = previous_task_assignment_map.get(i)
      files['base_trigger_dimensions%d.json' % file_index] = (
          self.generate_last_task_to_shard_query_response(i, bot_id))
      file_index = file_index + 1
    for i in xrange(num_shards):
      task = {
        'base_task_name': 'webgl_conformance_tests',
        'request': {
          'expiration_secs': 3600,
          'properties': {
            'execution_timeout_secs': 3600,
          },
        },
        'tasks': {
          'webgl_conformance_tests on NVIDIA GPU on Windows': {
            'task_id': 'f%d' % i,
          },
        },
      }
      files['base_trigger_dimensions%d.json' % file_index] = task
      file_index = file_index + 1
    return files

  def generate_last_task_to_shard_query_response(self, shard, bot_id):
    if len(bot_id):
      # Test both cases where bot_id is present and you have to parse
      # out of the tags.
      if shard % 2:
        return {'items': [{'bot_id': bot_id}]}
      else:
        return {'items': [{'tags': [('id:%s' % bot_id)]}]}
    return {}

  def generate_list_of_eligible_bots_query_response(
      self, alive_bots, dead_bots):
    items = {'items': []}
    for bot_id in alive_bots:
      items['items'].append(
          { 'bot_id': ('%s' % bot_id), 'is_dead': False, 'quarantined': False })
    is_dead = True
    for bot_id in dead_bots:
      is_quarantined = (not is_dead)
      items['items'].append({
          'bot_id': ('%s' % bot_id),
          'is_dead': is_dead,
          'quarantined': is_quarantined
      })
      is_dead = (not is_dead)
    return items


  def list_contains_sublist(self, main_list, sub_list):
    return any(sub_list == main_list[offset:offset + len(sub_list)]
               for offset in xrange(len(main_list) - (len(sub_list) - 1)))

  def assert_query_swarming_args(self, triggerer, num_shards):
    # Assert the calls to query swarming send the right args
    # First call is to get eligible bots and then one query
    # per shard
    for i in range(num_shards + 1):
      self.assertTrue('query' in triggerer._swarming_runs[i])
      self.assertTrue(self.list_contains_sublist(
        triggerer._swarming_runs[i], ['-S', 'foo_server']))
      self.assertTrue(self.list_contains_sublist(
        triggerer._swarming_runs[i], ['--auth-service-account-json',
                                      '/creds/test_service_account']))

  def get_triggered_shard_to_bot(self, triggerer, num_shards):
    self.assert_query_swarming_args(triggerer, num_shards)
    triggered_map = {}
    for run in triggerer._swarming_runs:
      if not 'trigger' in run:
        continue
      bot_id = run[(run.index('id') + 1)]
      shard = int(run[(run.index('GTEST_SHARD_INDEX') + 1)])
      triggered_map[shard] = bot_id
    return triggered_map


  def test_all_healthy_shards(self):
    triggerer = self.setup_and_trigger(
        previous_task_assignment_map={0: 'build3', 1: 'build4', 2: 'build5'},
        alive_bots=['build3', 'build4', 'build5'],
        dead_bots=['build1', 'build2'])
    expected_task_assignment = self.get_triggered_shard_to_bot(
        triggerer, num_shards=3)
    self.assertEquals(len(set(expected_task_assignment.values())), 3)

    # All three bots were healthy so we should expect the task assignment to
    # stay the same
    self.assertEquals(expected_task_assignment.get(0), 'build3')
    self.assertEquals(expected_task_assignment.get(1), 'build4')
    self.assertEquals(expected_task_assignment.get(2), 'build5')

  def test_previously_healthy_now_dead(self):
    # Test that it swaps out build1 and build2 that are dead
    # for two healthy bots
    triggerer = self.setup_and_trigger(
        previous_task_assignment_map={0: 'build1', 1: 'build2', 2: 'build3'},
        alive_bots=['build3', 'build4', 'build5'],
        dead_bots=['build1', 'build2'])
    expected_task_assignment = self.get_triggered_shard_to_bot(
        triggerer, num_shards=3)
    self.assertEquals(len(set(expected_task_assignment.values())), 3)

    # The first two should be assigned to one of the unassigned healthy bots
    new_healthy_bots = ['build4', 'build5']
    self.assertIn(expected_task_assignment.get(0), new_healthy_bots)
    self.assertIn(expected_task_assignment.get(1), new_healthy_bots)
    self.assertEquals(expected_task_assignment.get(2), 'build3')

  def test_not_enough_healthy_bots(self):
    triggerer = self.setup_and_trigger(
        previous_task_assignment_map= {0: 'build1', 1: 'build2',
                                       2: 'build3', 3: 'build4', 4: 'build5'},
        alive_bots=['build3', 'build4', 'build5'],
        dead_bots=['build1', 'build2'])
    expected_task_assignment = self.get_triggered_shard_to_bot(
        triggerer, num_shards=5)
    self.assertEquals(len(set(expected_task_assignment.values())), 5)

    # We have 5 shards and 5 bots that ran them, but two
    # are now dead and there aren't any other healthy bots
    # to swap out to.  Make sure they still assign to the
    # same shards.
    self.assertEquals(expected_task_assignment.get(0), 'build1')
    self.assertEquals(expected_task_assignment.get(1), 'build2')
    self.assertEquals(expected_task_assignment.get(2), 'build3')
    self.assertEquals(expected_task_assignment.get(3), 'build4')
    self.assertEquals(expected_task_assignment.get(4), 'build5')

  def test_not_enough_healthy_bots_shard_not_seen(self):
    triggerer = self.setup_and_trigger(
        previous_task_assignment_map= {0: 'build1', 1: '',
                                       2: 'build3', 3: 'build4', 4: 'build5'},
        alive_bots=['build3', 'build4', 'build5'],
        dead_bots=['build1', 'build2'])
    expected_task_assignment = self.get_triggered_shard_to_bot(
        triggerer, num_shards=5)
    self.assertEquals(len(set(expected_task_assignment.values())), 5)

    # Not enough healthy bots so make sure shard 0 is still assigned to its
    # same dead bot.
    self.assertEquals(expected_task_assignment.get(0), 'build1')
    # Shard 1 had not been triggered yet, but there weren't enough
    # healthy bots.  Make sure it got assigned to the other dead bot.
    self.assertEquals(expected_task_assignment.get(1), 'build2')
    # The rest of the assignments should stay the same.
    self.assertEquals(expected_task_assignment.get(2), 'build3')
    self.assertEquals(expected_task_assignment.get(3), 'build4')
    self.assertEquals(expected_task_assignment.get(4), 'build5')

  def test_shards_not_triggered_yet(self):
    # First time this configuration has been seen.  Choose three
    # healthy shards to trigger jobs on
    triggerer = self.setup_and_trigger(
        previous_task_assignment_map= {0: '', 1: '', 2: ''},
        alive_bots=['build3', 'build4', 'build5'],
        dead_bots=['build1', 'build2'])
    expected_task_assignment = self.get_triggered_shard_to_bot(
        triggerer, num_shards=3)
    self.assertEquals(len(set(expected_task_assignment.values())), 3)
    new_healthy_bots = ['build3', 'build4', 'build5']
    self.assertIn(expected_task_assignment.get(0), new_healthy_bots)
    self.assertIn(expected_task_assignment.get(1), new_healthy_bots)
    self.assertIn(expected_task_assignment.get(2), new_healthy_bots)

  def test_previously_duplicate_task_assignemnts(self):
    triggerer = self.setup_and_trigger(
        previous_task_assignment_map={0: 'build3', 1: 'build3', 2: 'build5',
                                      3: 'build6'},
        alive_bots=['build3', 'build4', 'build5', 'build7'],
        dead_bots=['build1', 'build6'])
    expected_task_assignment = self.get_triggered_shard_to_bot(
        triggerer, num_shards=3)

    # Test that the new assignment will add a new bot to avoid
    # assign 'build3' to both shard 0 & shard 1 as before.
    # It also replaces the dead 'build6' bot.
    self.assertEquals(set(expected_task_assignment.values()),
        {'build3', 'build4', 'build5', 'build7'})


if __name__ == '__main__':
  unittest.main()
