using System; using System.Collections.Generic; using System.IO; using System.Linq; using GameCult.Caching; using Xunit; public sealed class EconomyCommandTests { private sealed class Lambda : EconomyBatch { private readonly Action _author; public Lambda(Action author) => _author = author; public override void Author(EconomyAuthor author) => _author(author); } private static void Flush(EconomyFixture f) => f.Cache.FlushAsync().GetAwaiter().GetResult(); private static CultCache Read(EconomyFixture f) => AetheriaStores.Open(f.CatalogPath, registry: TestCatalog.Registry()); private static int Author(EconomyFixture f, bool apply, out string output, params Action[] batches) { Flush(f); var writer = new StringWriter(); var code = EconomyCommand.Author(f.CatalogPath, batches.Select(b => (EconomyBatch) new Lambda(b)).ToList(), apply, writer, TestCatalog.Registry()); output = writer.ToString(); return code; } // A commodity and its recipe, run by the fixture's mill unless a kind is given. private static void Good(EconomyFixture f, EconomyAuthor a, string id, string lore = "a good", string[] fills = null, (string Id, int Count)[] inputs = null, CultRecordRef? kind = null) { var key = new CultRecordKey(EconomyFixture.Key(id)); if (inputs == null) a.Put(new SimpleCommodityData { Name = id }, key); else a.Put(new CompoundCommodityData { Name = id }, key); a.Put(new RecipeData { Output = EconomyFixture.Item(key.Value), StationKind = kind ?? f.Mill, LoreHook = lore, Inputs = (inputs ?? Array.Empty<(string, int)>()).Select(i => new CommodityCount { Commodity = EconomyFixture.Item(EconomyFixture.Key(i.Id)), Count = i.Count }).ToList(), Fills = (fills ?? Array.Empty()).ToList() }, RecipeData.KeyOf(key)); } [Fact] public void ABatchWithAShapeFaultIsRefusedAndTheCatalogStaysByteIdentical() { using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var code = Author(f, true, out var output, a => Good(f, a, "tar", lore: " ")); Assert.Equal(1, code); Assert.Contains("L-lore", output); Assert.Contains("refused", output); Assert.Contains(" L-lore recipe:commodity:tar: ", output); Assert.Contains(" recipe:commodity:tar: A commodity's recipe needs a lore hook.", output); Assert.Contains("1 shape faults", output); Assert.Contains("commodity:ore depth 0", output); Assert.True(output.IndexOf("commodity:ore depth", StringComparison.Ordinal) < output.IndexOf("commodity:plate depth", StringComparison.Ordinal)); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void TheGateRefusesALoopThatCannotSustainItself() { using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var code = Author(f, true, out var output, a => { Good(f, a, "loopa", inputs: new[] { ("loopb", 1) }, fills: new[] { "Plate" }); Good(f, a, "loopb", inputs: new[] { ("loopa", 1) }); }); Assert.Equal(1, code); Assert.Contains("L-productive", output); Assert.Contains("L-source-sink", output); Assert.True(output.IndexOf("L-productive (", StringComparison.Ordinal) < output.IndexOf("L-source-sink (", StringComparison.Ordinal)); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void ADryRunWritesNothingAndPrintsWhatItWouldDo() { using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var code = Author(f, false, out var output, a => Good(f, a, "coal"), a => a.Edit("ore", ore => ore.Description = "grey")); Assert.Equal(0, code); Assert.Contains("added commodity:coal", output); Assert.Contains("added recipe:commodity:coal", output); Assert.Contains("changed commodity:ore", output); Assert.Contains("Dry run", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void ACleanBatchAppliesAndTheCatalogThenReportsNoFault() { using var f = EconomyFixture.Healthy(); var code = Author(f, true, out var output, a => { Good(f, a, "coal"); Good(f, a, "steel", inputs: new[] { ("coal", 2) }, fills: new[] { "Plate" }); }); Assert.Equal(0, code); Assert.Contains("0 faults", output); Assert.Contains("Wrote 4 records to Aetheria.cc", output); using var after = Read(f); Assert.NotNull(after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:steel")))); Assert.Empty(EconomyChecks.Check(after).Faults); } [Fact] public void ApplyConvergesAndTouchesOnlyWhatItNames() { using var f = EconomyFixture.Healthy(); Action coal = a => Good(f, a, "coal", fills: new[] { "Plate" }); Assert.Equal(0, Author(f, true, out _, coal)); var bytes = File.ReadAllBytes(f.CatalogPath); Assert.Equal(0, Author(f, true, out var again, coal)); Assert.Contains("0 added, 0 changed, 0 removed, 2 unchanged", again); Assert.Contains("Nothing to write.", again); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); Assert.Equal(0, Author(f, true, out var revised, a => Good(f, a, "coal", lore: "black rock", fills: new[] { "Plate" }))); Assert.Contains("0 added, 1 changed, 0 removed, 1 unchanged", revised); using var after = Read(f); Assert.Equal("black rock", after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:coal"))).LoreHook); Assert.Equal("a good", after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:ore"))).LoreHook); } [Fact] public void AFaultOnARecordTheBatchDidNotChangeDoesNotBlock() { using var f = EconomyFixture.Healthy(); f.Good("tar", lore: " "); var code = Author(f, true, out var output, a => Good(f, a, "tar", lore: " "), a => Good(f, a, "coal")); Assert.Equal(0, code); Assert.Contains("L-lore", output); Assert.Contains("2 added, 0 changed, 0 removed, 2 unchanged", output); using var after = Read(f); Assert.NotNull(after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:coal")))); } [Fact] public void TheTrialCopyIsRemoved() { using var f = EconomyFixture.Healthy(); var before = Directory.GetDirectories(Path.GetTempPath(), "aetherdb-economy-*").Length; Author(f, false, out _, a => Good(f, a, "coal")); Author(f, true, out _, a => Good(f, a, "tar", lore: "")); Assert.Equal(before, Directory.GetDirectories(Path.GetTempPath(), "aetherdb-economy-*").Length); } [Fact] public void ACompletenessFaultIsReportedAndDoesNotBlockApply() { using var f = EconomyFixture.Healthy(); var code = Author(f, true, out var output, a => Good(f, a, "dust")); Assert.Equal(0, code); Assert.Contains("L-source-sink", output); using var after = Read(f); Assert.NotNull(after.Get(RecipeData.KeyOf(new CultRecordKey("commodity:dust")))); } [Fact] public void ACommodityWrittenWithoutARecipeIsCompletenessNotARefusal() { using var f = EconomyFixture.Healthy(); var code = Author(f, true, out var output, a => a.Put(new SimpleCommodityData { Name = "bare" }, new CultRecordKey("commodity:bare"))); Assert.Equal(0, code); Assert.Contains("L-recipe", output); } [Fact] public void AuthorsAKindWithStandIns() { using var f = EconomyFixture.Healthy(); var forge = StationKindData.KeyOf("forge"); var code = Author(f, true, out _, a => { a.Put(new StationKindData { Name = "Forge", StandIns = new List { new StationKindStandIn { Faction = f.Maker, Kind = f.Mill } } }, forge); Good(f, a, "slab", kind: new CultRecordRef(forge), fills: new[] { "Plate" }); }); Assert.Equal(0, code); using var after = Read(f); Assert.Single(after.Get(forge).StandIns); Assert.DoesNotContain(EconomyChecks.Check(after).Faults, x => x.Invariant == EconomyChecks.LOneKindList); } [Fact] public void AKindThatStandsInForItselfIsRefusedAndNothingIsWritten() { using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var forge = StationKindData.KeyOf("forge"); var code = Author(f, true, out var output, a => a.Put(new StationKindData { Name = "Forge", StandIns = new List { new StationKindStandIn { Faction = f.Maker, Kind = new CultRecordRef(forge) } } }, forge)); Assert.Equal(1, code); Assert.Contains("refused", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void AnEditNamingNoItemIsARefusalNotAThrow() { using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var code = Author(f, true, out var output, a => a.Edit("unobtainium", i => i.Price = 3)); Assert.Equal(1, code); Assert.Contains("The catalog holds no SimpleCommodityData named 'unobtainium'.", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void AnEditNamingTwoItemsIsARefusalAndNothingIsWritten() { using var f = EconomyFixture.Healthy(); f.Cache.Upsert(typeof(SimpleCommodityData), new SimpleCommodityData { Name = "twin" }, new CultRecordKey("commodity:twin-a")); f.Cache.Upsert(typeof(SimpleCommodityData), new SimpleCommodityData { Name = "twin" }, new CultRecordKey("commodity:twin-b")); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var code = Author(f, true, out var output, a => a.Edit("twin", i => i.Price = 3)); Assert.Equal(1, code); Assert.Contains("2 SimpleCommodityData records are named 'twin'", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void AnEditChangesOnlyTheNamedItem() { using var f = EconomyFixture.Healthy(); var code = Author(f, true, out _, a => a.Edit("ore", ore => { ore.Description = "grey"; ore.Price = 7; ore.Mass = 2; })); Assert.Equal(0, code); using var after = Read(f); var ore = after.Get(new CultRecordKey("commodity:ore")); Assert.Equal(("grey", 7, 2f), (ore.Description, ore.Price, ore.Mass)); Assert.Null(after.Get(new CultRecordKey("commodity:plate")).Description); } [Fact] public void ReportPrintsFaultsAndDepthAndFailsOnAnyFault() { using var f = EconomyFixture.Healthy(); var healthy = new StringWriter(); Assert.Equal(0, EconomyCommand.Report(f.Cache, healthy)); var text = healthy.ToString(); Assert.Contains("0 faults", text); Assert.Contains("commodity:plate depth 1", text); Assert.Contains("commodity:ore <- nothing | Mill, cycle 1, workforce 0 | a good", text); Assert.Contains("commodity:plate <- 2 x commodity:ore | Mill, cycle 1, workforce 0 | a good", text); Assert.Contains("gear:laser <- 2 x Plate | Mill", text); Assert.True(text.IndexOf("commodity:ore <-", StringComparison.Ordinal) < text.IndexOf("commodity:plate <-", StringComparison.Ordinal)); Assert.True(text.IndexOf("commodity:plate <-", StringComparison.Ordinal) < text.IndexOf("gear:laser <-", StringComparison.Ordinal)); f.EditRecipe("commodity:plate", r => r.LoreHook = ""); var broken = new StringWriter(); Assert.Equal(1, EconomyCommand.Report(f.Cache, broken)); Assert.Contains("L-lore", broken.ToString()); } [Fact] public void AnUnknownSubcommandFails() => Assert.Equal(1, EconomyCommand.Run(new[] { "bogus" })); private static CultRecordKey K(string id) => new CultRecordKey(EconomyFixture.Key(id)); private static string FaultsOf(CultCache catalog, string invariant) => string.Join(";", EconomyChecks.Check(catalog).Faults.Where(x => x.Invariant == invariant).Select(x => x.Record)); [Fact] public void ACycleClosedByALaterBatchIsRefusedEvenThoughTheFaultNamesAnEarlierBatchsRecord() { using var f = EconomyFixture.Healthy(); Assert.Equal(0, Author(f, true, out _, a => { a.Put(new CompoundCommodityData { Name = "loopb" }, K("loopb")); Good(f, a, "loopa", inputs: new[] { ("loopb", 1) }); })); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var code = Author(f, true, out var output, a => Good(f, a, "loopb", inputs: new[] { ("loopa", 2) })); Assert.Equal(1, code); Assert.Contains("refused", output); Assert.Contains("L-productive", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void AnInPlaceEditThroughTheCatalogIsReportedChangedAndCheckedBeforeItIsWritten() { using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var plate = RecipeData.KeyOf(K("plate")); var code = Author(f, true, out var output, a => { var held = a.Catalog.Get(plate); held.LoreHook = " "; a.Put(held, plate); }); Assert.Equal(1, code); Assert.Contains("changed recipe:commodity:plate", output); Assert.Contains("L-lore", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void ABatchRunsOnceAndWhatIsWrittenIsWhatWasChecked() { using var f = EconomyFixture.Healthy(); var runs = 0; var code = Author(f, true, out _, a => { runs++; Good(f, a, "tin", lore: runs == 1 ? "a good" : " "); }); Assert.Equal(0, code); Assert.Equal(1, runs); using var after = Read(f); Assert.Equal("a good", after.Get(RecipeData.KeyOf(K("tin"))).LoreHook); Assert.DoesNotContain(EconomyChecks.Check(after).Faults, x => x.Invariant == EconomyChecks.LLore); } [Theory] [InlineData(false)] [InlineData(true)] public void ABatchThatWritesTheRealCatalogIsRefusedByName(bool apply) { using var f = EconomyFixture.Healthy(); var code = Author(f, apply, out var output, a => File.AppendAllText(f.CatalogPath, "x")); Assert.Equal(1, code); Assert.Contains("Aetheria.cc changed during the trial", output); Assert.DoesNotContain("Dry run", output); } [Fact] public void ABatchThatThrowsIsARefusalNamingItWithNoStackTrace() { using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var code = Author(f, true, out var output, a => Good(f, a, "zinc"), a => throw new IOException("boom")); Assert.Equal(1, code); Assert.Contains("The batch Lambda is refused: boom", output); Assert.DoesNotContain(" at ", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Theory] [InlineData(true)] [InlineData(false)] public void ANullOutsideMarketRowsOrRowIsANamedFaultNotAThrow(bool rowsNull) { using var f = EconomyFixture.Healthy(); f.Cache.Upsert(typeof(OutsideMarketData), new OutsideMarketData { Name = "gate", Rows = rowsNull ? null : new List { null } }, OutsideMarketData.KeyOf("gate")); var report = EconomyChecks.Check(f.Cache); Assert.Contains(report.Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "outside-market:gate"); Assert.Equal(1, EconomyCommand.Report(f.Cache, new StringWriter())); } [Fact] public void ANullEntryInFillsIsAnLRecipeFault() { using var f = EconomyFixture.Healthy(); f.EditRecipe("commodity:plate", r => r.Fills.Add(null)); Assert.Contains(EconomyChecks.Check(f.Cache).Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains("Fills")); } [Fact] public void FixingOneFaultOnARecordDoesNotHideANewOneThere() { using var f = EconomyFixture.Healthy(); f.EditRecipe("commodity:plate", r => r.Upkeep.Add(null)); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var key = RecipeData.KeyOf(new CultRecordKey(EconomyFixture.Key("plate"))); var code = Author(f, true, out var output, a => { var r = a.Catalog.Get(key); r.Upkeep.RemoveAll(x => x == null); r.Cycle = 0; a.Put(r, key); }); Assert.Equal(1, code); Assert.Contains("Cycle is not a finite number above zero", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } private static EconomyFixture HeldLoop() { var f = EconomyFixture.Healthy(); f.Good("loopa", inputs: new[] { ("loopb", 1) }); f.Good("loopb", inputs: new[] { ("loopa", 2) }); return f; } [Theory] [InlineData("worsen")] [InlineData("move")] [InlineData("move-lower")] [InlineData("grow")] [InlineData("disjoint")] public void AHeldUnproductiveCatalogRefusesABatchThatWorsensMovesOrGrowsTheCycle(string how) { using var f = HeldLoop(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); Action batch = how switch { "worsen" => a => Good(f, a, "loopb", inputs: new[] { ("loopa", 50) }), "move" => a => { Good(f, a, "loopb"); Good(f, a, "loopz", inputs: new[] { ("loopa", 9) }); Good(f, a, "loopa", inputs: new[] { ("loopz", 1) }); }, "move-lower" => a => { Good(f, a, "loopb"); Good(f, a, "loopz", inputs: new[] { ("loopa", 1) }); Good(f, a, "loopa", inputs: new[] { ("loopz", 1) }); }, "grow" => a => { Good(f, a, "loopy", inputs: new[] { ("loopa", 7) }); Good(f, a, "loopa", inputs: new[] { ("loopb", 1), ("loopy", 7) }); }, _ => a => { Good(f, a, "loopc", inputs: new[] { ("loopd", 5) }); Good(f, a, "loopd", inputs: new[] { ("loopc", 5) }); } }; var code = Author(f, true, out var output, batch); Assert.Equal(1, code); Assert.Contains("L-productive", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Theory] [InlineData(false)] [InlineData(true)] public void ANewUnproductiveCycleBelowTheHeldRadiusIsRefusedAloneOrBesideALoweringOfTheHeldCycle(bool alsoLowerHeld) { using var f = EconomyFixture.Healthy(); f.Good("loopa", inputs: new[] { ("loopb", 2) }); f.Good("loopb", inputs: new[] { ("loopa", 4) }); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var code = Author(f, true, out var output, a => { if (alsoLowerHeld) Good(f, a, "loopb", inputs: new[] { ("loopa", 2) }); Good(f, a, "loopc", inputs: new[] { ("loopd", 1) }); Good(f, a, "loopd", inputs: new[] { ("loope", 1) }); Good(f, a, "loope", inputs: new[] { ("loopc", 2) }); }); Assert.Equal(1, code); Assert.Contains("L-productive", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void AHeldCycleWhoseRadiusFallsStillApplies() { using var f = HeldLoop(); var code = Author(f, true, out var output, a => Good(f, a, "loopb", inputs: new[] { ("loopa", 1) })); Assert.Equal(0, code); Assert.Contains("L-productive", output); Assert.Contains("Wrote 1 records", output); } [Fact] public void AHeldUnproductiveCatalogStillAppliesABatchThatLeavesTheCycleAlone() { using var f = HeldLoop(); var code = Author(f, true, out var output, a => Good(f, a, "coal")); Assert.Equal(0, code); Assert.Contains("Wrote 2 records", output); } private static void Seed(EconomyFixture f, string id) { var key = new CultRecordKey(EconomyFixture.Key(id)); f.Cache.Upsert(typeof(SimpleCommodityData), new SimpleCommodityData { Name = id }, key); f.Cache.Upsert(typeof(RecipeData), new RecipeData { Output = EconomyFixture.Item(key.Value), StationKind = f.Mill, LoreHook = "a good", Inputs = new List() }, RecipeData.KeyOf(key)); } [Fact] public void RemovingACommodityAndItsRecipeIsListedAndApplies() { using var f = EconomyFixture.Healthy(); Seed(f, "tar"); var key = new CultRecordKey(EconomyFixture.Key("tar")); var code = Author(f, true, out var output, a => { a.Catalog.Remove(RecipeData.KeyOf(key)); a.Catalog.Remove(key); }); Assert.Equal(0, code); Assert.Contains("removed commodity:tar", output); Assert.Contains("removed recipe:commodity:tar", output); Assert.Contains("2 removed", output); Assert.Contains("Wrote 2 records", output); Assert.DoesNotContain("Nothing to write", output); using var after = Read(f); Assert.Null(after.Get(RecipeData.KeyOf(key))); } [Theory] [InlineData(true)] [InlineData(false)] public void RemovingARecipeAloneIsARefusedLRecipeFault(bool alongsideAnAddition) { using var f = EconomyFixture.Healthy(); Seed(f, "tar"); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); var key = new CultRecordKey(EconomyFixture.Key("tar")); var code = Author(f, true, out var output, a => { a.Catalog.Remove(RecipeData.KeyOf(key)); if (alongsideAnAddition) Good(f, a, "coal"); }); Assert.Equal(1, code); Assert.Contains("L-recipe commodity:tar", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void ANewCommodityWithNoRecipeYetIsStillPending() { using var f = EconomyFixture.Healthy(); var code = Author(f, true, out var output, a => a.Put(new SimpleCommodityData { Name = "coal" }, new CultRecordKey(EconomyFixture.Key("coal")))); Assert.Equal(0, code); Assert.Contains("Wrote 1 records", output); } [Fact] public void AReadOnlyCatalogIsARefusalOnEveryPlatformAndStaysIntact() { using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); File.SetAttributes(f.CatalogPath, FileAttributes.ReadOnly); try { var code = Author(f, true, out var output, a => Good(f, a, "coal")); Assert.Equal(1, code); Assert.Contains("read-only", output); Assert.DoesNotContain(" at ", output); Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } finally { File.SetAttributes(f.CatalogPath, FileAttributes.Normal); } } [Fact] public void ACatalogHeldOpenWithoutDeleteSharingIsANamedRefusalAndStaysIntact() { if (!OperatingSystem.IsWindows()) return; using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); using (new FileStream(f.CatalogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { var code = Author(f, true, out var output, a => Good(f, a, "coal")); Assert.Equal(1, code); Assert.Contains("close Unity", output); Assert.DoesNotContain(" at ", output); } Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(f.CatalogPath), "*.economy-*")); } [Fact] public void ACatalogHeldOpenForWritingIsANamedRefusalAndStaysIntact() { if (!OperatingSystem.IsWindows()) return; using var f = EconomyFixture.Healthy(); Flush(f); var bytes = File.ReadAllBytes(f.CatalogPath); using (new FileStream(f.CatalogPath, FileMode.Open, FileAccess.ReadWrite, FileShare.Read)) { var code = Author(f, true, out var output, a => Good(f, a, "coal")); Assert.Equal(1, code); Assert.Contains("close Unity", output); Assert.DoesNotContain(" at ", output); } Assert.Equal(bytes, File.ReadAllBytes(f.CatalogPath)); } [Fact] public void TheSwapKeepsTheCatalogsFileMode() { if (OperatingSystem.IsWindows()) return; using var f = EconomyFixture.Healthy(); Flush(f); var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead; File.SetUnixFileMode(f.CatalogPath, mode); Assert.Equal(0, Author(f, true, out _, a => Good(f, a, "coal"))); Assert.Equal(mode, File.GetUnixFileMode(f.CatalogPath)); } }