diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0e8e9d1..766bc8f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,12 @@ # This action continuously checks all pushes and Pull requests # for validity, integrity and bugs in datafiles. # For details, visit https://github.com/BSData/check-datafiles -name: CI -on: [ push, pull_request ] +name: Datafile Basic Validity +on: + push: + branches: + - main + pull_request: jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/test-in-new-recruit.yml b/.github/workflows/test-in-new-recruit.yml new file mode 100644 index 00000000..e2ae82e2 --- /dev/null +++ b/.github/workflows/test-in-new-recruit.yml @@ -0,0 +1,28 @@ +name: Test in New Recruit +on: + push: + branches: + - main + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checking out game system to horus-heresy + uses: actions/checkout@v4 + with: + path: horus-heresy + - name: Setting up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Installing package list + run: apt list --installed + # Need to fetch reqs if needed + - name: Installing all necessary packages + run: pip install webdriver-manager selenium + - name: Run tests + run: python3 tests.py + working-directory: horus-heresy/tests/ + env: + DEFAULT_DATA_DIRECTORY: ${{ github.workspace }} diff --git a/.gitignore b/.gitignore index d50b6d4c..6ed22b59 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,9 @@ !/.github !/.github/** +# Don't ignore our tests directory +!/tests +!/tests/** + # Don't ignore .yml for CI build definitions !*.yml diff --git a/README.md b/README.md index 6754cace..000f1fa4 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,24 @@ A .cattemplate file is a .cat file, renamed to .cattemplate, used by [BSCOPY](ht We used bscopy to copy all 18 legions after implementing the first one. We didn't maintain the template so it's not recommended to re-run bscopy +## Tests +GitHub actions will load configured lists in [tests](tests) and ensure they produce the expected outcome. +To add a new test: +1. Export a roster from NewRecruit or BattleScribe +2. Rename that roster from .ros to .test and place it in [tests](tests) +3. Add a new case to [tests.py](tests/tests.py): + ```python + def test_NameOfTest(self): + self.load_list('Name of Roster file with no extension') + errors = self.get_error_list() + self.assertEqual(0, len(errors), "This list has validation errors") + ``` + * There are other tests, such as checking for points on a specific unit. Look through the code for examples. +4. Run the unit tests with python, or create a pull request to have GitHub run them automatically. + * To run them locally, install python and the packages `selenium` and `webdriver-manager`, and Google Chrome. + + + ## References * Horus Heresy: Age of Darkness Rulebook diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 00000000..3b045699 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,6 @@ + +# Ignore .ros files as they break appspot +*.ros + +# Ignore pycache +__pycache__ \ No newline at end of file diff --git a/tests/Basic Marines Validate.test b/tests/Basic Marines Validate.test new file mode 100644 index 00000000..e22ca307 --- /dev/null +++ b/tests/Basic Marines Validate.test @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/tests/Dedicated Transport Squad Costs.test b/tests/Dedicated Transport Squad Costs.test new file mode 100644 index 00000000..4e818506 --- /dev/null +++ b/tests/Dedicated Transport Squad Costs.test @@ -0,0 +1,22 @@ + + \ No newline at end of file diff --git a/tests/Empty Validation Test.test b/tests/Empty Validation Test.test new file mode 100644 index 00000000..f0f37a18 --- /dev/null +++ b/tests/Empty Validation Test.test @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 00000000..acd2d5cb --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,144 @@ +import os +import shutil +import time +import unittest +from pathlib import Path + +from selenium import webdriver +from selenium.common import TimeoutException +from selenium.webdriver.common.by import By +import selenium.webdriver.support.ui as ui +import selenium.webdriver.support.expected_conditions as EC + +from selenium.webdriver.chrome.service import Service as ChromeService +from webdriver_manager.chrome import ChromeDriverManager + + +class GameTests(unittest.TestCase): + debug = False + + def setUp(self): + options = webdriver.ChromeOptions() + if not self.debug: + options.add_argument('--headless') + + driver = webdriver.Chrome( + service=ChromeService(ChromeDriverManager().install()), + options=options) + driver.delete_all_cookies() + self.wait = ui.WebDriverWait(driver, 30) # timeout after 30 seconds + self.driver = driver + driver.get("https://www.newrecruit.eu/app/MySystems") + print("Loading NR") + + driver.execute_script('localStorage.setItem("local", "true")') + # seems to end up running before the system initializes, so we don't need to refresh + + print("Waiting up to 30 seconds for the theme pop-up") + try: + theme_button_elements = self.wait.until(lambda drv: + drv.find_elements(By.XPATH, "//*[text()='Close']")) + if len(theme_button_elements) > 0: + print("Skipping the theme pop-up") + theme_button_elements[0].click() + except TimeoutException: + print("No theme pop-up to skip") + self.load_system('horus-heresy') + + def load_system(self, system_name): + default_data_directory = os.getenv("DEFAULT_DATA_DIRECTORY", os.path.expanduser("~/BattleScribe/data/")) + self.game_directory = str(os.path.join(default_data_directory, system_name)) + # add game system by clicking import + print("Looking for system import") + import_system_buttons = self.wait.until(lambda drv: + drv.find_elements(By.XPATH, "//input[@type='file']")) + if len(import_system_buttons) > 0: + print("Found the system import button") + import_system_buttons[0].send_keys(self.game_directory) + + # Load the 1st system. + import_buttons = self.wait.until(lambda drv: + drv.find_elements(By.CSS_SELECTOR, + "#mainContent > fieldset > div > div > div:nth-child(1)")) + if len(import_buttons) > 0: + print("Loading the first game system") + import_buttons[0].click() + + def load_list(self, roster_name: str): + # add list by clicking import + test_list = os.path.join(self.game_directory, 'tests', roster_name) + shutil.copy(test_list + ".test", test_list + ".ros") + + import_list_element = self.wait.until(lambda drv: + drv.find_elements(By.ID, "importBs") + ) + + if len(import_list_element) > 0: + print("Uploading list to the import list button") + import_list_element[0].send_keys(test_list + ".ros") + + # Load the first list + self.wait.until(EC.element_to_be_clickable((By.CLASS_NAME, "listName"))).click() + print("Loading the first list") + + # Wait until the list has loaded + print("Waiting for the list to load...") + self.wait.until(lambda drv: + drv.find_element(By.CLASS_NAME, 'titreRoster')) + + def tearDown(self): + if self.debug: + # 60 seconds for me to mess around in + time.sleep(60) + self.driver.quit() + + def get_error_list(self): + errors = self.driver.execute_script("return $debugOption.allErrors.map(error => ({" + "msg: error.msg," + "constraint_id:error.constraint.id," + "}))") + if self.debug: + print("$debugOption for list") + print(errors) + return errors + + def get_squad_cost(self, primary_category, unit_name, force_index=0): + script_to_run = (f" $debugOption.state.getChilds()[{force_index}].getChilds()[0].getChilds()" + f".filter(entry => entry.name == '{primary_category}')[0].getChilds()" + f".filter(entry => entry.name == '{unit_name}')[0].totalCosts") + if self.debug: + print(script_to_run) + costs = self.driver.execute_script(f"return {script_to_run}") + if len(costs) == 1: + return list(costs.values())[0] + return costs + + def test_verify_no_ros_files(self): + tests_dir = os.path.join(self.game_directory, 'tests') + for filename in os.listdir(tests_dir): + name, extension = os.path.splitext(filename) + if extension in ["ros", "rosz"]: + if not os.path.exists( + os.path.join(tests_dir, name, ".test")): # If this isn't a copy we made of a .test + self.fail( + "There is a .ros file in the tests directory, which will break appspot." + " Rename the file to .test") + + def test_LA_5_errors(self): + self.load_list('Empty Validation Test') + errors = self.get_error_list() + self.assertEqual(5, len(errors), "There should be 5 errors in an empty space marine list") + + def test_dt_does_not_affect_squad_cost(self): + self.load_list('Dedicated Transport Squad Costs') + squad_cost = self.get_squad_cost("Troops:", "Tactical Support Squad") + self.assertEqual(170, squad_cost, "TSS should not count the rhino as a model") + + def test_NameOfTest(self): + self.load_list('Basic Marines Validate') + errors = self.get_error_list() + self.assertEqual(0, len(errors), "This list has validation errors") + + +if __name__ == '__main__': + unittest.main()