using System; using System.Collections.Generic; using System.Linq; using Sandbox.ModAPI.Ingame; using VRage.Game.GUI.TextPanel; using VRageMath; using Xunit; using P = AutoMiningScript.Program; namespace AutoMiningScript.Tests { public class DisplayLocalizationTests { sealed class IdleRole : P.RoleLogic { readonly string diagnostics; readonly List telemetry; public IdleRole(P program, string text = "", List items = null) : base(program) { diagnostics = text; telemetry = items ?? new List(); } public override string Diagnostics { get { return diagnostics; } } public override List GetTelemetry() { return telemetry; } } sealed class ConsoleRig { public readonly P Program; public readonly P.Dashboard Dashboard; public readonly List Sprites = new List(); public readonly Stub MainSurface; public readonly Stub AuxiliarySurface; public int FrameCount; public readonly Vector2 ViewportSize; public readonly Vector2 TextureSize; public ConsoleRig(string config = "", bool grouped = false, Vector2? viewport = null, Vector2? texture = null) { ViewportSize = viewport ?? new Vector2(512, 512); TextureSize = texture ?? ViewportSize; Program = TestRig.Program(config); MainSurface = Surface(); AuxiliarySurface = Surface(); var me = new Stub() .Set("CubeGrid", Program.Me.CubeGrid).Set("EntityId", 20L) .Set("CustomName", "PB without LCD tag").Set("CustomData", config).Set("SurfaceCount", 2) .Method("GetSurface", a => (int)a[0] == 0 ? MainSurface.Value : AuxiliarySurface.Value); TestRig.SetBase(Program, "Me", me.Value); var terminal = new Stub(); if (grouped) { var group = new Stub().Method("GetBlocks", a => { ((List)a[0]).Add(me.Value); return null; }); terminal.Method("GetBlockGroupWithName", a => group.Value); } TestRig.SetBase(Program, "GridTerminalSystem", terminal.Value); Dashboard = new P.Dashboard(Program); } Stub Surface() { return new Stub().Set("TextureSize", TextureSize) .Set("SurfaceSize", ViewportSize) .Method("MeasureStringInPixels", a => Measure(a[0].ToString(), (float)a[2])) .Method("DrawFrame", a => new MySpriteDrawFrame(frame => { frame.AddToList(Sprites); FrameCount++; })); } public void Draw(P.RoleLogic role = null) { Dashboard.Draw(role ?? new IdleRole(Program)); } public IEnumerable Text { get { return Sprites.Where(s => s.Type == SpriteType.TEXT).Select(s => s.Data); } } } // Deterministic game-font stand-in: CJK glyphs are wider than Latin glyphs. // Assertions use these same surface-provided metrics, not character-count clipping. static Vector2 Measure(string text, float scale) { float width = 0, longest = 0; int lines = 1; foreach (char ch in text) { if (ch == '\n') { longest = Math.Max(longest, width); width = 0; lines++; } else if (ch != '\r') width += ch >= 0x2e80 ? 32 : 16; } return new Vector2(Math.Max(longest, width), lines * 32) * scale; } struct Bounds { public float Left, Top, Right, Bottom; public float Area { get { return (Right - Left) * (Bottom - Top); } } public bool Contains(Bounds other) { const float tolerance = .1f; return other.Left >= Left - tolerance && other.Top >= Top - tolerance && other.Right <= Right + tolerance && other.Bottom <= Bottom + tolerance; } public bool Overlaps(Bounds other) { return Left < other.Right && Right > other.Left && Top < other.Bottom && Bottom > other.Top; } } static Bounds SpriteBounds(MySprite sprite) { Vector2 position = sprite.Position.Value; if (sprite.Type == SpriteType.TEXT) { Vector2 size = Measure(sprite.Data, sprite.RotationOrScale); if (sprite.Alignment == TextAlignment.RIGHT) position.X -= size.X; else if (sprite.Alignment == TextAlignment.CENTER) position.X -= size.X / 2; return new Bounds { Left = position.X, Top = position.Y, Right = position.X + size.X, Bottom = position.Y + size.Y }; } Vector2 half = sprite.Size.Value / 2; return new Bounds { Left = position.X - half.X, Top = position.Y - half.Y, Right = position.X + half.X, Bottom = position.Y + half.Y }; } static string[] ConfigLabels { get { return new[] { P.L.DisplayConfigId, P.L.DisplayConfigFleet, P.L.DisplayConfigBase, P.L.DisplayConfigMass, P.L.DisplayConfigReturnBattery, P.L.DisplayConfigChargeTarget, P.L.DisplayConfigReturnCargo, P.L.DisplayConfigDepartureCargo, P.L.DisplayConfigDrillSpeed, P.L.DisplayConfigHoleRadius, P.L.DisplayConfigSortieDepth, P.L.DisplayConfigTargetOres, P.L.DisplayConfigDepthLimit, P.L.DisplayConfigDockSpeed, P.L.DisplayConfigDockAge, P.L.DisplayConfigWatchdogTimeout, P.L.DisplayConfigFont }; } } internal static int ConfigurationPageCount(int rowsPerPage) { return (ConfigLabels.Length + rowsPerPage - 1) / rowsPerPage; } static Bounds CardContaining(ConsoleRig rig, MySprite label) { Bounds text = SpriteBounds(label); return rig.Sprites.Where(s => s.Type == SpriteType.TEXTURE && s.Data == "SquareSimple") .Select(SpriteBounds).Where(b => b.Contains(text)).OrderBy(b => b.Area).First(); } [Fact] public void UntaggedProgrammingBlockDrawsConfigurationInSelectedLanguage() { var rig = new ConsoleRig("[Flight]\nDepartureMass=13579\n"); rig.Draw(); Assert.Equal(1, rig.Dashboard.ScreenCount); Assert.Equal(1, rig.FrameCount); Assert.Contains(P.L.Brand + " / " + P.Version, rig.Text); Assert.Contains(P.L.DisplayConfiguration, rig.Text); Assert.Contains(rig.Program.Config.Id + " / " + P.L.Language, rig.Text); Assert.Contains(P.L.DisplayConfigMass, rig.Text); Assert.Contains("13579 kg", rig.Text); Assert.Equal(ContentType.SCRIPT, rig.MainSurface.Values["ContentType"]); } [Theory] [InlineData("[Display]\nBuiltIn=false\n")] [InlineData("[AMS.Screen]\nEnabled=false\n")] public void ProgrammingBlockOptOutIsRespected(string config) { var rig = new ConsoleRig(config, true); rig.Draw(); Assert.Equal(0, rig.Dashboard.ScreenCount); Assert.Equal(0, rig.FrameCount); Assert.False(rig.MainSurface.Values.ContainsKey("ContentType")); } [Fact] public void GroupedProgrammingBlockIsDiscoveredOnlyOnce() { var rig = new ConsoleRig("", true); rig.Draw(); Assert.Equal(1, rig.Dashboard.ScreenCount); Assert.Equal(1, rig.FrameCount); } [Fact] public void PerSurfacePageAndFontOverrideBuiltInDefaults() { var rig = new ConsoleRig("[Display]\nBuiltInPage=miner\n[AMS.Screen]\nPage=config\nFont=White\n"); rig.Draw(); Assert.Contains(P.L.DisplayConfigId, rig.Text); Assert.All(rig.Sprites.Where(s => s.Type == SpriteType.TEXT), s => Assert.Equal("White", s.FontId)); } [Fact] public void AuxiliaryProgrammingBlockSurfaceRequiresExplicitOptIn() { var rig = new ConsoleRig("[AMS.Screen.1]\nEnabled=true\nPage=miner\n"); rig.Draw(); Assert.Equal(2, rig.Dashboard.ScreenCount); Assert.Equal(2, rig.FrameCount); Assert.Contains(P.L.F(P.L.DisplayNoTelemetry, P.L.DisplayThisMiner), rig.Text); } [Fact] public void ChineseSurfaceKeysAndPageCommandsControlRealRenderedOutput() { if (!string.Equals(P.L.Language, "zh-CN")) return; var rig = new ConsoleRig("[显示]\n内置屏幕页面=矿机\n[屏幕]\n页面=配置\n字体=等宽\n[屏幕.1]\n启用=否\n"); rig.Draw(); Assert.Equal(1, rig.Dashboard.ScreenCount); Assert.Contains(P.L.DisplayConfigId, rig.Text); Assert.All(rig.Sprites.Where(s => s.Type == SpriteType.TEXT), s => Assert.Equal("Monospace", s.FontId)); rig.Sprites.Clear(); rig.Dashboard.Command("页面 矿机"); rig.Draw(); Assert.Contains(P.L.F(P.L.DisplayNoTelemetry, P.L.DisplayThisMiner), rig.Text); rig.Sprites.Clear(); rig.Dashboard.Command("页面 配置"); for (int n = 1; n < ConfigurationPageCount(4); n++) rig.Dashboard.Command("页面 +"); rig.Draw(); Assert.Contains(P.Settings.ValueName("Display", "Font", "Debug"), rig.Text); } [Fact] public void ChineseScreenValidationReportsFieldAndSkipsOnlyInvalidSurface() { if (!string.Equals(P.L.Language, "zh-CN")) return; var rig = new ConsoleRig("[屏幕]\n启用=也许\n[屏幕.1]\n启用=是\n页面=配置\n"); rig.Draw(); Assert.Equal(1, rig.Dashboard.ScreenCount); Assert.Contains(rig.Program.Events, e => e.Contains("屏幕.启用") && e.Contains("是") && e.Contains("否")); } [Fact] public void PageCommandSelectsConfigurationAndPaginatesParameters() { var rig = new ConsoleRig("[Display]\nBuiltInPage=miner\n"); rig.Dashboard.Command("page config"); rig.Dashboard.Command("page +"); rig.Dashboard.Command("page +"); rig.Draw(); Assert.Contains(P.L.DisplayConfigTargetOres, rig.Text); Assert.Contains(P.L.OreList("Iron,Nickel,Cobalt"), rig.Text); Assert.Contains(P.L.F(P.L.DisplayConfigPages, 3, ConfigurationPageCount(4)), rig.Text); } [Theory] [InlineData("", "5%")] [InlineData("[Cargo]\nDepartureFill=0\n", "0%")] [InlineData("[Cargo]\nDepartureFill=0.12\n", "12%")] public void ConfigurationShowsDepartureCargoThresholdAsLocalizedPercentage(string config, string expected) { var rig = new ConsoleRig(config); rig.Dashboard.Command("page +"); rig.Draw(); var label = Assert.Single(rig.Sprites, s => s.Type == SpriteType.TEXT && s.Data == P.L.DisplayConfigDepartureCargo); Bounds card = CardContaining(rig, label); var value = Assert.Single(rig.Sprites, s => s.Type == SpriteType.TEXT && s.Data != label.Data && card.Contains(SpriteBounds(s))); Assert.Equal(expected, value.Data); } [Theory] [InlineData(512, 512, 1024, 1024, 4)] [InlineData(1024, 512, 2048, 1024, 5)] [InlineData(512, 768, 1024, 1024, 4)] public void ConfigurationUsesVisibleViewportAndReadableRows(int width, int height, int textureWidth, int textureHeight, int rows) { var rig = new ConsoleRig(viewport: new Vector2(width, height), texture: new Vector2(textureWidth, textureHeight)); rig.Draw(); var background = rig.Sprites.Where(s => s.Type == SpriteType.TEXTURE).OrderByDescending(s => SpriteBounds(s).Area).First(); Assert.Equal(rig.ViewportSize, background.Size.Value); Assert.Equal(rig.TextureSize / 2, background.Position.Value); Bounds viewport = SpriteBounds(background); var labels = rig.Sprites.Where(s => s.Type == SpriteType.TEXT && ConfigLabels.Contains(s.Data)).ToArray(); Assert.Equal(rows, labels.Length); float scale = Math.Min(width, height) / 512f; foreach (var label in labels) { Assert.True(label.RotationOrScale >= .8f * scale, "Parameter labels must remain readable."); Bounds card = CardContaining(rig, label); Assert.True(card.Right - card.Left >= width * .85f, "Parameter cards should use the visible screen width."); var value = Assert.Single(rig.Sprites, s => s.Type == SpriteType.TEXT && s.Data != label.Data && card.Contains(SpriteBounds(s))); Assert.True(value.RotationOrScale >= .85f * scale, "Values must not shrink to the old tiny font size."); Assert.False(SpriteBounds(label).Overlaps(SpriteBounds(value)), "Labels and values must have separate space."); } Assert.All(rig.Sprites.Where(s => s.Type == SpriteType.TEXT), s => Assert.True(viewport.Contains(SpriteBounds(s)), "Text outside visible viewport: " + s.Data)); } [Theory] [InlineData(512, 512, 4)] [InlineData(1024, 512, 5)] public void ConfigurationPaginationKeepsEveryParameterReachable(int width, int height, int rowsPerPage) { var rig = new ConsoleRig(viewport: new Vector2(width, height)); var observed = new List(); int pageCount = ConfigurationPageCount(rowsPerPage); for (int page = 0; page < pageCount; page++) { rig.Sprites.Clear(); rig.Draw(); observed.AddRange(rig.Text.Where(t => ConfigLabels.Contains(t))); Assert.Contains(P.L.F(P.L.DisplayConfigPages, page + 1, pageCount), rig.Text); rig.Dashboard.Command("page +"); } Assert.Equal(ConfigLabels, observed); rig.Sprites.Clear(); rig.Draw(); Assert.Contains(P.L.F(P.L.DisplayConfigPages, 1, pageCount), rig.Text); Assert.Contains(P.L.DisplayConfigId, rig.Text); } [Theory] [InlineData(false, 512)] [InlineData(true, 512)] [InlineData(false, 1024)] [InlineData(true, 1024)] public void LongIdentifiersStayInsideTheirCardsWithoutShrinkingOrOverlapping(bool chinese, int width) { string id = new string(chinese ? '矿' : 'W', 64); var rig = new ConsoleRig("[System]\nId=" + id + "\nFleetId=" + id + "\nBaseId=" + id + "\n", viewport: new Vector2(width, 512)); rig.Draw(); foreach (string labelText in ConfigLabels.Take(3)) { var label = Assert.Single(rig.Sprites, s => s.Type == SpriteType.TEXT && s.Data == labelText); Bounds card = CardContaining(rig, label); var value = Assert.Single(rig.Sprites, s => s.Type == SpriteType.TEXT && s.Data != labelText && card.Contains(SpriteBounds(s))); Assert.NotEqual(id, value.Data); Assert.StartsWith(id.Substring(0, 2), value.Data); Assert.True(value.RotationOrScale >= .85f, "Long values must be clipped or wrapped, not made illegibly small."); Assert.False(SpriteBounds(label).Overlaps(SpriteBounds(value))); } } [Fact] public void SurfaceScalingPreservesTextAndSpriteAspectRatio() { var large = new ConsoleRig(viewport: new Vector2(1024, 512)); large.Draw(); var small = new ConsoleRig(viewport: new Vector2(512, 256)); small.Draw(); Assert.Equal(large.Sprites.Count, small.Sprites.Count); for (int index = 0; index < large.Sprites.Count; index++) { var a = large.Sprites[index]; var b = small.Sprites[index]; Assert.Equal(a.Type, b.Type); Assert.Equal(a.Data, b.Data); Assert.Equal(a.Position.Value / 2, b.Position.Value); if (a.Type == SpriteType.TEXT) { Assert.Equal(a.RotationOrScale / 2, b.RotationOrScale); Assert.Null(a.Size); Assert.Null(b.Size); } else Assert.Equal(a.Size.Value / 2, b.Size.Value); } } [Fact] public void ExistingMinerPageRetainsItsCenteredSquareCanvas() { var rig = new ConsoleRig("[Display]\nBuiltInPage=miner\n", viewport: new Vector2(1024, 512), texture: new Vector2(2048, 1024)); rig.Draw(); var background = rig.Sprites.Where(s => s.Type == SpriteType.TEXTURE).OrderByDescending(s => SpriteBounds(s).Area).First(); Assert.Equal(new Vector2(512, 512), background.Size.Value); Assert.Equal(rig.TextureSize / 2, background.Position.Value); Assert.Contains(P.L.F(P.L.DisplayNoTelemetry, P.L.DisplayThisMiner), rig.Text); } [Fact] public void PresentationMappingsPreserveProtocolTokensAndUnknownModdedOre() { foreach (P.FlightState state in Enum.GetValues(typeof(P.FlightState))) Assert.NotEqual(P.L.DisplayUnknown, P.L.State(state)); Assert.Equal(P.L.DisplayOreIron + ":12.3, Unobtainium:4", P.L.OreList("Iron:12.3, Unobtainium:4")); Assert.Equal(P.L.DisplayOutcomeInvalidSample, P.L.Outcome("InvalidSample")); Assert.Equal("CustomOutcome", P.L.Outcome("CustomOutcome")); Assert.Equal("SurveyEmpty", new P.Job { Outcome = "SurveyEmpty" }.Outcome); } [Fact] public void ConfigurationShowsRoleDiagnosticWhenNoSelfTelemetryExists() { var rig = new ConsoleRig(); rig.Draw(new IdleRole(rig.Program, P.L.DisplayStatePaused + "\nDetailed diagnostics")); var sprite = Assert.Single(rig.Sprites, s => s.Data == P.L.F(P.L.DisplayConfigStatus, P.L.DisplayStatePaused)); Assert.Equal(new Color(255, 188, 78), sprite.Color); Assert.DoesNotContain(P.L.F(P.L.DisplayConfigStatus, P.L.DisplayNominal), rig.Text); } [Theory] [InlineData(false)] [InlineData(true)] public void ConfigurationMarksFaultOrOfflineTelemetryRed(bool offline) { var rig = new ConsoleRig(); rig.Program.Now = offline ? 10 : 0; var telemetry = new P.Telemetry { Id = "test", State = offline ? P.FlightState.Ready : P.FlightState.Fault, ReceivedAt = 0 }; rig.Draw(new IdleRole(rig.Program, "", new List { telemetry })); string status = offline ? P.L.DisplayOffline : P.L.DisplayStateFault; var sprite = Assert.Single(rig.Sprites, s => s.Data == P.L.F(P.L.DisplayConfigStatus, status)); Assert.Equal(new Color(255, 102, 117), sprite.Color); } } }