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;
}
}
}
}
}