diff --git a/GitCommands/Settings/AppSettings.cs b/GitCommands/Settings/AppSettings.cs index 46213481b51..c911784bd68 100644 --- a/GitCommands/Settings/AppSettings.cs +++ b/GitCommands/Settings/AppSettings.cs @@ -1690,6 +1690,12 @@ public static bool BlameShowOriginalFilePath set => SetBool("Blame.ShowOriginalFilePath", value); } + public static bool BlameShowAuthorAvatar + { + get => GetBool("Blame.ShowAuthorAvatar", true); + set => SetBool("Blame.ShowAuthorAvatar", value); + } + public static bool AutomaticContinuousScroll { get => GetBool("DiffViewer.AutomaticContinuousScroll", false); diff --git a/GitUI/Avatars/AvatarService.cs b/GitUI/Avatars/AvatarService.cs index 31c02fcd834..895208c22a6 100644 --- a/GitUI/Avatars/AvatarService.cs +++ b/GitUI/Avatars/AvatarService.cs @@ -4,7 +4,7 @@ namespace GitUI.Avatars { public static class AvatarService { - private static InitialsAvatarGenerator AvatarGenerator = new InitialsAvatarGenerator(); + private static readonly InitialsAvatarGenerator AvatarGenerator = new InitialsAvatarGenerator(); public static IAvatarProvider Default { get; } = new BackupAvatarProvider(new AvatarMemoryCache(new AvatarPersistentCache(new AvatarDownloader(AvatarGenerator), AvatarGenerator)), Images.User80); diff --git a/GitUI/CommandsDialogs/FormFileHistory.Designer.cs b/GitUI/CommandsDialogs/FormFileHistory.Designer.cs index 73a7c031db7..a89d488d641 100644 --- a/GitUI/CommandsDialogs/FormFileHistory.Designer.cs +++ b/GitUI/CommandsDialogs/FormFileHistory.Designer.cs @@ -67,6 +67,7 @@ private void InitializeComponent() this.showAuthorTimeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.showLineNumbersToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.showOriginalFilePathToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + this.showAuthorAvatarToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); this.splitContainer1.Panel1.SuspendLayout(); this.splitContainer1.Panel2.SuspendLayout(); @@ -481,6 +482,7 @@ private void InitializeComponent() this.toolStripSeparator5, this.displaySettingsToolStripMenuItem, this.displayAuthorFirstToolStripMenuItem, + this.showAuthorAvatarToolStripMenuItem, this.showAuthorToolStripMenuItem, this.showAuthorDateToolStripMenuItem, this.showAuthorTimeToolStripMenuItem, @@ -575,6 +577,13 @@ private void InitializeComponent() this.showOriginalFilePathToolStripMenuItem.Text = "Show original file path"; this.showOriginalFilePathToolStripMenuItem.Click += new System.EventHandler(this.showOriginalFilePathToolStripMenuItem_Click); // + // showAuthorAvatarToolStripMenuItem + // + this.showAuthorAvatarToolStripMenuItem.Name = "showAuthorAvatarToolStripMenuItem"; + this.showAuthorAvatarToolStripMenuItem.Size = new System.Drawing.Size(247, 22); + this.showAuthorAvatarToolStripMenuItem.Text = "Show author avatar"; + this.showAuthorAvatarToolStripMenuItem.Click += new System.EventHandler(this.showAuthorAvatarToolStripMenuItem_Click); + // // FormFileHistory // this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); @@ -661,5 +670,6 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripMenuItem displaySettingsToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem showAuthorToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem showOriginalFilePathToolStripMenuItem; + private System.Windows.Forms.ToolStripMenuItem showAuthorAvatarToolStripMenuItem; } -} \ No newline at end of file +} diff --git a/GitUI/CommandsDialogs/FormFileHistory.cs b/GitUI/CommandsDialogs/FormFileHistory.cs index fd6460fe4f5..9a69eec78f5 100644 --- a/GitUI/CommandsDialogs/FormFileHistory.cs +++ b/GitUI/CommandsDialogs/FormFileHistory.cs @@ -135,6 +135,7 @@ public FormFileHistory(GitUICommands commands, string fileName, GitRevision revi detectMoveAndCopyInAllFilesToolStripMenuItem.Checked = AppSettings.DetectCopyInAllOnBlame; detectMoveAndCopyInThisFileToolStripMenuItem.Checked = AppSettings.DetectCopyInFileOnBlame; displayAuthorFirstToolStripMenuItem.Checked = AppSettings.BlameDisplayAuthorFirst; + showAuthorAvatarToolStripMenuItem.Checked = AppSettings.BlameShowAuthorAvatar; showAuthorToolStripMenuItem.Checked = AppSettings.BlameShowAuthor; showAuthorDateToolStripMenuItem.Checked = AppSettings.BlameShowAuthorDate; showAuthorTimeToolStripMenuItem.Checked = AppSettings.BlameShowAuthorTime; @@ -704,5 +705,12 @@ private void showOriginalFilePathToolStripMenuItem_Click(object sender, EventArg showOriginalFilePathToolStripMenuItem.Checked = AppSettings.BlameShowOriginalFilePath; UpdateSelectedFileViewers(true); } + + private void showAuthorAvatarToolStripMenuItem_Click(object sender, EventArgs e) + { + AppSettings.BlameShowAuthorAvatar = !AppSettings.BlameShowAuthorAvatar; + showAuthorAvatarToolStripMenuItem.Checked = AppSettings.BlameShowAuthorAvatar; + UpdateSelectedFileViewers(true); + } } } diff --git a/GitUI/Editor/BlameAuthorMargin.cs b/GitUI/Editor/BlameAuthorMargin.cs new file mode 100644 index 00000000000..5baa14392ec --- /dev/null +++ b/GitUI/Editor/BlameAuthorMargin.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using System.Windows.Forms; +using GitExtUtils.GitUI; +using ICSharpCode.TextEditor; + +namespace GitUI.Editor +{ + /// + /// This class display avatars in the gutter in a blame control. + /// + public class BlameAuthorMargin : AbstractMargin + { + private static readonly int AgeBucketMarkerWidth = Convert.ToInt32(4 * DpiUtil.ScaleX); + private List _avatars; + private readonly int _lineHeight; + private readonly Color _backgroundColor; + private List _blameLines; + private readonly Dictionary _brushs = new Dictionary(); + private bool _isVisible = true; + + public BlameAuthorMargin(TextArea textArea) : base(textArea) + { + _lineHeight = GetFontHeight(textArea.Font); + _backgroundColor = SystemColors.Window; + Width = _lineHeight + AgeBucketMarkerWidth + DpiUtil.Scale(2); + } + + public override int Width { get; } + public override bool IsVisible => _isVisible; + + private static int GetFontHeight(Font font) + { + var max = Math.Max( + TextRenderer.MeasureText("_", font).Height, + (int)Math.Ceiling(font.GetHeight())); + + return max + 1; + } + + public void Initialize(IEnumerable blameLines) + { + _blameLines = blameLines.ToList(); + _avatars = _blameLines.Select(a => a.Avatar).ToList(); + + // Update the resolution otherwise the image is not drawn at the good size :( + foreach (var avatar in _avatars) + { + if (avatar is Bitmap bitmapAvatar) + { + bitmapAvatar.SetResolution(DpiUtil.DpiX, DpiUtil.DpiY); + } + } + + // Build brushes + foreach (var blameLine in _blameLines) + { + if (!_brushs.ContainsKey(blameLine.AgeBucketIndex)) + { + _brushs.Add(blameLine.AgeBucketIndex, new SolidBrush(blameLine.AgeBucketColor)); + } + } + } + + public void SetVisiblity(bool isVisible) + { + _isVisible = isVisible; + } + + public override void Paint(Graphics g, Rectangle rect) + { + if (rect.Width <= 0 || rect.Height <= 0 || _blameLines == null || _blameLines.Count == 0) + { + return; + } + + g.Clear(_backgroundColor); + + if (_avatars == null || _avatars.Count == 0) + { + return; + } + + var verticalOffset = textArea.VirtualTop.Y; + var lineStart = verticalOffset / _lineHeight; + var negativeOffset = (lineStart * _lineHeight) - verticalOffset; + var lineCount = (int)Math.Ceiling((double)(rect.Height - negativeOffset) / _lineHeight); + + for (int i = 0; i < lineCount; i++) + { + if (lineStart + i >= _avatars.Count) + { + break; + } + + int y = negativeOffset + (i * _lineHeight); + g.FillRectangle(_brushs[_blameLines[lineStart + i].AgeBucketIndex], 0, y, AgeBucketMarkerWidth, _lineHeight); + + if (_avatars[lineStart + i] != null) + { + g.DrawImage(_avatars[lineStart + i], new Point(AgeBucketMarkerWidth, y)); + } + } + + base.Paint(g, rect); + } + } +} diff --git a/GitUI/Editor/FileViewer.cs b/GitUI/Editor/FileViewer.cs index 396786f4bd0..2e1a5bb912f 100644 --- a/GitUI/Editor/FileViewer.cs +++ b/GitUI/Editor/FileViewer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Drawing; using System.IO; @@ -1667,5 +1668,15 @@ public bool ShowSyntaxHighlightingInDiff public ToolStripButton IgnoreAllWhitespacesButton => _fileViewer.ignoreAllWhitespaces; public ToolStripMenuItem IgnoreAllWhitespacesMenuItem => _fileViewer.ignoreAllWhitespaceChangesToolStripMenuItem; } + + public void SetGitBlameGutter(IEnumerable gitBlameEntries) + { + internalFileViewer.ShowGutterAvatars = AppSettings.BlameShowAuthorAvatar; + + if (AppSettings.BlameShowAuthorAvatar) + { + internalFileViewer.SetGitBlameGutter(gitBlameEntries); + } + } } } diff --git a/GitUI/Editor/FileViewerInternal.cs b/GitUI/Editor/FileViewerInternal.cs index 3e5d8864682..f03573bd1a5 100644 --- a/GitUI/Editor/FileViewerInternal.cs +++ b/GitUI/Editor/FileViewerInternal.cs @@ -37,6 +37,8 @@ public partial class FileViewerInternal : GitModuleControl, IFileViewer private bool _shouldScrollToBottom = false; private readonly int _bottomBlankHeight = DpiUtil.Scale(300); private ContinuousScrollEventManager _continuousScrollEventManager; + private BlameAuthorMargin _authorsAvatarMargin; + private bool _showGutterAvatars; public FileViewerInternal() { @@ -536,6 +538,39 @@ private void OnVScrollPositionChanged(EventArgs e) #endregion + public void SetGitBlameGutter(IEnumerable gitBlameEntries) + { + if (_showGutterAvatars) + { + _authorsAvatarMargin.Initialize(gitBlameEntries); + } + } + + public bool ShowGutterAvatars + { + get => _showGutterAvatars; + set + { + _showGutterAvatars = value; + if (!_showGutterAvatars) + { + _authorsAvatarMargin?.SetVisiblity(false); + + return; + } + + if (_authorsAvatarMargin == null) + { + _authorsAvatarMargin = new BlameAuthorMargin(TextEditor.ActiveTextAreaControl.TextArea); + TextEditor.ActiveTextAreaControl.TextArea.InsertLeftMargin(0, _authorsAvatarMargin); + } + else + { + _authorsAvatarMargin.SetVisiblity(true); + } + } + } + internal sealed class CurrentViewPositionCache { private readonly FileViewerInternal _viewer; diff --git a/GitUI/Editor/GitBlameEntry.cs b/GitUI/Editor/GitBlameEntry.cs new file mode 100644 index 00000000000..8458364d47f --- /dev/null +++ b/GitUI/Editor/GitBlameEntry.cs @@ -0,0 +1,11 @@ +using System.Drawing; + +namespace GitUI.Editor +{ + public class GitBlameEntry + { + public Image Avatar { get; set; } + public int AgeBucketIndex { get; set; } + public Color AgeBucketColor { get; set; } + } +} diff --git a/GitUI/Translation/English.xlf b/GitUI/Translation/English.xlf index 29602713ad8..04d6913e3af 100644 --- a/GitUI/Translation/English.xlf +++ b/GitUI/Translation/English.xlf @@ -4268,6 +4268,10 @@ If you are not sure just close this window. Save as + + Show author avatar + + Show author date diff --git a/GitUI/UserControls/BlameControl.cs b/GitUI/UserControls/BlameControl.cs index 6dd4547c10c..cc3b3b3056c 100644 --- a/GitUI/UserControls/BlameControl.cs +++ b/GitUI/UserControls/BlameControl.cs @@ -2,19 +2,26 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; +using System.Drawing.Imaging; using System.Globalization; using System.Linq; using System.Text; +using System.Threading.Tasks; using System.Windows.Forms; using GitCommands; using GitExtUtils; +using GitExtUtils.GitUI.Theming; +using GitUI.Avatars; using GitUI.BranchTreePanel; using GitUI.CommitInfo; using GitUI.Editor; using GitUI.HelperDialogs; +using GitUI.Properties; using GitUIPluginInterfaces; using GitUIPluginInterfaces.RepositoryHosts; +using ICSharpCode.TextEditor; using JetBrains.Annotations; +using Microsoft.VisualStudio.Threading; using ResourceManager; namespace GitUI.Blame @@ -44,6 +51,7 @@ public sealed partial class BlameControl : GitModuleControl private GitBlameCommit _tooltipCommit; private bool _changingScrollPosition; private IRepositoryHostPlugin _gitHoster; + private static readonly IList AgeBucketGradientColors = GetAgeBucketGradientColors(); public BlameControl() { @@ -283,7 +291,10 @@ private void BlameFile_VScrollPositionChanged(object sender, EventArgs e) private void ProcessBlame(string filename, GitRevision revision, IReadOnlyList children, Control controlToMask, int lineNumber, int scrollpos) { - var (gutter, body) = BuildBlameContents(filename); + var avatarSize = BlameAuthor.Font.Height + 1; + var (gutter, body, avatars) = BuildBlameContents(filename, avatarSize); + + BlameAuthor.SetGitBlameGutter(avatars); ThreadHelper.JoinableTaskFactory.RunAsync( () => BlameAuthor.ViewTextAsync("committer.txt", gutter)); @@ -307,12 +318,15 @@ private void ProcessBlame(string filename, GitRevision revision, IReadOnlyList gitBlameDisplays) BuildBlameContents(string filename, int avatarSize) { var body = new StringBuilder(capacity: 4096); GitBlameCommit lastCommit = null; + bool showAuthorAvatar = AppSettings.BlameShowAuthorAvatar; + var gitBlameDisplays = showAuthorAvatar ? CalculateBlameGutterData(_blame.Lines) : new List(0); + var dateTimeFormat = AppSettings.BlameShowAuthorTime ? CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " " + CultureInfo.CurrentCulture.DateTimeFormat.ShortTimePattern @@ -324,22 +338,49 @@ private void ProcessBlame(string filename, GitRevision revision, IReadOnlyList filename != l.Commit.FileName) - .Select(l => l.Commit.FileName.Length) - .DefaultIfEmpty(0) - .Max(); + .Select(l => l.Commit.FileName.Length) + .DefaultIfEmpty(0) + .Max(); var lineLengthEstimate = 25 + _blame.Lines.Max(l => l.Commit.Author?.Length ?? 0) + filePathLengthEstimate; var lineLength = Math.Max(80, lineLengthEstimate); var lineBuilder = new StringBuilder(lineLength + 2); var gutter = new StringBuilder(capacity: lineBuilder.Capacity * _blame.Lines.Count); var emptyLine = new string(' ', lineLength); - foreach (var line in _blame.Lines) + var cacheAvatars = new Dictionary(); + var noAuthorImage = (Image)new Bitmap(Images.User80, avatarSize, avatarSize); + for (var index = 0; index < _blame.Lines.Count; index++) { + var line = _blame.Lines[index]; if (line.Commit == lastCommit) { gutter.AppendLine(emptyLine); } else { + var authorEmail = line.Commit.AuthorMail?.Trim('<', '>'); + if (showAuthorAvatar) + { + if (authorEmail != null) + { + if (cacheAvatars.ContainsKey(authorEmail)) + { + gitBlameDisplays[index].Avatar = cacheAvatars[authorEmail]; + } + else + { + var avatar = ThreadHelper.JoinableTaskFactory.Run(() => + AvatarService.Default.GetAvatarAsync(authorEmail, line.Commit.Author, + avatarSize)); + cacheAvatars.Add(authorEmail, avatar); + gitBlameDisplays[index].Avatar = avatar; + } + } + else + { + gitBlameDisplays[index].Avatar = noAuthorImage; + } + } + BuildAuthorLine(line, lineBuilder, dateTimeFormat, filename, AppSettings.BlameShowAuthor, AppSettings.BlameShowAuthorDate, AppSettings.BlameShowOriginalFilePath, AppSettings.BlameDisplayAuthorFirst); gutter.Append(lineBuilder); @@ -352,10 +393,11 @@ private void ProcessBlame(string filename, GitRevision revision, IReadOnlyList GetAgeBucketGradientColors() + { + // Color chosen from: https://colorbrewer2.org/#type=sequential&scheme=Greens&n=7 + return new[] + { + Color.FromArgb(247, 252, 245), + Color.FromArgb(199, 233, 192), + Color.FromArgb(161, 217, 155), + Color.FromArgb(116, 196, 118), + Color.FromArgb(65, 171, 93), + Color.FromArgb(35, 139, 69), + Color.FromArgb(0, 68, 27), + }.Select(ColorHelper.AdaptBackColor).ToList(); + } + + public DateTime ArtificialOldBoundary => DateTime.Now.AddYears(-3); + + private List CalculateBlameGutterData(IReadOnlyList blameLines) + { + var mostRecentDate = DateTime.Now.Ticks; + var artificialOldBoundary = ArtificialOldBoundary; + var gitBlameDisplays = new List(blameLines.Count); + + var lessRecentDate = Math.Min(artificialOldBoundary.Ticks, + blameLines.Select(l => l.Commit.AuthorTime) + .Where(d => d != DateTime.MinValue) + .DefaultIfEmpty(artificialOldBoundary) + .Min() + .Ticks); + var intervalSize = (mostRecentDate - lessRecentDate + 1) / AgeBucketGradientColors.Count; + foreach (var blame in blameLines) + { + var relativeTicks = Math.Max(0, blame.Commit.AuthorTime.Ticks - lessRecentDate); + var ageBucketIndex = Math.Min((int)(relativeTicks / intervalSize), AgeBucketGradientColors.Count - 1); + var gitBlameDisplay = new GitBlameEntry + { + AgeBucketIndex = ageBucketIndex, + AgeBucketColor = AgeBucketGradientColors[ageBucketIndex] + }; + gitBlameDisplays.Add(gitBlameDisplay); + } + + return gitBlameDisplays; + } + private void ActiveTextAreaControlDoubleClick(object sender, EventArgs e) { if (_lastBlameLine == null) @@ -572,10 +659,15 @@ public GitBlame Blame set => _control._blame = value; } + public DateTime ArtificialOldBoundary => _control.ArtificialOldBoundary; + public void BuildAuthorLine(GitBlameLine line, StringBuilder lineBuilder, string dateTimeFormat, string filename, bool showAuthor, bool showAuthorDate, bool showOriginalFilePath, bool displayAuthorFirst) => _control.BuildAuthorLine(line, lineBuilder, dateTimeFormat, filename, showAuthor, showAuthorDate, showOriginalFilePath, displayAuthorFirst); - public (string gutter, string body) BuildBlameContents(string filename) => _control.BuildBlameContents(filename); + public (string gutter, string body, List avatars) BuildBlameContents(string filename) => _control.BuildBlameContents(filename, avatarSize: 10); + + public List CalculateBlameGutterData(IReadOnlyList blameLines) + => _control.CalculateBlameGutterData(blameLines); } } } diff --git a/UnitTests/GitUI.Tests/UserControls/BlameControlTests.cs b/UnitTests/GitUI.Tests/UserControls/BlameControlTests.cs index a0439514f1c..9260d590d1f 100644 --- a/UnitTests/GitUI.Tests/UserControls/BlameControlTests.cs +++ b/UnitTests/GitUI.Tests/UserControls/BlameControlTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; @@ -102,7 +103,7 @@ public void BuildBlameContents_WithDateAndTime() { AppSettings.BlameShowAuthorTime = true; - var (gutter, content) = _blameControl.GetTestAccessor().BuildBlameContents("fileName.txt"); + var (gutter, content, _) = _blameControl.GetTestAccessor().BuildBlameContents("fileName.txt"); content.Should().Be($"line1{Environment.NewLine}line2{Environment.NewLine}line3{Environment.NewLine}line4{Environment.NewLine}"); var gutterLines = gutter.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries); @@ -131,7 +132,7 @@ public void BuildBlameContents_WithDateButNotTime() AppSettings.BlameShowAuthorTime = false; // When - var (gutter, content) = _blameControl.GetTestAccessor().BuildBlameContents("fileName.txt"); + var (gutter, content, _) = _blameControl.GetTestAccessor().BuildBlameContents("fileName.txt"); // Then content.Should().Be($"line1{Environment.NewLine}line2{Environment.NewLine}line3{Environment.NewLine}line4{Environment.NewLine}"); @@ -149,5 +150,106 @@ public void BuildBlameContents_WithDateButNotTime() AppSettings.BlameShowAuthorTime = originalValue; } } + + private IEnumerable CreateBlameLine(params DateTime[] lineDates) + { + for (var index = 0; index < lineDates.Length; index++) + { + DateTime lineDate = lineDates[index]; + yield return new GitBlameLine( + new GitBlameCommit(null, "Author1", "@Author1", lineDate, string.Empty, + "Commiter", "@Committer", lineDate, string.Empty, "Summary1", "file"), + index + 1, index + 1, "text"); + } + } + + [Test] + public void CalculateBlameGutterData_When_date_is_older_than_artificial_old_boundary_Then_it_defines_first_age_bucket_and_so_falls_into_it() + { + // Given + var sut = _blameControl.GetTestAccessor(); + IReadOnlyList blameLines = CreateBlameLine(sut.ArtificialOldBoundary.AddDays(-1)).ToList(); + + // When + var blameEntries = sut.CalculateBlameGutterData(blameLines); + + // Then + blameEntries.Should().HaveCount(1); + blameEntries[0].AgeBucketIndex.Should().Be(0); + } + + [Test] + public void CalculateBlameGutterData_When_date_is_newer_than_artificial_old_boundary_Then_it_falls_in_a_later_age_bucket() + { + // Given + IReadOnlyList blameLines = CreateBlameLine(DateTime.Now.AddMonths(-18)).ToList(); + + // When + var blameEntries = _blameControl.GetTestAccessor().CalculateBlameGutterData(blameLines); + + // Then + blameEntries.Should().HaveCount(1); + blameEntries[0].AgeBucketIndex.Should().Be(3); + } + + [Test] + public void CalculateBlameGutterData_When_date_is_DateMin_Then_it_falls_in_a_first_age_bucket() + { + // Given + IReadOnlyList blameLines = CreateBlameLine(DateTime.Now.AddMonths(-18), DateTime.MinValue).ToList(); + + // When + var blameEntries = _blameControl.GetTestAccessor().CalculateBlameGutterData(blameLines); + + // Then + blameEntries.Should().HaveCount(2); + blameEntries[1].AgeBucketIndex.Should().Be(0); + } + + [Test] + public void CalculateBlameGutterData_When_all_dates_are_DateMin_Then_they_falls_in_a_first_age_bucket() + { + // Given + IReadOnlyList blameLines = CreateBlameLine(DateTime.MinValue, DateTime.MinValue).ToList(); + + // When + var blameEntries = _blameControl.GetTestAccessor().CalculateBlameGutterData(blameLines); + + // Then + blameEntries.Should().HaveCount(2); + blameEntries[0].AgeBucketIndex.Should().Be(0); + blameEntries[1].AgeBucketIndex.Should().Be(0); + } + + [Test] + public void CalculateBlameGutterData_When_dates_just_after_age_bucket_limit_Then_One_date_in_each_age_bucket() + { + // Given + var now = DateTime.Now; + var marginError = TimeSpan.FromMinutes(10); // Due to the DateTime.Now value which is slightly different + IReadOnlyList blameLines = CreateBlameLine( + now.AddDays(-7 * 365), // Because there are 7 age buckets (corresponding to the different colors) + now.AddDays(-6 * 365).Add(marginError), + now.AddDays(-5 * 365).Add(marginError), + now.AddDays(-4 * 365).Add(marginError), + now.AddDays(-3 * 365).Add(marginError), + now.AddDays(-2 * 365).Add(marginError), + now.AddDays(-1 * 365).Add(marginError), + now.Add(-marginError)).ToList(); + + // When + var blameEntries = _blameControl.GetTestAccessor().CalculateBlameGutterData(blameLines); + + // Then + blameEntries.Should().HaveCount(8); + blameEntries[0].AgeBucketIndex.Should().Be(0); + blameEntries[1].AgeBucketIndex.Should().Be(1); + blameEntries[2].AgeBucketIndex.Should().Be(2); + blameEntries[3].AgeBucketIndex.Should().Be(3); + blameEntries[4].AgeBucketIndex.Should().Be(4); + blameEntries[5].AgeBucketIndex.Should().Be(5); + blameEntries[6].AgeBucketIndex.Should().Be(6); + blameEntries[7].AgeBucketIndex.Should().Be(6); + } } }