#!/usr/bin/env vpython3
# Copyright 2016 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 git_dates."""

from __future__ import unicode_literals

import datetime
import os
import re
import shutil
import sys
import tempfile
import unittest

from io import BytesIO
if sys.version_info.major == 2:
  from StringIO import StringIO
  import mock
else:
  from io import StringIO
  from unittest import mock

DEPOT_TOOLS_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, DEPOT_TOOLS_ROOT)

from testing_support import coverage_utils
from testing_support import git_test_utils

import gclient_utils
import git_common

GitRepo = git_test_utils.GitRepo


class GitHyperBlameTestBase(git_test_utils.GitRepoReadOnlyTestBase):
  @classmethod
  def setUpClass(cls):
    super(GitHyperBlameTestBase, cls).setUpClass()
    import git_hyper_blame
    cls.git_hyper_blame = git_hyper_blame

  def setUp(self):
    mock.patch('sys.stderr', StringIO()).start()
    self.addCleanup(mock.patch.stopall)

  def run_hyperblame(self, ignored, filename, revision):
    outbuf = BytesIO()
    ignored = [self.repo[c] for c in ignored]
    retval = self.repo.run(
        self.git_hyper_blame.hyper_blame, outbuf, ignored, filename, revision)
    return retval, outbuf.getvalue().rstrip().split(b'\n')

  def blame_line(self, commit_name, rest, author=None, filename=None):
    """Generate a blame line from a commit.

    Args:
      commit_name: The commit's schema name.
      rest: The blame line after the timestamp. e.g., '2) file2 - merged'.
      author: The author's name. If omitted, reads the name out of the commit.
      filename: The filename. If omitted, not shown in the blame line.
    """
    short = self.repo[commit_name][:8]
    start = '%s %s' % (short, filename) if filename else short
    if author is None:
      author = self.repo.show_commit(commit_name, format_string='%an %ai')
    else:
      author += self.repo.show_commit(commit_name, format_string=' %ai')
    return ('%s (%s %s' % (start, author, rest)).encode('utf-8')

class GitHyperBlameMainTest(GitHyperBlameTestBase):
  """End-to-end tests on a very simple repo."""
  REPO_SCHEMA = "A B C D"

  COMMIT_A = {
    'some/files/file': {'data': b'line 1\nline 2\n'},
  }

  COMMIT_B = {
    'some/files/file': {'data': b'line 1\nline 2.1\n'},
  }

  COMMIT_C = {
    'some/files/file': {'data': b'line 1.1\nline 2.1\n'},
  }

  COMMIT_D = {
    # This file should be automatically considered for ignore.
    '.git-blame-ignore-revs': {'data': b'tag_C'},
    # This file should not be considered.
    'some/files/.git-blame-ignore-revs': {'data': b'tag_B'},
  }

  def setUp(self):
    super(GitHyperBlameMainTest, self).setUp()
    # Most tests want to check out C (so the .git-blame-ignore-revs is not
    # used).
    self.repo.git('checkout', '-f', 'tag_C')

  def testBasicBlame(self):
    """Tests the main function (simple end-to-end test with no ignores)."""
    expected_output = [self.blame_line('C', '1) line 1.1'),
                       self.blame_line('B', '2) line 2.1')]
    outbuf = BytesIO()
    retval = self.repo.run(
        self.git_hyper_blame.main, ['tag_C', 'some/files/file'], outbuf)
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, outbuf.getvalue().rstrip().split(b'\n'))
    self.assertEqual('', sys.stderr.getvalue())

  def testIgnoreSimple(self):
    """Tests the main function (simple end-to-end test with ignores)."""
    expected_output = [self.blame_line('C', ' 1) line 1.1'),
                       self.blame_line('A', '2*) line 2.1')]
    outbuf = BytesIO()
    retval = self.repo.run(
        self.git_hyper_blame.main, ['-i', 'tag_B', 'tag_C', 'some/files/file'],
        outbuf)
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, outbuf.getvalue().rstrip().split(b'\n'))
    self.assertEqual('', sys.stderr.getvalue())

  def testBadRepo(self):
    """Tests the main function (not in a repo)."""
    # Make a temp dir that has no .git directory.
    curdir = os.getcwd()
    tempdir = tempfile.mkdtemp(suffix='_nogit', prefix='git_repo')
    try:
      os.chdir(tempdir)
      outbuf = BytesIO()
      retval = self.git_hyper_blame.main(
          ['-i', 'tag_B', 'tag_C', 'some/files/file'], outbuf)
    finally:
      os.chdir(curdir)
      shutil.rmtree(tempdir)

    self.assertNotEqual(0, retval)
    self.assertEqual(b'', outbuf.getvalue())
    r = re.compile('^fatal: Not a git repository', re.I)
    self.assertRegexpMatches(sys.stderr.getvalue(), r)

  def testBadFilename(self):
    """Tests the main function (bad filename)."""
    outbuf = BytesIO()
    retval = self.repo.run(
        self.git_hyper_blame.main, ['-i', 'tag_B', 'tag_C', 'some/files/xxxx'],
        outbuf)
    self.assertNotEqual(0, retval)
    self.assertEqual(b'', outbuf.getvalue())
    # TODO(mgiuca): This test used to test the exact string, but it broke due to
    # an upstream bug in git-blame. For now, just check the start of the string.
    # A patch has been sent upstream; when it rolls out we can revert back to
    # the original test logic.
    self.assertTrue(
        sys.stderr.getvalue().startswith(
            'fatal: no such path some/files/xxxx in '))

  def testBadRevision(self):
    """Tests the main function (bad revision to blame from)."""
    outbuf = BytesIO()
    retval = self.repo.run(
        self.git_hyper_blame.main, ['-i', 'tag_B', 'xxxx', 'some/files/file'],
        outbuf)
    self.assertNotEqual(0, retval)
    self.assertEqual(b'', outbuf.getvalue())
    self.assertRegexpMatches(sys.stderr.getvalue(),
                             '^fatal: ambiguous argument \'xxxx\': unknown '
                             'revision or path not in the working tree.')

  def testBadIgnore(self):
    """Tests the main function (bad revision passed to -i)."""
    expected_output = [self.blame_line('C', '1) line 1.1'),
                       self.blame_line('B', '2) line 2.1')]
    outbuf = BytesIO()
    retval = self.repo.run(
        self.git_hyper_blame.main, ['-i', 'xxxx', 'tag_C', 'some/files/file'],
        outbuf)
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, outbuf.getvalue().rstrip().split(b'\n'))
    self.assertEqual(
        'warning: unknown revision \'xxxx\'.\n', sys.stderr.getvalue())

  def testIgnoreFile(self):
    """Tests passing the ignore list in a file."""
    expected_output = [self.blame_line('C', ' 1) line 1.1'),
                       self.blame_line('A', '2*) line 2.1')]
    outbuf = BytesIO()

    with gclient_utils.temporary_file() as ignore_file:
      gclient_utils.FileWrite(
          ignore_file,
          '# Line comments are allowed.\n'
          '\n'
          '{}\n'
          'xxxx\n'.format(self.repo['B']))
      retval = self.repo.run(
          self.git_hyper_blame.main,
          ['--ignore-file', ignore_file, 'tag_C', 'some/files/file'],
          outbuf)

    self.assertEqual(0, retval)
    self.assertEqual(expected_output, outbuf.getvalue().rstrip().split(b'\n'))
    self.assertEqual(
        'warning: unknown revision \'xxxx\'.\n', sys.stderr.getvalue())

  def testDefaultIgnoreFile(self):
    """Tests automatically using a default ignore list."""
    # Check out revision D. We expect the script to use the default ignore list
    # that is checked out, *not* the one committed at the given revision.
    self.repo.git('checkout', '-f', 'tag_D')

    expected_output = [self.blame_line('A', '1*) line 1.1'),
                       self.blame_line('B', ' 2) line 2.1')]
    outbuf = BytesIO()

    retval = self.repo.run(
        self.git_hyper_blame.main, ['tag_D', 'some/files/file'], outbuf)

    self.assertEqual(0, retval)
    self.assertEqual(expected_output, outbuf.getvalue().rstrip().split(b'\n'))
    self.assertEqual('', sys.stderr.getvalue())

    # Test blame from a different revision. Despite the default ignore file
    # *not* being committed at that revision, it should still be picked up
    # because D is currently checked out.
    outbuf = BytesIO()

    retval = self.repo.run(
        self.git_hyper_blame.main, ['tag_C', 'some/files/file'], outbuf)

    self.assertEqual(0, retval)
    self.assertEqual(expected_output, outbuf.getvalue().rstrip().split(b'\n'))
    self.assertEqual('', sys.stderr.getvalue())

  def testNoDefaultIgnores(self):
    """Tests the --no-default-ignores switch."""
    # Check out revision D. This has a .git-blame-ignore-revs file, which we
    # expect to be ignored due to --no-default-ignores.
    self.repo.git('checkout', '-f', 'tag_D')

    expected_output = [self.blame_line('C', '1) line 1.1'),
                       self.blame_line('B', '2) line 2.1')]
    outbuf = BytesIO()

    retval = self.repo.run(
        self.git_hyper_blame.main,
        ['tag_D', 'some/files/file', '--no-default-ignores'], outbuf)

    self.assertEqual(0, retval)
    self.assertEqual(expected_output, outbuf.getvalue().rstrip().split(b'\n'))
    self.assertEqual('', sys.stderr.getvalue())

class GitHyperBlameSimpleTest(GitHyperBlameTestBase):
  REPO_SCHEMA = """
  A B D E F G H
  A C D
  """

  COMMIT_A = {
    'some/files/file1': {'data': b'file1'},
    'some/files/file2': {'data': b'file2'},
    'some/files/empty': {'data': b''},
    'some/other/file':  {'data': b'otherfile'},
  }

  COMMIT_B = {
    'some/files/file2': {
      'mode': 0o755,
      'data': b'file2 - vanilla\n'},
    'some/files/empty': {'data': b'not anymore'},
    'some/files/file3': {'data': b'file3'},
  }

  COMMIT_C = {
    'some/files/file2': {'data': b'file2 - merged\n'},
  }

  COMMIT_D = {
    'some/files/file2': {'data': b'file2 - vanilla\nfile2 - merged\n'},
  }

  COMMIT_E = {
    'some/files/file2': {'data': b'file2 - vanilla\nfile_x - merged\n'},
  }

  COMMIT_F = {
    'some/files/file2': {'data': b'file2 - vanilla\nfile_y - merged\n'},
  }

  # Move file2 from files to other.
  COMMIT_G = {
    'some/files/file2': {'data': None},
    'some/other/file2': {'data': b'file2 - vanilla\nfile_y - merged\n'},
  }

  COMMIT_H = {
    'some/other/file2': {'data': b'file2 - vanilla\nfile_z - merged\n'},
  }

  def testBlameError(self):
    """Tests a blame on a non-existent file."""
    expected_output = [b'']
    retval, output = self.run_hyperblame([], 'some/other/file2', 'tag_D')
    self.assertNotEqual(0, retval)
    self.assertEqual(expected_output, output)

  def testBlameEmpty(self):
    """Tests a blame of an empty file with no ignores."""
    expected_output = [b'']
    retval, output = self.run_hyperblame([], 'some/files/empty', 'tag_A')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

  def testBasicBlame(self):
    """Tests a basic blame with no ignores."""
    # Expect to blame line 1 on B, line 2 on C.
    expected_output = [self.blame_line('B', '1) file2 - vanilla'),
                       self.blame_line('C', '2) file2 - merged')]
    retval, output = self.run_hyperblame([], 'some/files/file2', 'tag_D')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

  def testBlameRenamed(self):
    """Tests a blame with no ignores on a renamed file."""
    # Expect to blame line 1 on B, line 2 on H.
    # Because the file has a different name than it had when (some of) these
    # lines were changed, expect the filenames to be displayed.
    expected_output = [self.blame_line('B', '1) file2 - vanilla',
                                       filename='some/files/file2'),
                       self.blame_line('H', '2) file_z - merged',
                                       filename='some/other/file2')]
    retval, output = self.run_hyperblame([], 'some/other/file2', 'tag_H')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

  def testIgnoreSimpleEdits(self):
    """Tests a blame with simple (line-level changes) commits ignored."""
    # Expect to blame line 1 on B, line 2 on E.
    expected_output = [self.blame_line('B', '1) file2 - vanilla'),
                       self.blame_line('E', '2) file_x - merged')]
    retval, output = self.run_hyperblame([], 'some/files/file2', 'tag_E')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

    # Ignore E; blame line 1 on B, line 2 on C.
    expected_output = [self.blame_line('B', ' 1) file2 - vanilla'),
                       self.blame_line('C', '2*) file_x - merged')]
    retval, output = self.run_hyperblame(['E'], 'some/files/file2', 'tag_E')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

    # Ignore E and F; blame line 1 on B, line 2 on C.
    expected_output = [self.blame_line('B', ' 1) file2 - vanilla'),
                       self.blame_line('C', '2*) file_y - merged')]
    retval, output = self.run_hyperblame(['E', 'F'], 'some/files/file2',
                                         'tag_F')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

  def testIgnoreInitialCommit(self):
    """Tests a blame with the initial commit ignored."""
    # Ignore A. Expect A to get blamed anyway.
    expected_output = [self.blame_line('A', '1) file1')]
    retval, output = self.run_hyperblame(['A'], 'some/files/file1', 'tag_A')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

  def testIgnoreFileAdd(self):
    """Tests a blame ignoring the commit that added this file."""
    # Ignore A. Expect A to get blamed anyway.
    expected_output = [self.blame_line('B', '1) file3')]
    retval, output = self.run_hyperblame(['B'], 'some/files/file3', 'tag_B')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

  def testIgnoreFilePopulate(self):
    """Tests a blame ignoring the commit that added data to an empty file."""
    # Ignore A. Expect A to get blamed anyway.
    expected_output = [self.blame_line('B', '1) not anymore')]
    retval, output = self.run_hyperblame(['B'], 'some/files/empty', 'tag_B')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

class GitHyperBlameLineMotionTest(GitHyperBlameTestBase):
  REPO_SCHEMA = """
  A B C D E F
  """

  COMMIT_A = {
    'file':  {'data': b'A\ngreen\nblue\n'},
  }

  # Change "green" to "yellow".
  COMMIT_B = {
    'file': {'data': b'A\nyellow\nblue\n'},
  }

  # Insert 2 lines at the top,
  # Change "yellow" to "red".
  # Insert 1 line at the bottom.
  COMMIT_C = {
    'file': {'data': b'X\nY\nA\nred\nblue\nZ\n'},
  }

  # Insert 2 more lines at the top.
  COMMIT_D = {
    'file': {'data': b'earth\nfire\nX\nY\nA\nred\nblue\nZ\n'},
  }

  # Insert a line before "red", and indent "red" and "blue".
  COMMIT_E = {
    'file': {'data': b'earth\nfire\nX\nY\nA\ncolors:\n red\n blue\nZ\n'},
  }

  # Insert a line between "A" and "colors".
  COMMIT_F = {
    'file': {'data': b'earth\nfire\nX\nY\nA\nB\ncolors:\n red\n blue\nZ\n'},
  }

  def testCacheDiffHunks(self):
    """Tests the cache_diff_hunks internal function."""
    expected_hunks = [((0, 0), (1, 2)),
                      ((2, 1), (4, 1)),
                      ((3, 0), (6, 1)),
                      ]
    hunks = self.repo.run(self.git_hyper_blame.cache_diff_hunks, 'tag_B',
                          'tag_C')
    self.assertEqual(expected_hunks, hunks)

  def testApproxLinenoAcrossRevs(self):
    """Tests the approx_lineno_across_revs internal function."""
    # Note: For all of these tests, the "old revision" and "new revision" are
    # reversed, which matches the usage by hyper_blame.

    # Test an unchanged line before any hunks in the diff. Should be unchanged.
    lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
                           'file', 'file', 'tag_B', 'tag_A', 1)
    self.assertEqual(1, lineno)

    # Test an unchanged line after all hunks in the diff. Should be matched to
    # the line's previous position in the file.
    lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
                           'file', 'file', 'tag_D', 'tag_C', 6)
    self.assertEqual(4, lineno)

    # Test a line added in a new hunk. Should be matched to the line *before*
    # where the hunk was inserted in the old version of the file.
    lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
                           'file', 'file', 'tag_F', 'tag_E', 6)
    self.assertEqual(5, lineno)

    # Test lines added in a new hunk at the very start of the file. This tests
    # an edge case: normally it would be matched to the line *before* where the
    # hunk was inserted (Line 0), but since the hunk is at the start of the
    # file, we match to Line 1.
    lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
                           'file', 'file', 'tag_C', 'tag_B', 1)
    self.assertEqual(1, lineno)
    lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
                           'file', 'file', 'tag_C', 'tag_B', 2)
    self.assertEqual(1, lineno)

    # Test an unchanged line in between hunks in the diff. Should be matched to
    # the line's previous position in the file.
    lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
                           'file', 'file', 'tag_C', 'tag_B', 3)
    self.assertEqual(1, lineno)

    # Test a changed line. Should be matched to the hunk's previous position in
    # the file.
    lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
                           'file', 'file', 'tag_C', 'tag_B', 4)
    self.assertEqual(2, lineno)

    # Test a line added in a new hunk at the very end of the file. Should be
    # matched to the line *before* where the hunk was inserted (the last line of
    # the file). Technically same as the case above but good to boundary test.
    lineno = self.repo.run(self.git_hyper_blame.approx_lineno_across_revs,
                           'file', 'file', 'tag_C', 'tag_B', 6)
    self.assertEqual(3, lineno)

  def testInterHunkLineMotion(self):
    """Tests a blame with line motion in another hunk in the ignored commit."""
    # Blame from D, ignoring C.

    # Lines 1, 2 were added by D.
    # Lines 3, 4 were added by C (but ignored, so blame A).
    # Line 5 was added by A.
    # Line 6 was modified by C (but ignored, so blame B). (Note: This requires
    # the algorithm to figure out that Line 6 in D == Line 4 in C ~= Line 2 in
    # B, so it blames B. Otherwise, it would blame A.)
    # Line 7 was added by A.
    # Line 8 was added by C (but ignored, so blame A).
    expected_output = [self.blame_line('D', ' 1) earth'),
                       self.blame_line('D', ' 2) fire'),
                       self.blame_line('A', '3*) X'),
                       self.blame_line('A', '4*) Y'),
                       self.blame_line('A', ' 5) A'),
                       self.blame_line('B', '6*) red'),
                       self.blame_line('A', ' 7) blue'),
                       self.blame_line('A', '8*) Z'),
                       ]
    retval, output = self.run_hyperblame(['C'], 'file', 'tag_D')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

  def testIntraHunkLineMotion(self):
    """Tests a blame with line motion in the same hunk in the ignored commit."""
    # This test was mostly written as a demonstration of the limitations of the
    # current algorithm (it exhibits non-ideal behaviour).

    # Blame from E, ignoring E.
    # Line 6 was added by E (but ignored, so blame C).
    # Lines 7, 8 were modified by E (but ignored, so blame A).
    # TODO(mgiuca): Ideally, this would blame Line 7 on C, because the line
    # "red" was added by C, and this is just a small change to that line. But
    # the current algorithm can't deal with line motion within a hunk, so it
    # just assumes Line 7 in E ~= Line 7 in D == Line 3 in A (which was "blue").
    expected_output = [self.blame_line('D', ' 1) earth'),
                       self.blame_line('D', ' 2) fire'),
                       self.blame_line('C', ' 3) X'),
                       self.blame_line('C', ' 4) Y'),
                       self.blame_line('A', ' 5) A'),
                       self.blame_line('C', '6*) colors:'),
                       self.blame_line('A', '7*)  red'),
                       self.blame_line('A', '8*)  blue'),
                       self.blame_line('C', ' 9) Z'),
                       ]
    retval, output = self.run_hyperblame(['E'], 'file', 'tag_E')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)


class GitHyperBlameLineNumberTest(GitHyperBlameTestBase):
  REPO_SCHEMA = """
  A B C D
  """

  COMMIT_A = {
    'file':  {'data': b'red\nblue\n'},
  }

  # Change "blue" to "green".
  COMMIT_B = {
    'file': {'data': b'red\ngreen\n'},
  }

  # Insert 2 lines at the top,
  COMMIT_C = {
    'file': {'data': b'\n\nred\ngreen\n'},
  }

  # Change "green" to "yellow".
  COMMIT_D = {
    'file': {'data': b'\n\nred\nyellow\n'},
  }

  def testTwoChangesWithAddedLines(self):
    """Regression test for https://crbug.com/709831.

    Tests a line with multiple ignored edits, and a line number change in
    between (such that the line number in the current revision is bigger than
    the file's line count at the older ignored revision).
    """
    expected_output = [self.blame_line('C', ' 1) '),
                       self.blame_line('C', ' 2) '),
                       self.blame_line('A', ' 3) red'),
                       self.blame_line('A', '4*) yellow'),
                       ]
    # Due to https://crbug.com/709831, ignoring both B and D would crash,
    # because of C (in between those revisions) which moves Line 2 to Line 4.
    # The algorithm would incorrectly think that Line 4 was still on Line 4 in
    # Commit B, even though it was Line 2 at that time. Its index is out of
    # range in the number of lines in Commit B.
    retval, output = self.run_hyperblame(['B', 'D'], 'file', 'tag_D')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)


class GitHyperBlameUnicodeTest(GitHyperBlameTestBase):
  REPO_SCHEMA = """
  A B C
  """

  COMMIT_A = {
    GitRepo.AUTHOR_NAME: 'ASCII Author',
    'file':  {'data': b'red\nblue\n'},
  }

  # Add a line.
  COMMIT_B = {
      # AUTHOR_NAME has .encode('utf-8') for py2 as Windows raises exception
      # otherwise. Type remains str
      GitRepo.AUTHOR_NAME:
      ('\u4e2d\u56fd\u4f5c\u8005'.encode('utf-8')
       if sys.version_info.major == 2 else '\u4e2d\u56fd\u4f5c\u8005'),
      'file': {
          'data': b'red\ngreen\nblue\n'
      },
  }

  # Modify a line with non-UTF-8 author and file text.
  COMMIT_C = {
      GitRepo.AUTHOR_NAME:
      ('Lat\u00edn-1 Author'.encode('latin-1')
       if sys.version_info.major == 2 else 'Lat\xedn-1 Author'),
      'file': {
          'data': 'red\ngre\u00e9n\nblue\n'.encode('latin-1')
      },
  }

  @unittest.skipIf(
      sys.platform.startswith("win") and sys.version_info.major == 2,
      "Known issue for Windows and py2")
  def testNonASCIIAuthorName(self):
    """Ensures correct tabulation.

    Tests the case where there are non-ASCII (UTF-8) characters in the author
    name.

    Regression test for https://crbug.com/808905.

    This test is disabled only for Windows and Python2 as `author` gets escaped
    differently.
    """
    # Known issue with Windows and py2, skip test for such env
    expected_output = [
        self.blame_line('A', '1) red', author='ASCII Author'),
        # Expect 8 spaces, to line up with the other name.
        self.blame_line(
            'B', '2) green', author='\u4e2d\u56fd\u4f5c\u8005        '),
        self.blame_line('A', '3) blue', author='ASCII Author'),
    ]
    retval, output = self.run_hyperblame([], 'file', 'tag_B')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)

  def testNonUTF8Data(self):
    """Ensures correct behaviour even if author or file data is not UTF-8.

    There is no guarantee that a file will be UTF-8-encoded, so this is
    realistic.
    """
    expected_output = [
        self.blame_line('A', '1) red', author='ASCII Author  '),
        # The Author has been re-encoded as UTF-8. The file data is converted to
        # UTF8 and unknown characters replaced.
        self.blame_line('C', '2) gre\ufffdn', author='Lat\xedn-1 Author'),
        self.blame_line('A', '3) blue', author='ASCII Author  '),
    ]
    retval, output = self.run_hyperblame([], 'file', 'tag_C')
    self.assertEqual(0, retval)
    self.assertEqual(expected_output, output)


if __name__ == '__main__':
  sys.exit(coverage_utils.covered_main(
    os.path.join(DEPOT_TOOLS_ROOT, 'git_hyper_blame.py')))
