using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using NUnit.Framework; using UnityEditor; using UnityEngine; namespace NightclubArcadia.Skills.Tests { /// /// Guards the voice-sheet pipeline: writing/voices/*.md holds the prose, skill_bible.json /// mirrors it, and the SkillDefinition assets are generated from the JSON. /// /// A writer edits the markdown and runs tools/writing/sync_voices.py --to-json. If they /// forget, or if someone edits the JSON prose directly, the two drift and the shipped /// voice stops matching the sheet the next scene is written against. That is silent and /// expensive, so it fails here instead. /// /// This deliberately does NOT reimplement the markdown parser in C#. It asserts that the /// prose the game ships is present verbatim in the sheet, which catches drift in either /// direction without a second parser to keep in step with the Python one. /// public class VoiceSheetSyncTests { const string DatabasePath = "Assets/Skills/SkillDatabase.asset"; static string VoicesDir => Path.GetFullPath(Path.Combine(Application.dataPath, "..", "..", "writing", "voices")); static string SheetPath(string id) => Path.Combine(VoicesDir, id + ".md"); SkillDatabase LoadDatabase() { var db = AssetDatabase.LoadAssetAtPath(DatabasePath); Assert.IsNotNull(db, $"Expected SkillDatabase at {DatabasePath}"); return db; } /// Files in voices/ that are prose for the writer, not a voice sheet. static bool IsSupportingDoc(string name) => name.StartsWith("_") || string.Equals(name, "README", StringComparison.OrdinalIgnoreCase); /// Collapse whitespace so a hard-wrapped sheet still matches a single-line source. static string Flatten(string s) => Regex.Replace(s ?? string.Empty, @"\s+", " ").Trim(); [Test] public void EverySkill_HasAVoiceSheet() { Assert.IsTrue(Directory.Exists(VoicesDir), $"Expected the voice sheets at {VoicesDir}"); var missing = LoadDatabase().All .Where(s => !File.Exists(SheetPath(s.Id))) .Select(s => s.Id) .ToList(); Assert.IsEmpty(missing, "No voice sheet for: " + string.Join(", ", missing) + ". Run: python3 tools/writing/sync_voices.py --to-md"); } [Test] public void EveryVoiceSheet_HasASkill() { Assert.IsTrue(Directory.Exists(VoicesDir), $"Expected the voice sheets at {VoicesDir}"); var ids = new HashSet(LoadDatabase().All.Select(s => s.Id)); var orphans = Directory.GetFiles(VoicesDir, "*.md") .Select(Path.GetFileNameWithoutExtension) .Where(name => !IsSupportingDoc(name) && !ids.Contains(name)) .ToList(); Assert.IsEmpty(orphans, "Voice sheets with no matching skill id: " + string.Join(", ", orphans)); } [Test] public void VoiceSheets_MatchTheShippedProse() { var problems = new List(); foreach (var skill in LoadDatabase().All) { var path = SheetPath(skill.Id); if (!File.Exists(path)) { continue; // EverySkill_HasAVoiceSheet reports this } var sheet = Flatten(File.ReadAllText(path)); if (!sheet.Contains(Flatten("# " + skill.DisplayName))) { problems.Add($"{skill.Id}: sheet title does not match displayName '{skill.DisplayName}'"); } if (!sheet.Contains(Flatten(skill.Domain))) { problems.Add($"{skill.Id}: the domain text is not in the sheet"); } foreach (var line in VoiceSamples(skill.Notes)) { if (!sheet.Contains(Flatten(line))) { var preview = line.Length > 60 ? line.Substring(0, 60) + "…" : line; problems.Add($"{skill.Id}: voice sample missing from the sheet: {preview}"); } } } Assert.IsEmpty(problems, "writing/voices/*.md has drifted from skill_bible.json:\n " + string.Join("\n ", problems) + "\n\nProse is authored in the markdown. If you changed it there, run:\n" + " python3 tools/writing/sync_voices.py --to-json\n" + "If you changed the JSON directly, move the change into the sheet instead."); } /// The SUCCESS / FAILURE / PASSIVE lines out of a Skill Bible notes blob. static IEnumerable VoiceSamples(string notes) { if (string.IsNullOrEmpty(notes)) { yield break; } foreach (var raw in notes.Split('\n')) { var match = Regex.Match(raw, @"^(?:SUCCESS|FAILURE|PASSIVE) VOICE(?: — .*?)?: (.+)$"); if (match.Success) { yield return match.Groups[1].Value; } } } } }