Reorganize mission menu and separate design from logic
This commit is contained in:
+15
-11
@@ -7,8 +7,10 @@ from pathlib import Path
|
||||
BASE=Path(os.environ.get("ERR_CONVOY_SETTINGS_ROOT", Path(__file__).resolve().parents[1]))
|
||||
from policy_harness import ROOT, body, run
|
||||
source = BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c'
|
||||
fields = source.with_name('ERR_AdminMenuFields.c')
|
||||
navigation = source.with_name('ERR_AdminMenuPages.c')
|
||||
layout = BASE / 'addons/core/UI/layouts/ERR/AdminMenu.layout'
|
||||
keys = re.findall(r'"([A-Za-z]+)"', body(source, 'static array<string> Keys('))
|
||||
keys = re.findall(r'"([A-Za-z]+)"', body(fields, 'static array<string> Keys('))
|
||||
settings = re.findall(r'"([A-Za-z]+)"', body(BASE / 'addons/core/Scripts/Game/ERR/00_Core/ERR_Settings.c', 'static array<string> Keys('))
|
||||
assert len(keys) == len(set(keys)) and set(settings) - set(keys) == {"automaticConvoyEnabled", "keepWeaponOnUnconscious", "selfEpinephrine", "planeReconEnabled", "restartOnEmpty"}
|
||||
assert set(keys) <= set(settings) # Every menu field must have a server setting.
|
||||
@@ -46,9 +48,9 @@ else:
|
||||
names = re.findall(r'\bName "([^"]+)"', layout.read_text())
|
||||
assert 'RandomDay_startTimeMinutes' in names
|
||||
assert 'PlaneOn' in names and 'PlaneOff' in names
|
||||
assert all(n in names for n in ('OpenPlane','PlaneBack','PlanePage','Selected_OpenPlane'))
|
||||
assert all(n in names for n in ('OpenPlane','Sidebar_HQ','PlanePage','Selected_OpenPlane'))
|
||||
for signature in ('static array<string> Pages(', 'static array<string> Navigation('):
|
||||
for n in re.findall(r'"([A-Za-z]+)"', body(source, signature)): assert n in names
|
||||
for n in re.findall(r'"([A-Za-z]+)"', body(navigation, signature)): assert n in names
|
||||
assert 'Apply_planeReconEnabled' not in names # Saved-state toggle pair, not a numeric edit.
|
||||
assert len(names) == len(set(names)), 'Menu widget names must be unique'
|
||||
for key in keys:
|
||||
@@ -59,11 +61,11 @@ for name in ('Close', 'SettingsPage', 'HQPage', 'JournalPage', 'JournalText', 'J
|
||||
assert name in names
|
||||
for name in re.findall(r'"([A-Za-z]+)"', body(source, 'override void OnMenuOpen(').split('foreach')[0]):
|
||||
assert name in names
|
||||
journal = body(source, 'static string JournalText(').replace('ERR_JournalText.Menu', 'ERR_JournalText::Menu')
|
||||
journal = body(fields, 'static string JournalText(').replace('ERR_JournalText.Menu', 'ERR_JournalText::Menu')
|
||||
menu_format = body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_JournalText.c', 'static string Menu(')
|
||||
follow = body(source, 'static bool FollowJournal(')
|
||||
command = body(source, 'static string Command(')
|
||||
available = body(source, 'static bool Available(').replace('Keys().Contains(key)', 'Known(key)')
|
||||
follow = body(fields, 'static bool FollowJournal(')
|
||||
command = body(fields, 'static string Command(')
|
||||
available = body(fields, 'static bool Available(').replace('Keys().Contains(key)', 'Known(key)')
|
||||
known = ' || '.join('key == "' + key + '"' for key in keys)
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
@@ -160,7 +162,7 @@ int main(){
|
||||
}
|
||||
''')
|
||||
# All four rows remain inside the visible Mission page with no overlapping controls.
|
||||
page = body(layout, 'FrameWidgetClass { Name "SettingsPage"')
|
||||
page = body(layout, 'FrameWidgetClass { "Is Visible" 1 Name "SettingsPage"')
|
||||
rectangles = []
|
||||
for line in page.splitlines()[1:]:
|
||||
if not line.startswith(('TextWidgetClass', 'ButtonWidgetClass')):
|
||||
@@ -183,8 +185,10 @@ print('PASS: item editor, preset controls and dynamic item-row layout are struct
|
||||
|
||||
# Run the production selected-state styling: retain selected color at disabled
|
||||
# opacity 1, while unselected controls remain neutral rather than looking active.
|
||||
choice=body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_SelectionStyle.c','static void Choice(').replace('SCR_ButtonTextComponent button','SCR_ButtonTextComponent* button').replace('button.','button->').replace('Color.FromInt','Color::FromInt')
|
||||
run(r'''
|
||||
choice=body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_SelectionStyle.c','static void Choice(').replace('SCR_ButtonTextComponent button','SCR_ButtonTextComponent* button').replace('button.','button->').replace('Color.FromInt','Color::FromInt').replace('ERR_MenuTheme.', 'ERR_MenuTheme::')
|
||||
palette=(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_MenuTheme.c').read_text().split('class ERR_MenuTheme',1)[1]
|
||||
palette='struct ERR_MenuTheme'+palette.replace('static const int', 'static constexpr uint32_t')+';\n'
|
||||
run('#include <cstdint>\n'+palette+r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
struct Color{uint32_t value;static Color FromInt(uint32_t v){return {v};}};
|
||||
@@ -213,7 +217,7 @@ weight_layout=(ROOT/'addons/core/UI/layouts/ERR/ItemWeightRow.layout').read_text
|
||||
validate_layout_structure(weight_layout)
|
||||
weight_names=re.findall(r'\bName "([^"]+)"', weight_layout)
|
||||
assert len(weight_names)==len(set(weight_names))
|
||||
for name in ('EquipmentNavigation','EquipmentOverview','OpenAllowedItems','AllowedItemsPage','OpenItemPresets','ItemPresetsPage','OpenWeights','WeightsPage','WeightsMod','WeightsShow','WeightsPrevious','WeightsNext','ModWeight','ModWeightApply','ModWeightNormal','ModWeightHigh','ModWeightSaved','WeightsChance','WeightsPageState','WeightsContent','WeightsScroll'):
|
||||
for name in ('Sidebar_Items','OpenItems','OpenAllowedItems','AllowedItemsPage','OpenItemPresets','ItemPresetsPage','OpenWeights','WeightsPage','WeightsMod','WeightsShow','WeightsPrevious','WeightsNext','ModWeight','ModWeightApply','ModWeightNormal','ModWeightHigh','ModWeightSaved','WeightsChance','WeightsPageState','WeightsContent','WeightsScroll'):
|
||||
assert name in names, name
|
||||
for name in ('WeightLabel','WeightState','WeightValue','WeightApply','WeightNormal'):
|
||||
assert name in weight_names, name
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Generated UI stays reproducible; design edits cannot silently break includes/navigation."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('menu_builder', ROOT / 'tools/build_admin_menu.py')
|
||||
builder = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(builder)
|
||||
|
||||
|
||||
class MenuSourceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.source = Path(self.temp.name) / 'menu'
|
||||
shutil.copytree(builder.SOURCE, self.source)
|
||||
|
||||
def edit_json(self, name, edit):
|
||||
path = self.source / name
|
||||
data = json.loads(path.read_text())
|
||||
edit(data)
|
||||
path.write_text(json.dumps(data))
|
||||
|
||||
def test_committed_outputs_match_sources_and_widget_ids_are_unique(self):
|
||||
for path, rendered in builder.render().items():
|
||||
self.assertEqual((ROOT / path).read_text(), rendered, str(path))
|
||||
self.assertNotIn('{{', rendered)
|
||||
layout = builder.render()[builder.LAYOUTS / 'AdminMenu.layout']
|
||||
names = re.findall(r'\bName "([^"]+)"', layout)
|
||||
self.assertEqual(len(names), len(set(names)))
|
||||
|
||||
def test_theme_edit_reaches_pages_rows_and_runtime_colors(self):
|
||||
def change(theme):
|
||||
theme['layout']['COLOR_TEXT'] = '1 0.5 0.25 1'
|
||||
theme['buttons']['POSITIVE'] = '0xFF123456'
|
||||
self.edit_json('theme.json', change)
|
||||
rendered = builder.render(self.source)
|
||||
for name in ('AdminMenu.layout', 'ContentRow.layout', 'ItemRow.layout', 'ItemWeightRow.layout'):
|
||||
self.assertIn('Color 1 0.5 0.25 1', rendered[builder.LAYOUTS / name])
|
||||
self.assertNotIn('Color 0.9 0.92 0.94 1', rendered[builder.LAYOUTS / name])
|
||||
self.assertIn('POSITIVE = 0xFF123456', rendered[builder.PALETTE])
|
||||
|
||||
def test_relabel_and_reorder_groups_preserves_stable_page_ids(self):
|
||||
def change(nav):
|
||||
nav['groups'].reverse()
|
||||
nav['pages'][21]['label'] = 'Special forces'
|
||||
self.edit_json('navigation.json', change)
|
||||
rendered = builder.render(self.source)
|
||||
self.assertIn('SARKA = 21', rendered[builder.PAGES])
|
||||
layout = rendered[builder.LAYOUTS / 'AdminMenu.layout']
|
||||
self.assertIn('m_sText "Special forces"', layout)
|
||||
self.assertLess(layout.index('Name "Main_Status"'), layout.index('Name "Main_Mission"'))
|
||||
|
||||
def test_top_tabs_and_sidebar_do_not_overlap_content(self):
|
||||
layout = builder.render()[builder.LAYOUTS / 'AdminMenu.layout']
|
||||
slots = dict(re.findall(r'Name "([^"]+)" Slot FrameWidgetSlot \{([^}]+)\}', layout))
|
||||
def rect(name):
|
||||
slot = slots[name]
|
||||
return [int(re.search(r'\b' + key + r' (-?\d+)', slot)[1])
|
||||
for key in ('PositionX', 'PositionY', 'SizeX', 'SizeY')]
|
||||
nav = json.loads((builder.SOURCE / 'navigation.json').read_text())
|
||||
pages = {p['key']: p for p in nav['pages']}
|
||||
panel_width, panel_height = rect('Panel')[2:]
|
||||
previous_right = 0
|
||||
for group in nav['groups']:
|
||||
x, y, width, height = rect('Main_' + group['name'])
|
||||
self.assertGreaterEqual(x, previous_right)
|
||||
self.assertLessEqual(x + width, panel_width)
|
||||
previous_right = x + width
|
||||
sx, sy, sw, sh = rect('Sidebar_' + group['name'])
|
||||
previous_bottom = 0
|
||||
for key in group['pages']:
|
||||
page = pages[key]
|
||||
px, py, pw, ph = rect(page['widget'])
|
||||
bx, by, bw, bh = rect(page['button'])
|
||||
self.assertLessEqual(y + height, py)
|
||||
self.assertLessEqual(sx + bx + bw, px)
|
||||
self.assertGreaterEqual(by, previous_bottom)
|
||||
self.assertLessEqual(bx + bw, sw)
|
||||
self.assertLessEqual(by + bh, sh)
|
||||
self.assertGreaterEqual(sy + by, py)
|
||||
self.assertLessEqual(sy + by + bh, py + ph)
|
||||
self.assertLessEqual(px + pw, panel_width)
|
||||
self.assertLessEqual(py + ph, panel_height)
|
||||
previous_bottom = by + bh
|
||||
|
||||
def test_editor_initial_visibility_matches_mission_page_even_after_reordering(self):
|
||||
self.edit_json('navigation.json', lambda nav: nav['groups'].reverse())
|
||||
layout = builder.render(self.source)[builder.LAYOUTS / 'AdminMenu.layout']
|
||||
visibility = dict((name, int(value)) for value, name in re.findall(
|
||||
r'WidgetClass \{ "Is Visible" ([01]) Name "([^"]+)"', layout))
|
||||
nav = json.loads((self.source / 'navigation.json').read_text())
|
||||
for page in nav['pages']:
|
||||
self.assertEqual(visibility[page['widget']], int(page['key'] == 'MISSION'))
|
||||
self.assertEqual(visibility['Selected_' + page['button']], int(page['key'] == 'MISSION'))
|
||||
for group in nav['groups']:
|
||||
expected = int('MISSION' in group['pages'])
|
||||
self.assertEqual(visibility['Sidebar_' + group['name']], expected)
|
||||
self.assertEqual(visibility['Selected_Main_' + group['name']], expected)
|
||||
|
||||
def test_unknown_token_missing_include_cycle_and_escape_fail(self):
|
||||
shell = self.source / 'shell.layout.in'
|
||||
for content in ('{{TYPO}}', '{{include:missing.layout.in}}',
|
||||
'{{include:shell.layout.in}}', '{{include:../outside.layout.in}}'):
|
||||
with self.subTest(content=content):
|
||||
shell.write_text(content)
|
||||
with self.assertRaises(ValueError):
|
||||
builder.render(self.source)
|
||||
|
||||
def test_duplicate_membership_missing_page_and_invalid_theme_fail(self):
|
||||
original = (self.source / 'navigation.json').read_text()
|
||||
for change in (lambda n: n['groups'][0]['pages'].append('SARKA'),
|
||||
lambda n: n['groups'][0]['pages'].pop(),
|
||||
lambda n: n['pages'][0].update(id=99)):
|
||||
(self.source / 'navigation.json').write_text(original)
|
||||
self.edit_json('navigation.json', change)
|
||||
with self.assertRaises(ValueError):
|
||||
builder.render(self.source)
|
||||
(self.source / 'navigation.json').write_text(original)
|
||||
self.edit_json('theme.json', lambda t: t['layout'].update(COLOR_TEXT='1 1 1 2'))
|
||||
with self.assertRaises(ValueError):
|
||||
builder.render(self.source)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -58,7 +58,7 @@ helper=body(provider,'protected void ConvoyAutoSetting(')
|
||||
for forbidden in ('StartConvoy','StartScenarioConvoy','Spawn','m_HQ.'):
|
||||
assert forbidden not in helper
|
||||
# Do not advertise this command-only setting as an editable field absent from the actual fixed menu.
|
||||
menu=body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c','static array<string> Keys(')
|
||||
menu=body(ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuFields.c','static array<string> Keys(')
|
||||
assert 'automaticConvoyEnabled' not in menu
|
||||
assert body(provider,'override bool Help(').count('replies.Insert(')<=5
|
||||
print('PASS: actual read migrates old valid settings, preserves explicit disabled state, rejects invalid data; next-round feedback and fixed menu remain honest')
|
||||
|
||||
@@ -4,7 +4,7 @@ from policy_harness import ROOT, body, run
|
||||
p = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_SpeechMenu.c'
|
||||
src = p.read_text()
|
||||
layout = (ROOT / 'addons/core/UI/layouts/ERR/AdminMenu.layout').read_text()
|
||||
menu = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenu.c'
|
||||
menu = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_AdminMenuPages.c'
|
||||
for name in ('OpenTTS', 'TTSPage', 'TTSEndpoint', 'TTSToken', 'TTSTest', 'TTSDisable', 'TTSPasteEndpoint', 'TTSPasteToken', 'TTSStatus', 'TTSTokenStatus', 'TTSSelectionState', 'RecordingsOn', 'RecordingsOff', 'RecordingsLabel'):
|
||||
assert f'Name "{name}"' in layout, name
|
||||
assert '"TTSPage"' in body(menu, 'static array<string> Pages(')
|
||||
|
||||
@@ -17,7 +17,7 @@ def cpp(s):
|
||||
return s
|
||||
settings='\n'.join(cpp(body(S,sig)) for sig in ['static array<string> Keys(','static bool Valid(','static bool Randomizable(','static bool ValidSelection(','static string SelectionText(','static int AddedDefault(','protected bool Read(','static bool Parse(','bool Set('])
|
||||
args='\n'.join(cpp(body(A,sig)) for sig in ['static bool Integer(','static bool Selection('])
|
||||
fields='\n'.join(cpp(body(M,sig)) for sig in ['static string Display(','static string StartSummary('])
|
||||
fields='\n'.join(cpp(body(CORE/'ERR_AdminMenuFields.c',sig)) for sig in ['static string Display(','static string StartSummary('])
|
||||
command=cpp(body(A,'protected bool StartCommand('))
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
|
||||
Reference in New Issue
Block a user