291 lines
14 KiB
C#
291 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
|
|
namespace Ironspore.Editor
|
|
{
|
|
// Editor-only integration checks and real Game View captures, invoked through Unity MCP.
|
|
public static class LocalizationSmoke
|
|
{
|
|
static string Folder;
|
|
static readonly List<Action> steps = new List<Action>();
|
|
static readonly Dictionary<string, byte[]> rangeFiles = new Dictionary<string, byte[]>();
|
|
static Result result;
|
|
static GameSession game;
|
|
static string preference, mainState;
|
|
static bool hadPreference;
|
|
static int initialChassis, step;
|
|
static int[] initialEquipment;
|
|
static float nextAt;
|
|
static VisualElement Root => game.Hud.GetComponent<UIDocument>().rootVisualElement;
|
|
static string MainPath => ProfileStore.SavePath(false);
|
|
static byte[] mainBytes;
|
|
|
|
[Serializable]
|
|
public sealed class Check
|
|
{
|
|
public string name;
|
|
public bool passed;
|
|
}
|
|
[Serializable]
|
|
public sealed class Result
|
|
{
|
|
public bool passed;
|
|
public int stepsCompleted;
|
|
public string error;
|
|
public List<Check> checks = new List<Check>();
|
|
}
|
|
static void CheckThat(string name, bool condition)
|
|
{
|
|
result.checks.Add(new Check { name = name, passed = condition });
|
|
if (!condition)
|
|
throw new InvalidOperationException(name);
|
|
}
|
|
static void Submit(string name)
|
|
{
|
|
var button = Root.Q<Button>(name);
|
|
if (button == null)
|
|
throw new InvalidOperationException("Missing UI button: " + name);
|
|
button.Focus();
|
|
using (var evt = NavigationSubmitEvent.GetPooled())
|
|
{
|
|
evt.target = button;
|
|
button.SendEvent(evt);
|
|
}
|
|
}
|
|
static void Capture(string name)
|
|
{
|
|
ScreenCapture.CaptureScreenshot(Path.GetFullPath(Folder + "/Screenshots/" + name + ".png"));
|
|
}
|
|
static void Key(string key, bool shift = false)
|
|
{
|
|
var input = Event.KeyboardEvent(key);
|
|
if (shift)
|
|
input.modifiers |= EventModifiers.Shift;
|
|
using (var evt = KeyDownEvent.GetPooled(input))
|
|
{
|
|
var target = Root.focusController.focusedElement as VisualElement ?? Root;
|
|
evt.target = target;
|
|
target.SendEvent(evt);
|
|
}
|
|
}
|
|
static void CheckBounds(int width, int height)
|
|
{
|
|
string name = width + "x" + height;
|
|
CheckThat(name + " is the actual render resolution", Screen.width == width && Screen.height == height);
|
|
var outer = Root.worldBound;
|
|
var button = Root.Q<Button>("language-toggle").worldBound;
|
|
CheckThat(name + " button remains bottom-right", button.width > 200 && button.xMin > outer.center.x && button.xMax <= outer.xMax && button.yMax <= outer.yMax && outer.xMax - button.xMax < 80 && outer.yMax - button.yMax < 80);
|
|
}
|
|
public static string Begin(string outputFolder = "Docs/QA/Localization")
|
|
{
|
|
Folder = outputFolder;
|
|
if (!EditorApplication.isPlaying || !GameSession.Active || GameSession.Active.InField)
|
|
throw new InvalidOperationException("Run the localization smoke from the playing hangar.");
|
|
if (result != null)
|
|
throw new InvalidOperationException("Localization smoke is already active.");
|
|
game = GameSession.Active;
|
|
result = new Result();
|
|
hadPreference = PlayerPrefs.HasKey(GameLocalization.PreferenceKey);
|
|
preference = PlayerPrefs.GetString(GameLocalization.PreferenceKey, "");
|
|
mainState = JsonUtility.ToJson(game.Store.Data);
|
|
mainBytes = File.Exists(MainPath) ? File.ReadAllBytes(MainPath) : null;
|
|
initialChassis = game.SelectedChassis;
|
|
initialEquipment = (int[])game.InitialEquipment.Clone();
|
|
rangeFiles.Clear();
|
|
string range = ProfileStore.SavePath(true);
|
|
foreach (string path in new[] { range, range + ".bak", range + ".tmp" })
|
|
rangeFiles[path] = File.Exists(path) ? File.ReadAllBytes(path) : null;
|
|
Directory.CreateDirectory(Folder + "/Screenshots");
|
|
Directory.CreateDirectory(Folder + "/TestResults");
|
|
Application.runInBackground = true;
|
|
SetResolution(1920, 1080);
|
|
game.SelectedChassis = 0;
|
|
game.InitialEquipment[2] = 1;
|
|
var world = GameObject.Find("World");
|
|
for (int i = 0; i < 30; i++)
|
|
GameLocalization.SelectLocale(i % 2 == 0 ? "zh-Hans" : "en", false);
|
|
CheckThat("30 locale changes preserve chassis and loadout", game.SelectedChassis == 0 && game.InitialEquipment[2] == 1);
|
|
CheckThat("30 locale changes do not recreate the world", GameObject.Find("World") == world);
|
|
CheckThat("30 locale changes do not duplicate the language button", Root.Query<Button>("language-toggle").ToList().Count == 1);
|
|
CheckThat("30 locale changes do not mutate save data", mainState == JsonUtility.ToJson(game.Store.Data));
|
|
steps.Clear();
|
|
steps.Add(() => { CheckBounds(1920, 1080); Capture("hangar-en-1080"); });
|
|
steps.Add(() => Submit("language-toggle"));
|
|
steps.Add(() => { CheckThat("Submit opens language menu", Root.Q("language-menu") != null); Capture("language-menu-en"); });
|
|
steps.Add(() => Submit("locale-zh-Hans"));
|
|
steps.Add(() =>
|
|
{
|
|
CheckThat("Menu choice immediately switches and persists", GameLocalization.CurrentCode == "zh-Hans" && PlayerPrefs.GetString(GameLocalization.PreferenceKey) == "zh-Hans");
|
|
CheckThat("Menu closes and restores toggle focus", Root.Q("language-menu") == null && Root.focusController.focusedElement == Root.Q<Button>("language-toggle"));
|
|
Capture("hangar-zh-1080");
|
|
});
|
|
steps.Add(() =>
|
|
{
|
|
Submit("language-toggle");
|
|
using (var evt = KeyDownEvent.GetPooled(Event.KeyboardEvent("escape")))
|
|
{
|
|
evt.target = Root.Q<Button>("locale-zh-Hans");
|
|
Root.SendEvent(evt);
|
|
}
|
|
CheckThat("Escape dismisses the menu", Root.Q("language-menu") == null);
|
|
});
|
|
steps.Add(() => Key("return"));
|
|
steps.Add(() =>
|
|
{
|
|
CheckThat("Enter opens focused language toggle", Root.Q("language-menu") != null);
|
|
Key("tab");
|
|
CheckThat("Tab moves focus to English", Root.focusController.focusedElement == Root.Q<Button>("locale-en"));
|
|
});
|
|
steps.Add(() => Key("space"));
|
|
steps.Add(() =>
|
|
{
|
|
CheckThat("Space selects focused locale", GameLocalization.CurrentCode == "en" && Root.Q("language-menu") == null);
|
|
Key("space");
|
|
});
|
|
steps.Add(() =>
|
|
{
|
|
CheckThat("Space opens focused language toggle", Root.Q("language-menu") != null);
|
|
Key("tab", true);
|
|
CheckThat("Shift Tab moves focus to Chinese", Root.focusController.focusedElement == Root.Q<Button>("locale-zh-Hans"));
|
|
});
|
|
steps.Add(() => Key("return"));
|
|
steps.Add(() => CheckThat("Enter selects focused locale", GameLocalization.CurrentCode == "zh-Hans" && Root.Q("language-menu") == null));
|
|
foreach (var resolution in new[] { new Vector2Int(1280, 720), new Vector2Int(2560, 1440), new Vector2Int(2560, 1080) })
|
|
{
|
|
var size = resolution;
|
|
steps.Add(() => SetResolution(size.x, size.y));
|
|
steps.Add(() => { CheckBounds(size.x, size.y); Capture("hangar-zh-" + size.x + "x" + size.y); });
|
|
}
|
|
steps.Add(() =>
|
|
{
|
|
SetResolution(1920, 1080);
|
|
game.StartTrial();
|
|
game.SetMenu(true);
|
|
game.Store.AddCargo("alloy", 4);
|
|
game.Store.AddCargo("blueprint_coil_capacitor", 1);
|
|
});
|
|
AddPair("hud", game.Hud.ShowField, false);
|
|
AddPair("cargo", game.Hud.ShowCargo, true);
|
|
AddPair("refit", () => game.Hud.ShowPart(2, 1), true);
|
|
AddPair("routes", game.Hud.ShowRoutes, true);
|
|
AddPair("pause", game.Hud.ShowPause, true);
|
|
steps.Add(() => { game.CloseMenu(); game.EndSortie(true); GameLocalization.SelectLocale("zh-Hans", false); });
|
|
steps.Add(() => Capture("report-zh"));
|
|
steps.Add(() => GameLocalization.SelectLocale("en", false));
|
|
steps.Add(() =>
|
|
{
|
|
Capture("report-en");
|
|
CheckThat("Existing report follows locale", game.Report.StartsWith("Driving range complete"));
|
|
CheckThat("Formal inventory and receipts remain unchanged", mainState == JsonUtility.ToJson(game.Store.Data));
|
|
CheckThat("Formal profile file remains byte-for-byte unchanged", mainBytes == null ? !File.Exists(MainPath) : mainBytes.SequenceEqual(File.ReadAllBytes(MainPath)));
|
|
});
|
|
step = 0;
|
|
nextAt = Time.realtimeSinceStartup + .7f;
|
|
EditorApplication.update += Tick;
|
|
return "Localization integration checks and screenshots started; results in about 20 seconds.";
|
|
}
|
|
static void AddPair(string name, Action show, bool overlay)
|
|
{
|
|
steps.Add(() => { GameLocalization.SelectLocale("en", false); show(); });
|
|
steps.Add(() =>
|
|
{
|
|
Capture(name + "-en");
|
|
});
|
|
steps.Add(() =>
|
|
{
|
|
var world = GameObject.Find("World");
|
|
var before = JsonUtility.ToJson(game.Run);
|
|
GameLocalization.SelectLocale("zh-Hans", false);
|
|
CheckThat(name + " switch preserves world and cargo", world == GameObject.Find("World") && before == JsonUtility.ToJson(game.Run));
|
|
CheckThat(name + " switch preserves UI context", overlay == (Root.Q(className: "overlay") != null));
|
|
CheckThat(name + " refreshes existing world signs", UnityEngine.Object.FindObjectsByType<TextMesh>().Any(x => x.text == "撤离点"));
|
|
});
|
|
steps.Add(() => Capture(name + "-zh"));
|
|
}
|
|
static void Tick()
|
|
{
|
|
if (Time.realtimeSinceStartup < nextAt)
|
|
return;
|
|
try
|
|
{
|
|
if (!EditorApplication.isPlaying || !game)
|
|
throw new InvalidOperationException("Play mode ended during localization smoke.");
|
|
if (step >= steps.Count)
|
|
{
|
|
result.passed = true;
|
|
Finish();
|
|
return;
|
|
}
|
|
steps[step++]();
|
|
result.stepsCompleted = step;
|
|
nextAt = Time.realtimeSinceStartup + .55f;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
result.error = ex.ToString();
|
|
Finish();
|
|
}
|
|
}
|
|
static void Finish()
|
|
{
|
|
EditorApplication.update -= Tick;
|
|
if (game)
|
|
{
|
|
if (game.InField && game.Trial)
|
|
game.EndSortie(true);
|
|
game.SelectedChassis = initialChassis;
|
|
game.InitialEquipment = initialEquipment;
|
|
if (hadPreference)
|
|
PlayerPrefs.SetString(GameLocalization.PreferenceKey, preference);
|
|
else
|
|
PlayerPrefs.DeleteKey(GameLocalization.PreferenceKey);
|
|
PlayerPrefs.Save();
|
|
GameLocalization.Initialize(game.localization);
|
|
game.Hud.ShowHangar();
|
|
}
|
|
// Restore only the three explicit trial files, after the trial session has closed.
|
|
foreach (var entry in rangeFiles)
|
|
{
|
|
if (entry.Value != null)
|
|
File.WriteAllBytes(entry.Key, entry.Value);
|
|
else if (File.Exists(entry.Key))
|
|
File.Delete(entry.Key);
|
|
}
|
|
File.WriteAllText(Folder + "/TestResults/Integration.json", JsonUtility.ToJson(result, true));
|
|
result = null;
|
|
}
|
|
public static void SetResolution(int width, int height)
|
|
{
|
|
var assembly = typeof(UnityEditor.Editor).Assembly;
|
|
var type = assembly.GetType("UnityEditor.GameViewSizes");
|
|
var instance = typeof(ScriptableSingleton<>).MakeGenericType(type).GetProperty("instance").GetValue(null);
|
|
var group = type.GetMethod("GetGroup").Invoke(instance, new[] { Enum.ToObject(assembly.GetType("UnityEditor.GameViewSizeGroupType"), 0) });
|
|
int count = (int)group.GetType().GetMethod("GetTotalCount").Invoke(group, null);
|
|
int index = count;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
var existing = group.GetType().GetMethod("GetGameViewSize").Invoke(group, new object[] { i });
|
|
if ((int)existing.GetType().GetProperty("width").GetValue(existing) == width && (int)existing.GetType().GetProperty("height").GetValue(existing) == height)
|
|
{
|
|
index = i;
|
|
break;
|
|
}
|
|
}
|
|
if (index == count)
|
|
{
|
|
var size = Activator.CreateInstance(assembly.GetType("UnityEditor.GameViewSize"), BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, new object[] { Enum.Parse(assembly.GetType("UnityEditor.GameViewSizeType"), "FixedResolution"), width, height, "Ironspore QA " + width + "x" + height }, null);
|
|
group.GetType().GetMethod("AddCustomSize").Invoke(group, new[] { size });
|
|
}
|
|
var view = EditorWindow.GetWindow(assembly.GetType("UnityEditor.GameView"));
|
|
view.GetType().GetProperty("selectedSizeIndex", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).SetValue(view, index);
|
|
view.Focus();
|
|
}
|
|
}
|
|
}
|