diff --git a/Assets/Resources/Sprites/Attribution.txt b/Assets/Resources/Sprites/Attribution.txt index 666742406..8ca89fb1b 100644 --- a/Assets/Resources/Sprites/Attribution.txt +++ b/Assets/Resources/Sprites/Attribution.txt @@ -2,8 +2,7 @@ Noun Project Tornado by Bart Laubsch Alien Abduction by Bart Laubsch -Hurricane by icon 54 from -Smashicons Space by Ben Davis +Hurricane by icon 54 from Smashicons Space by Ben Davis Space 2 - Solid by Ben Davis Filter by Karthik Aathis Search by il Capitano diff --git a/Assets/Scripts/ServerShared/Credits.cs b/Assets/Scripts/ServerShared/Credits.cs index 3501ed2c9..3bb0af84b 100644 --- a/Assets/Scripts/ServerShared/Credits.cs +++ b/Assets/Scripts/ServerShared/Credits.cs @@ -19,6 +19,13 @@ public static class Credits public const string TwemojiFile = "Assets/Fonts/Emoji/LICENSE.txt"; public const string PlaydeadFile = "Assets/Plugins/Playdead/LICENSE.txt"; public const string NounProjectFile = "Assets/Resources/Sprites/Attribution.txt"; + public const string LiberationFile = "Assets/TextMesh Pro/Fonts/LiberationSans - OFL.txt"; + public const string UniRxFile = "Assets/Plugins/UniRx/ReadMe.txt"; + public const string AmbientCgFile = "Assets/Materials/PBR/SOURCES.md"; + public const string KdTreeFile = "Assets/Scripts/ServerShared/NIH/KDTree/KDTree.cs"; + public const string SimplexNoiseFile = "Assets/Plugins/GPU Noise/SimplexNoise2D.cginc"; + // The first line of the attribution file; the icon credits are the lines after it. + public const string NounProjectHeading = "Noun Project"; // Where the player build loads the Noun Project credit lines from (Resources path, no extension). public const string NounProjectResource = "Sprites/Attribution"; @@ -45,6 +52,44 @@ public static class Credits LicenceFile = PlaydeadFile }, new Credit + { + Name = "Liberation Sans", + Licence = "SIL Open Font License 1.1", + Notice = "Digitized data copyright (c) 2010 Google Corporation with Reserved Font Arimo, Tinos and Cousine. " + + "Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation.", + LicenceFile = LiberationFile + }, + new Credit + { + Name = "UniRx", + Licence = "MIT", + Notice = "Created by Yoshifumi Kawai (neuecc)", + LicenceFile = UniRxFile + }, + new Credit + { + Name = "KDTree", + Licence = "MIT", + Notice = "Copyright (c) 2018 Vili VolĨini / viliwonka", + LicenceFile = KdTreeFile + }, + new Credit + { + Name = "webgl-noise simplex noise", + Licence = "MIT", + Notice = "Copyright (C) 2011 Ashima Arts. Author: Ian McEwan, Ashima Arts " + + "(https://github.com/ashima/webgl-noise). Translated to Unity shaders and modified by Keijiro Takahashi " + + "(https://github.com/keijiro/NoiseShader).", + LicenceFile = SimplexNoiseFile + }, + new Credit + { + Name = "ambientCG PBR materials", + Licence = "CC0 1.0 Universal", + Notice = "Texture sets from ambientCG (https://ambientcg.com), public domain", + LicenceFile = AmbientCgFile + }, + new Credit { Name = "Noun Project icons", Licence = "Noun Project attribution licence", @@ -56,9 +101,16 @@ public static class Credits return credits; } - // The icon credit lines of the attribution file: everything after its "Noun Project" heading. - public static IEnumerable NounProjectLines(string attribution) => - (attribution ?? "").Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).Skip(1); + // The icon credit lines of the attribution file: everything after its "Noun Project" heading, one line per icon (the + // file's "Other" sub-heading and its lines follow in the same list). A file that does not open with the heading is + // refused rather than having its first line dropped. + public static IEnumerable NounProjectLines(string attribution) + { + var lines = (attribution ?? "").Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).ToArray(); + if (lines.Length < 2 || lines[0] != NounProjectHeading) + throw new InvalidOperationException($"{NounProjectFile} must open with the heading '{NounProjectHeading}' followed by icon credits"); + return lines.Skip(1); + } // Throws on an entry a player-facing screen could not honestly show. public static void Validate(IReadOnlyList credits) diff --git a/tests/Aetheria.Shared.Tests/CreditsTests.cs b/tests/Aetheria.Shared.Tests/CreditsTests.cs index 8f2691cf7..b346f1fca 100644 --- a/tests/Aetheria.Shared.Tests/CreditsTests.cs +++ b/tests/Aetheria.Shared.Tests/CreditsTests.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using System.Text.RegularExpressions; using Xunit; namespace Aetheria.Shared.Tests @@ -19,41 +20,151 @@ namespace Aetheria.Shared.Tests private static System.Collections.Generic.IReadOnlyList Load() => Credits.All(Read(Credits.NounProjectFile)); - [Fact] - public void EveryLicenceFileUnderAssetsIsCredited() + // A file under Assets that looks like a licence, attribution, notice, readme or sources list. Each one is either + // credited (the credit names it as its LicenceFile) or reviewed below with the reason it is not. + private static readonly Regex LicenceLike = new Regex( + @"^(LICEN[CS]E|COPYING|NOTICE|README|SOURCES|_SOURCES|Attribution)|OFL", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + private static readonly string[] SkippedDirectories = { "bin", "obj", "Library" }; + + // Discovered files whose own text names no holder and no licence, so there is nothing true to credit. A new + // licence-like file is not here until someone has read it and written the reason. + private static readonly System.Collections.Generic.Dictionary ReviewedNotCredited = new System.Collections.Generic.Dictionary + { + ["Assets/Scripts/ServerShared/NIH/LinqFaster/readme.txt"] = "usage guide for LinqFaster; names no holder and no licence", + ["Assets/Plugins/LowPoly_AsteroidsPack/README.pdf"] = "how to customise the asteroid pack; names no holder and no licence", + ["Assets/Plugins/Celestial Body/Textures/_Sources.txt"] = "URLs of the source textures only; states no licence", + }; + + // Bundled works with no licence-like file at all, found by reading Assets. Their licences are not stated in the + // repo, so they are not credited; each path must still exist, so this list cannot go stale unnoticed. + private static readonly string[] UnlicensedWorks = + { + "Assets/Fonts/Ubuntu", + "Assets/Fonts/Retro", + "Assets/Plugins/Ink", + "Assets/Plugins/GPUTrail", + "Assets/Plugins/com.unity.uiextensions", + "Assets/Shaders/StableFluids", + }; + + // What each credited file itself states, written here independently of the credit text: these strings must + // appear in the file and in the credit's notice. + private static readonly (string file, string[] stated)[] Holders = + { + (Credits.TwemojiFile, new[] { "Jason Sofonia & Justine De Caires", "Twitter", "CC BY 4.0" }), + (Credits.PlaydeadFile, new[] { "Playdead" }), + (Credits.LiberationFile, new[] { "Google Corporation", "Red Hat, Inc." }), + (Credits.UniRxFile, new[] { "Yoshifumi Kawai" }), + (Credits.KdTreeFile, new[] { "Vili Vol\u010dini / viliwonka" }), + (Credits.SimplexNoiseFile, new[] { "Ashima Arts", "Ian McEwan", "Keijiro Takahashi" }), + (Credits.AmbientCgFile, new[] { "ambientCG" }), + }; + + private static string[] LicenceLikeFiles() { var root = Root(); - var found = Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) + return Directory.EnumerateFiles(Path.Combine(root, "Assets"), "*", SearchOption.AllDirectories) .Where(path => !path.EndsWith(".meta")) - .Where(path => { var name = Path.GetFileName(path); return name.StartsWith("LICENSE", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Attribution", StringComparison.OrdinalIgnoreCase); }) .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/')) + .Where(path => !path.Split('/').Any(segment => SkippedDirectories.Contains(segment))) + .Where(path => LicenceLike.IsMatch(Path.GetFileName(path))) + .OrderBy(path => path, StringComparer.Ordinal) .ToArray(); - Assert.Contains(Credits.TwemojiFile, found); - var credited = Load().Select(credit => credit.LicenceFile).ToHashSet(); - Assert.Empty(found.Where(path => !credited.Contains(path))); - Assert.Empty(credited.Where(path => !File.Exists(Path.Combine(root, path)))); } + // The ways a licence-like file can be accounted for; shared so the mutations in the report hit the same rule. + private static string[] Unaccounted(string[] found, System.Collections.Generic.IEnumerable credited) => + found.Where(path => !credited.Contains(path) && !ReviewedNotCredited.ContainsKey(path)).ToArray(); + [Fact] - public void TwemojiComesFirstWithTheHoldersItsLicenceNames() + public void EveryLicenceLikeFileUnderAssetsIsCreditedOrReviewed() + { + var root = Root(); + var found = LicenceLikeFiles(); + Assert.Contains(Credits.TwemojiFile, found); + Assert.Contains(Credits.LiberationFile, found); + var credited = Load().Select(credit => credit.LicenceFile).ToArray(); + Assert.Equal(Array.Empty(), Unaccounted(found, credited)); + Assert.Equal(Array.Empty(), credited.Where(path => !File.Exists(Path.Combine(root, path))).ToArray()); + Assert.Equal(Array.Empty(), ReviewedNotCredited.Keys.Where(path => !found.Contains(path)).ToArray()); + Assert.Equal(Array.Empty(), ReviewedNotCredited.Keys.Where(path => credited.Contains(path)).ToArray()); + Assert.Equal(Array.Empty(), UnlicensedWorks.Where(path => !Directory.Exists(Path.Combine(root, path))).ToArray()); + } + + // Every holder the licence file states is in the credit that answers for it; the notices are typed into + // Credits.cs, so this is what keeps them true to the files. + [Fact] + public void EveryCreditShowsTheHoldersItsOwnFileStates() + { + var credits = Load(); + Assert.Equal(Holders.Select(h => h.file).Append(Credits.NounProjectFile).OrderBy(f => f, StringComparer.Ordinal), + credits.Select(credit => credit.LicenceFile).OrderBy(f => f, StringComparer.Ordinal)); + foreach (var (file, stated) in Holders) + { + var text = Normal(Read(file)); + var notice = Normal(credits.Single(credit => credit.LicenceFile == file).Notice); + foreach (var holder in stated) + { + Assert.True(text.Contains(Normal(holder)), $"{file} does not state '{holder}'"); + Assert.True(notice.Contains(Normal(holder)), $"the credit for {file} omits '{holder}'"); + } + // And every copyright line the file carries, whatever the table above says. + foreach (var line in Read(file).Split('\n')) + { + var match = CopyrightLine.Match(line); + if (!match.Success) continue; + var body = Regex.Replace(match.Groups[1].Value.Replace("[", "").Replace("]", "").Trim(), @"(\s*All rights reserved)?\.?$", "", RegexOptions.IgnoreCase); + Assert.True(notice.Contains(Normal(body)), $"the credit for {file} omits its copyright line '{body}'"); + } + } + } + + private static readonly Regex CopyrightLine = new Regex(@"copyright\s*(?:\(c\)|\u00a9)?\s*(\[?\d.*)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + // Dash variants and case folded, so the file's en dash and the screen's hyphen compare equal. + private static string Normal(string text) => text.Replace('\u2013', '-').Replace('\u2014', '-').Replace("\r", "").ToLowerInvariant(); + + [Fact] + public void TwemojiComesFirst() { var twemoji = Load()[0]; Assert.Equal("Twemoji", twemoji.Name); Assert.Contains("CC BY 4.0", twemoji.Licence); - Assert.Contains("Jason Sofonia & Justine De Caires", Read(Credits.TwemojiFile)); - Assert.Contains("Jason Sofonia & Justine De Caires", twemoji.Notice); - Assert.Contains("Twitter", twemoji.Notice); } + // The expectations are stated here, not derived from the parser: 81 icon credits between the heading and the + // "Other" sub-heading's two artists, named at the ends and at the line that was once split across two. [Fact] public void EveryIconCreditInTheAttributionFileIsShown() { - var lines = Credits.NounProjectLines(Read(Credits.NounProjectFile)).ToArray(); - Assert.True(lines.Length > 50); + var text = Read(Credits.NounProjectFile); + Assert.StartsWith(Credits.NounProjectHeading, text.TrimStart('\uFEFF')); + var lines = Credits.NounProjectLines(text).ToArray(); + Assert.Equal(82, lines.Length); + Assert.Equal("Tornado by Bart Laubsch", lines[0]); + Assert.Equal("Alien Abduction by Bart Laubsch", lines[1]); + Assert.Equal("Hurricane by icon 54 from Smashicons Space by Ben Davis", lines[2]); + Assert.Equal("Space 2 - Solid by Ben Davis", lines[3]); + Assert.Contains("Other", lines); + Assert.Equal("Missile War Weapon by Freepik", lines[^1]); + // One credit per line: every line but the sub-heading names an artist and does not stop mid-sentence. + foreach (var line in lines.Where(line => line != "Other")) + { + Assert.Contains(" by ", line, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotMatch(@"\s(from|by)$", line); + } var notice = Load().Single(credit => credit.LicenceFile == Credits.NounProjectFile).Notice.Split('\n'); Assert.Equal(lines, notice); } + [Fact] + public void AnAttributionFileWithoutItsHeadingIsRefused() + { + Assert.Throws(() => Credits.NounProjectLines("Tornado by Bart Laubsch\nAlien Abduction by Bart Laubsch").ToArray()); + Assert.Throws(() => Credits.NounProjectLines("Noun Project\n\n").ToArray()); + } + [Fact] public void EntriesNameTheirLicenceAndHolder() { @@ -63,8 +174,6 @@ namespace Aetheria.Shared.Tests Assert.False(string.IsNullOrWhiteSpace(credit.Licence)); Assert.False(string.IsNullOrWhiteSpace(credit.Notice)); } - Assert.Contains("Playdead", Read(Credits.PlaydeadFile)); - Assert.Contains("Playdead", Load().Single(credit => credit.LicenceFile == Credits.PlaydeadFile).Notice); } [Fact] @@ -73,17 +182,20 @@ namespace Aetheria.Shared.Tests Assert.Throws(() => Credits.Validate(new[] { new Credit { Name = "x", Licence = "", Notice = "n", LicenceFile = "f" } })); } - // The Unity menu is not compilable headlessly, so this reads its source: the main menu must offer Credits and - // the Credits screen must render the Shared list. It proves the wiring exists, not that the screen draws. + // The Unity menu is not compilable headlessly, so this reads its source with comments removed (a commented-out + // button or call must not satisfy it): the main menu must offer Credits, and ShowCredits must build the Shared + // list. It proves the wiring exists, not that the screen draws. [Fact] public void MainMenuOffersACreditsPathThatRendersTheList() { - var menu = Read("Assets/Scripts/UI/MainMenu.cs"); + var menu = Regex.Replace(Read("Assets/Scripts/UI/MainMenu.cs"), @"/\*.*?\*/|//[^\n]*", "", RegexOptions.Singleline); var main = menu.Substring(menu.IndexOf("private void ShowMain()", StringComparison.Ordinal)); - main = main.Substring(0, main.IndexOf("private void ShowScenarios()", StringComparison.Ordinal)); + main = main.Substring(0, main.IndexOf("private void ShowCredits()", StringComparison.Ordinal)); Assert.Contains("AddButton(\"Credits\"", main); Assert.Contains("ShowCredits()", main); - Assert.Contains("Credits.All(", menu); + var credits = menu.Substring(menu.IndexOf("private void ShowCredits()", StringComparison.Ordinal)); + credits = credits.Substring(0, credits.IndexOf("private void ShowScenarios()", StringComparison.Ordinal)); + Assert.Contains("Credits.All(", credits); } } }