From 769291e093449a424a32d28e9420740ef95a69ed Mon Sep 17 00:00:00 2001 From: redruin1 Date: Thu, 6 Jul 2023 01:24:51 -0400 Subject: [PATCH] moar changes --- README.md | 7 +- draftsman/classes/blueprint.py | 22 +- draftsman/classes/blueprint_book.py | 31 +- draftsman/classes/blueprintable.py | 109 ++-- draftsman/classes/deconstruction_planner.py | 8 + draftsman/classes/entity.py | 2 +- draftsman/classes/exportable.py | 140 +++++ draftsman/classes/mixins/request_filters.py | 10 +- draftsman/classes/upgrade_planner.py | 647 +++++++++----------- draftsman/classes/validatable.py | 3 - draftsman/data/entities.py | 140 +++-- draftsman/data/instruments.py | 11 +- draftsman/data/items.py | 10 +- draftsman/data/mods.py | 2 +- draftsman/data/modules.py | 7 +- draftsman/data/recipes.py | 12 +- draftsman/data/signals.py | 74 +-- draftsman/data/tiles.py | 5 +- draftsman/error.py | 4 +- draftsman/signatures.py | 195 +++++- draftsman/warning.py | 14 +- examples/item_requester.py | 39 ++ examples/revision_history_experiment.py | 36 ++ test/entities/test_decider_combinator.py | 2 +- test/test_blueprint.py | 64 +- test/test_blueprint_book.py | 71 +-- test/test_deconstruction_planner.py | 2 +- test/test_mixins.py | 8 +- test/test_upgrade_planner.py | 616 +++++++++++++++---- 29 files changed, 1475 insertions(+), 816 deletions(-) create mode 100644 draftsman/classes/exportable.py delete mode 100644 draftsman/classes/validatable.py create mode 100644 examples/item_requester.py create mode 100644 examples/revision_history_experiment.py diff --git a/README.md b/README.md index df56cdf..dd9434a 100644 --- a/README.md +++ b/README.md @@ -163,10 +163,9 @@ Allows `draftsman-update` to run on Lua 5.2 instead of Lua 5.4 (which fixes some * Python3-ify everything * More doctests * Make draftsman's prototypes match Factorio's prototypes exactly (for consistency's sake) -* Add documentation on report and contributing -* Write test cases for `dump_format` -* Change type annotations on all functions to follow py3 -* Add plaintext representations of Entity JSON objects for all entities in addition to blueprintables +* Write `__repr__` function for everything +* Write `dump_format` (and test_cases) + * Add plaintext representations of Entity JSON objects for all entities in addition to blueprintables * Update modding documentation guide to reflect 2.0 changes * Add warnings for placement constraints on rails, rail signals and train stops * Reevaluate the diamond diagrams for inherited `Entity` subclass diff --git a/draftsman/classes/blueprint.py b/draftsman/classes/blueprint.py index 8cc7b22..82a9720 100644 --- a/draftsman/classes/blueprint.py +++ b/draftsman/classes/blueprint.py @@ -293,12 +293,22 @@ def label_color(self, value): # type: (dict) -> None if value is None: self._root.pop("label_color", None) - return + else: + self._root["label_color"] = value + def set_label_color(self, r, g, b, a=None): + """ + TODO + """ try: - self._root["label_color"] = signatures.COLOR.validate(value) + if a is None: + self._root["label_color"] = signatures.COLOR.validate([r, g, b]) # TODO + else: + self._root["label_color"] = signatures.COLOR.validate( + [r, g, b, a] + ) # FIXME except SchemaError as e: - six.raise_from(DataFormatError(e), None) + raise DataFormatError from e # ========================================================================= @@ -779,6 +789,12 @@ def recalculate_area(self): " (10,000 x 10,000)".format(self.tile_width, self.tile_height) ) + def validate(self): + """ + TODO + """ + pass + def to_dict(self): # type: () -> dict """ diff --git a/draftsman/classes/blueprint_book.py b/draftsman/classes/blueprint_book.py index 4cf12a8..51e5ddf 100644 --- a/draftsman/classes/blueprint_book.py +++ b/draftsman/classes/blueprint_book.py @@ -89,28 +89,28 @@ def __init__(self, initlist=None, unknown="error"): if "blueprint" in elem: self.append( Blueprint( - elem["blueprint"], + elem, unknown=unknown ) ) elif "deconstruction_planner" in elem: self.append( DeconstructionPlanner( - elem["deconstruction_planner"], + elem, unknown=unknown ) ) elif "upgrade_planner" in elem: self.append( UpgradePlanner( - elem["upgrade_planner"], + elem, unknown=unknown ) ) elif "blueprint_book" in elem: self.append( BlueprintBook( - elem["blueprint_book"], + elem, unknown=unknown ) ) @@ -255,11 +255,22 @@ def label_color(self, value): # type: (dict) -> None if value is None: self._root.pop("label_color", None) - return + else: + self._root["label_color"] = value + + def set_label_color(self, r, g, b, a=None): + """ + TODO + """ try: - self._root["label_color"] = signatures.COLOR.validate(value) + if a is None: + self._root["label_color"] = signatures.COLOR.validate([r, g, b]) # TODO + else: + self._root["label_color"] = signatures.COLOR.validate( + [r, g, b, a] + ) # FIXME except SchemaError as e: - six.raise_from(DataFormatError(e), None) + raise DataFormatError from e # ========================================================================= @@ -335,6 +346,12 @@ def blueprints(self, value): # Utility functions # ========================================================================= + def validate(self): + """ + TODO + """ + pass + def to_dict(self): # type: () -> dict """ diff --git a/draftsman/classes/blueprintable.py b/draftsman/classes/blueprintable.py index 995a334..6f33cbf 100644 --- a/draftsman/classes/blueprintable.py +++ b/draftsman/classes/blueprintable.py @@ -4,6 +4,8 @@ from __future__ import unicode_literals import re +from draftsman.classes.exportable import Exportable +from draftsman.data.signals import signal_dict from draftsman.error import ( IncorrectBlueprintTypeError, DataFormatError, @@ -13,14 +15,14 @@ from abc import ABCMeta, abstractmethod +from functools import cache import json from schema import SchemaError import six from typing import Any, Sequence, Union -@six.add_metaclass(ABCMeta) -class Blueprintable(object): +class Blueprintable(Exportable, metaclass=ABCMeta): """ An abstract base class representing "blueprint-like" objects, such as :py:class:`.Blueprint`, :py:class:`.DeconstructionPlanner`, @@ -37,10 +39,12 @@ def __init__(self, root_item, item, init_data, unknown): Initializes the private ``_root`` data dictionary, as well as setting the ``item`` name. """ + # Init exportable + super().__init__() + # The "root" dict, contains everything inside of this blueprintable # Output format is equivalent to: # { self._root_item: self._root } - self._root = {} self._root_item = six.text_type(root_item) self._root["item"] = six.text_type(item) @@ -91,7 +95,7 @@ def load_from_string(self, string, unknown="error"): self.setup(**root[self._root_item], unknown=unknown) @abstractmethod - def setup(unknown="error", **kwargs): # pragma: no coverage + def setup(self, unknown="error", **kwargs): # pragma: no coverage # type: (str, **dict) -> None """ Setup the Blueprintable's parameters with the input keywords as values. @@ -179,10 +183,8 @@ def label(self, value): # type: (str) -> None if value is None: self._root.pop("label", None) - elif isinstance(value, six.string_types): - self._root["label"] = six.text_type(value) else: - raise TypeError("`label` must be a string or None") + self._root["label"] = value # ========================================================================= @@ -208,10 +210,8 @@ def description(self, value): # type: (str) -> None if value is None: self._root.pop("description", None) - elif isinstance(value, six.string_types): - self._root["description"] = six.text_type(value) else: - raise TypeError("'description' must be a string or None") + self._root["description"] = value # ========================================================================= @@ -255,11 +255,16 @@ def icons(self, value): # type: (list[Union[dict, str]]) -> None if value is None: self._root.pop("icons", None) - return - try: - self._root["icons"] = signatures.ICONS.validate(value) - except SchemaError as e: - six.raise_from(DataFormatError(e), None) + else: + self._root["icons"] = value + + def set_icons(self, *icon_names): + """ + TODO + """ + self.icons = [None] * len(icon_names) + for i, icon in enumerate(icon_names): + self.icons[i] = {"index": i + 1, "signal": signal_dict(icon)} # ========================================================================= @@ -312,12 +317,22 @@ def version(self, value): # type: (Union[int, Sequence[int]]) -> None if value is None: self._root.pop("version", None) - elif isinstance(value, six.integer_types): - self._root["version"] = value - elif isinstance(value, Sequence): - self._root["version"] = utils.encode_version(*value) else: - raise TypeError("'version' must be an int, sequence of ints or None") + self._root["version"] = value + + def set_version(self, major, minor, patch=0, dev_ver=0): + """ + Convenience function for setting a Blueprintable's version by it's + component semantic version numbers. Loose wrapper around + :py:func:`.encode_version`. + + :param major: The major Factorio version. + :param minor: The minor Factorio version. + :param patch: The current patch number. + :param dev_ver: The (internal) development version. + """ + # TODO: use this method in constructor + self._root["version"] = utils.encode_version(major, minor, patch, dev_ver) # ========================================================================= # Utility functions @@ -385,54 +400,14 @@ def version_string(self): version_tuple = utils.decode_version(self._root["version"]) return utils.version_tuple_to_string(version_tuple) - @abstractmethod - def to_dict(self): # pragma: no coverage - # type: () -> dict - """ - Returns the blueprintable as a dictionary. Intended for getting the - precursor to a Factorio blueprint string before encoding and compression - takes place. - - :returns: The ``dict`` representation of the :py:class:`.Blueprintable`. - """ - pass - - def to_string(self): # pragma: no coverage - # type: () -> str - """ - Returns this object as an encoded Factorio blueprint string. - - :returns: The zlib-compressed, base-64 encoded string. - - :example: - - .. doctest:: - - >>> from draftsman.blueprintable import ( - ... Blueprint, DeconstructionPlanner, UpgradePlanner, BlueprintBook - ... ) - >>> Blueprint({"version": (1, 0)}).to_string() - '0eNqrVkrKKU0tKMrMK1GyqlbKLEnNVbJCEtNRKkstKs7Mz1OyMrIwNDE3sTQ3Mzc0MDM1q60FAHmVE1M=' - >>> DeconstructionPlanner({"version": (1, 0)}).to_string() - '0eNpdy0EKgCAQAMC/7Nkgw7T8TIQtIdga7tol/HtdunQdmBs2DJlYSg0SMy1nWomwgL+BUSTSzuCppqQgCh7gf6H7goILC78Cfpi0cWZ21unejra1B7C2I9M=' - >>> UpgradePlanner({"version": (1, 0)}).to_string() - '0eNo1yksKgCAUBdC93LFBhmm5mRB6iGAv8dNE3Hsjz/h0tOSzu+lK0TFThu0oVGtgX2C5xSgQKj2wcy5zCnyUS3gZdjukMuo02shV73qMH4ZxHbs=' - >>> BlueprintBook({"version": (1, 0)}).to_string() - '0eNqrVkrKKU0tKMrMK4lPys/PVrKqVsosSc1VskJI6IIldJQSk0syy1LjM/NSUiuUrAx0lMpSi4oz8/OUrIwsDE3MTSzNzcwNDcxMzWprAVWGHQI=' - """ - return utils.JSON_to_string(self.to_dict()) - - def __setitem__(self, key, value): - # type: (str, Any) -> None - self._root[key] = value - - def __getitem__(self, key): - # type: (str) -> Any - return self._root[key] + # def validate(self): + # # type: () -> bool + # self.label = signatures.Label.validate(self.label) + # self.description = signatures.Description.validate(self.description) + # self.icons = signatures.Icons.validate(self.icons) + # self.version = signatures.Version.validate(self.version) - def __contains__(self, item): - # type: (str) -> bool - return item in self._root + # super().validate() def __str__(self): # pragma: no coverage # type: () -> str diff --git a/draftsman/classes/deconstruction_planner.py b/draftsman/classes/deconstruction_planner.py index e6a6fb3..1e8bd2e 100644 --- a/draftsman/classes/deconstruction_planner.py +++ b/draftsman/classes/deconstruction_planner.py @@ -358,6 +358,14 @@ def set_tile_filter(self, index, name): # Otherwise its unique; add to list self.tile_filters.append({"index": index + 1, "name": name}) + # ========================================================================= + + def validate(self): + """ + TODO + """ + pass + def to_dict(self): out_dict = copy.deepcopy(self._root) diff --git a/draftsman/classes/entity.py b/draftsman/classes/entity.py index f5e3685..d17383a 100644 --- a/draftsman/classes/entity.py +++ b/draftsman/classes/entity.py @@ -231,7 +231,7 @@ def __init__(self, name, similar_entities, tile_position=[0, 0], **kwargs): self.tile_position = tile_position # Entity tags - self.tags = None + self.tags = {} if "tags" in kwargs: self.tags = kwargs["tags"] self.unused_args.pop("tags") diff --git a/draftsman/classes/exportable.py b/draftsman/classes/exportable.py new file mode 100644 index 0000000..9ec7d40 --- /dev/null +++ b/draftsman/classes/exportable.py @@ -0,0 +1,140 @@ +# exportable.py + +from draftsman import utils + +from abc import ABCMeta, abstractmethod +from typing import List, Any +import warnings +import pprint # TODO: think about + + +class ValidationResult: + def __init__(self, error_list: List[Exception], warning_list: List[Warning]): + self.error_list: List[Exception] = error_list + self.warning_list: List[Warning] = warning_list + + def reissue_all(self): + for error in self.error_list: + raise error + for warning in self.warning_list: + warnings.warn(warning, stacklevel=2) + + def __eq__(self, other): # pragma: no coverage + # Primarily for test suite. + if not isinstance(other, ValidationResult): + return False + if len(self.error_list) != len(other.error_list) or len( + self.warning_list + ) != len(other.warning_list): + return False + for i in range(len(self.error_list)): + if ( + type(self.error_list[i]) != type(other.error_list[i]) + or self.error_list[i].args != other.error_list[i].args + ): + return False + for i in range(len(self.warning_list)): + if ( + type(self.warning_list[i]) != type(other.warning_list[i]) + or self.warning_list[i].args != other.warning_list[i].args + ): + return False + return True + + def __str__(self): # pragma: no coverage + return "ValidationResult{{\n errors={}, \n warnings={}\n}}".format( + pprint.pformat(self.error_list, indent=4), + pprint.pformat(self.warning_list, indent=4), + ) + + def __repr__(self): # pragma: no coverage + return "ValidationResult{{errors={}, warnings={}}}".format( + self.error_list, self.warning_list + ) + + +class Exportable(metaclass=ABCMeta): + """ + TODO + """ + + def __init__(self): + self._root = {} + self._is_valid = False + + def __setattr__(self, name, value): + super().__setattr__("_is_valid", False) + super().__setattr__(name, value) + + @property + def is_valid(self): + """ + TODO + """ + return self._is_valid + + @abstractmethod + def validate(self): + """ + TODO + """ + # Subsequent objects must implement this method and then call this + # parent method to cache successful validity + super().__setattr__("_is_valid", True) + + def inspect(self): + """ + TODO + """ + return ValidationResult([], []) + + @abstractmethod + def to_dict(self): # pragma: no coverage + # type: () -> dict + """ + Returns this object as a dictionary. Intended for getting the precursor + to a Factorio blueprint string before encoding and compression takes + place. + + :returns: The ``dict`` representation of this object. + """ + pass + + def to_string(self): # pragma: no coverage + # type: () -> str + """ + Returns this object as an encoded Factorio blueprint string. + + :returns: The zlib-compressed, base-64 encoded string. + + :example: + + .. doctest:: + + >>> from draftsman.blueprintable import ( + ... Blueprint, DeconstructionPlanner, UpgradePlanner, BlueprintBook + ... ) + >>> Blueprint({"version": (1, 0)}).to_string() + '0eNqrVkrKKU0tKMrMK1GyqlbKLEnNVbJCEtNRKkstKs7Mz1OyMrIwNDE3sTQ3Mzc0MDM1q60FAHmVE1M=' + >>> DeconstructionPlanner({"version": (1, 0)}).to_string() + '0eNpdy0EKgCAQAMC/7Nkgw7T8TIQtIdga7tol/HtdunQdmBs2DJlYSg0SMy1nWomwgL+BUSTSzuCppqQgCh7gf6H7goILC78Cfpi0cWZ21unejra1B7C2I9M=' + >>> UpgradePlanner({"version": (1, 0)}).to_string() + '0eNo1yksKgCAUBdC93LFBhmm5mRB6iGAv8dNE3Hsjz/h0tOSzu+lK0TFThu0oVGtgX2C5xSgQKj2wcy5zCnyUS3gZdjukMuo02shV73qMH4ZxHbs=' + >>> BlueprintBook({"version": (1, 0)}).to_string() + '0eNqrVkrKKU0tKMrMK4lPys/PVrKqVsosSc1VskJI6IIldJQSk0syy1LjM/NSUiuUrAx0lMpSi4oz8/OUrIwsDE3MTSzNzcwNDcxMzWprAVWGHQI=' + """ + # TODO: add options to compress/canoncialize blueprints before export + # (though should that happen on a per-blueprintable basis? And what about non-Blueprint strings, like upgrade planners?) + return utils.JSON_to_string(self.to_dict()) + + def __setitem__(self, key, value): + # type: (str, Any) -> None + self._root[key] = value + + def __getitem__(self, key): + # type: (str) -> Any + return self._root[key] + + def __contains__(self, item): + # type: (str) -> bool + return item in self._root diff --git a/draftsman/classes/mixins/request_filters.py b/draftsman/classes/mixins/request_filters.py index e5ad4da..ad9afe3 100644 --- a/draftsman/classes/mixins/request_filters.py +++ b/draftsman/classes/mixins/request_filters.py @@ -67,8 +67,8 @@ def set_request_filter(self, index, item, count=None): except SchemaError as e: six.raise_from(TypeError(e), None) - if item is not None and item not in items.raw: - raise InvalidItemError("'{}'".format(item)) + # if item is not None and item not in items.raw: + # raise InvalidItemError("'{}'".format(item)) if not 0 <= index < 1000: raise IndexError("Filter index ({}) not in range [0, 1000)".format(index)) if count is None: # default count to the item's stack size @@ -115,9 +115,9 @@ def set_request_filters(self, filters): six.raise_from(DataFormatError(e), None) # Make sure the items are items - for item in filters: - if item["name"] not in items.raw: - raise InvalidItemError(item["name"]) + # for item in filters: + # if item["name"] not in items.raw: + # raise InvalidItemError(item["name"]) self.request_filters = [] for i in range(len(filters)): diff --git a/draftsman/classes/upgrade_planner.py b/draftsman/classes/upgrade_planner.py index b9f23f8..33a5013 100644 --- a/draftsman/classes/upgrade_planner.py +++ b/draftsman/classes/upgrade_planner.py @@ -1,104 +1,100 @@ # upgrade_planner.py -# -*- encoding: utf-8 -*- """ .. code-block:: python - { - "upgrade_planner": { - "item": "upgrade-planner", # The associated item with this structure - "label": str, # A user given name for this upgrade planner - "version": int, # The encoded version of Factorio this planner was created - # with/designed for (64 bits) - "settings": { - "mappers": [ # List of dicts, each one a "mapper" - { - "from": { # The from entity/item. If this key is omitted, it appears blank in-game - "name": str, # The name of a valid replacable entity/item - "type": "entity" or "item" # Depending on name - }, - "to": { # The to entity/item. If this key is omitted, it appears blank in-game - "name": str, # The name of a different, corresponding entity/item - "type": "entity" or "item" # Depending on name - }, - "index": u64 # in range [1, max_mappers as defined in prototypes] - }, - .. # Up to 24 mappers total for default upgrade planner - ], - "description": str, # A user given description for this upgrade planner - "icons": [ # A set of signals to act as visual identification - { - "signal": {"name": str, "type": str}, # Name and type of signal - "index": int, # In range [1, 4], starting top-left and moving across and down - }, - ... # Up to 4 icons total - ] - } - } - } + >>> from draftsamn.blueprintable import UpgradePlanner + >>> UpgradePlanner.Format.schema_json(indent=4) """ from __future__ import unicode_literals from draftsman import __factorio_version_info__ from draftsman.classes.blueprintable import Blueprintable +from draftsman.classes.exportable import ValidationResult from draftsman.data import entities, items, modules from draftsman.error import DataFormatError from draftsman import signatures from draftsman import utils from draftsman.warning import ( - ItemLimitationWarning, - ValueWarning, IndexWarning, DraftsmanWarning, RedundantOperationWarning, - UnrecognizedElementWarning + UnrecognizedElementWarning, ) +from functools import cached_property +import bisect import copy import fastjsonschema -from schema import SchemaError +from pydantic import BaseModel, Extra, Field, validator +from schema import Schema, Optional, SchemaError import six from typing import Union, Sequence import warnings -def check_valid_upgrade_pair(from_obj, to_obj): +def check_valid_upgrade_pair( + from_obj: dict | None, to_obj: dict | None +) -> list[Warning]: """ - Checks two :py:data:`MAPPING_ID` objects to see if it's possible for + Checks two :py:data:`MAPPING_ID` objects to see if it's possible for ``from_obj`` to upgrade into ``to_obj``. - :param from_obj: A ``dict`` containing a ``"name"`` and ``"type"`` key. - :param to_obj: A ``dict`` containing a ``"name"`` and ``"type"`` key. + :param from_obj: A ``dict`` containing a ``"name"`` and ``"type"`` key, or + ``None`` if that entry was null. + :param to_obj: A ``dict`` containing a ``"name"`` and ``"type"`` key , or + ``None`` if that entry was null. - :returns: A Warning object with the reason why the mapping would be invalid, - or ``None`` if no reason could be found. + :returns: A list of one or more Warning objects containing the reason why + the mapping would be invalid, or ``None`` if no reason could be deduced. """ - # If both from and to are the same, the game will allow it; but the GUI - # prevents the user from doing it and it ends up being functionally useless, - # so we warn the user since this is likely not intentional - if from_obj["name"] == to_obj["name"]: - return RedundantOperationWarning( - False, - "Mapping entity/item '{}' to itself has no effect".format(from_obj["name"]), - ) - - # Next we need to check if Draftsman even recognizes both from and to, + # First we need to check if Draftsman even recognizes both from and to, # because if not then Draftsman cannot possibly expect to know whether the # upgrade pair is valid or not; hence, we early exit with a simple # "unrecognized entity/item" warning: - # FIXME: technically this will only issue one warning if both are unrecognized - # FIXME: technically this will only issue warnings if both to and from are defined, - # so it probably makes sense to move this to inspect() - if from_obj["name"] not in entities.raw and from_obj["name"] not in items.raw: - return UnrecognizedElementWarning( - "Unrecognized entity/item '{}'".format(from_obj["name"]) + unrecognized = [] + if ( + from_obj is not None + and from_obj["name"] not in entities.raw + and from_obj["name"] not in items.raw + ): + unrecognized.append( + UnrecognizedElementWarning( + "Unrecognized entity/item '{}'".format(from_obj["name"]) + ) ) - if to_obj["name"] not in entities.raw and to_obj["name"] not in items.raw: - return UnrecognizedElementWarning( - "Unrecognized entity/item '{}'".format(to_obj["name"]) + if ( + to_obj is not None + and to_obj["name"] not in entities.raw + and to_obj["name"] not in items.raw + ): + unrecognized.append( + UnrecognizedElementWarning( + "Unrecognized entity/item '{}'".format(to_obj["name"]) + ) ) + if unrecognized: + return unrecognized + + # If one (or both) of from and to are empty, then there's also no reason to + # check if a mapping between them is valid because there's simply not + # enough information + if from_obj is None or to_obj is None: + return None + + # If both from and to are the same, the game will allow it; but the GUI + # prevents the user from doing it and it ends up being functionally useless, + # so we warn the user since this is likely not intentional + if from_obj == to_obj: + return [ + RedundantOperationWarning( + "Mapping entity/item '{}' to itself has no effect".format( + from_obj["name"] + ) + ) + ] # To quote Entity prototype documentation for the "next_upgrade" key: # > "This entity may not have 'not-upgradable' flag set and must be @@ -114,25 +110,27 @@ def check_valid_upgrade_pair(from_obj, to_obj): # from must be upgradable if "not-upgradable" in from_entity.get("flags", set()): - return DraftsmanWarning("'{}' is not upgradable".format(from_obj["name"])) + return [DraftsmanWarning("'{}' is not upgradable".format(from_obj["name"]))] # from must be minable if not from_entity.get("minable", False): - return DraftsmanWarning("'{}' is not minable".format(from_obj["name"])) + return [DraftsmanWarning("'{}' is not minable".format(from_obj["name"]))] # Mining results from the upgrade must not be hidden if "results" in from_entity["minable"]: - mined_items = from_entity["minable"]["results"] + mined_items = [r["name"] for r in from_entity["minable"]["results"]] else: - mined_items = from_entity["minable"]["result"] + mined_items = [from_entity["minable"]["result"]] # I assume that it means ALL of the items have to be not hidden for mined_item in mined_items: if "hidden" in items.raw[mined_item].get("flags", set()): - return DraftsmanWarning( - "Returned item '{}' when mining '{}' is hidden".format( - mined_item, from_obj["name"] - ), - ) + return [ + DraftsmanWarning( + "Returned item '{}' when upgrading '{}' is hidden".format( + mined_item, from_obj["name"] + ), + ) + ] # Cannot upgrade rolling stock (train cars) if from_entity["type"] in { @@ -141,111 +139,79 @@ def check_valid_upgrade_pair(from_obj, to_obj): "fluid-wagon", "artillery-wagon", }: - return DraftsmanWarning( - "Cannot upgrade '{}' because it is RollingStock".format(from_obj["name"]), - ) + return [ + DraftsmanWarning( + "Cannot upgrade '{}' because it is RollingStock".format( + from_obj["name"] + ), + ) + ] # Collision boxes must match (assuming None is valid) if from_entity.get("collision_box", None) != to_entity.get("collision_box", None): - return DraftsmanWarning( - "Cannot upgrade '{}' to '{}'; collision boxes differ".format( - from_obj["name"], to_obj["name"] - ), - ) + return [ + DraftsmanWarning( + "Cannot upgrade '{}' to '{}'; collision boxes differ".format( + from_obj["name"], to_obj["name"] + ), + ) + ] # Collision masks must match (assuming None is valid) if from_entity.get("collision_mask", None) != to_entity.get("collision_mask", None): - return DraftsmanWarning( - "Cannot upgrade '{}' to '{}'; collision masks differ".format( - from_obj["name"], to_obj["name"] - ), - ) + return [ + DraftsmanWarning( + "Cannot upgrade '{}' to '{}'; collision masks differ".format( + from_obj["name"], to_obj["name"] + ), + ) + ] # Fast replacable groups must match (assuming None is valid) ffrg = from_entity.get("fast_replaceable_group", None) tfrg = to_entity.get("fast_replaceable_group", None) if ffrg != tfrg: - return DraftsmanWarning( - "Cannot upgrade '{}' to '{}'; fast replacable groups differ".format( - from_obj["name"], to_obj["name"] - ), - ) + return [ + DraftsmanWarning( + "Cannot upgrade '{}' to '{}'; fast replacable groups differ".format( + from_obj["name"], to_obj["name"] + ), + ) + ] # Otherwise, we must conclude that the mapping makes sense return None -signal_schema = { - "$id": "SIGNAL_DICT", - "title": "Signal dict", - "description": "JSON object that represents a signal. Used in the circuit network, but also used for blueprintable icons.", - "type": "object", - "properties": { - "name": { - "description": "Must be a name recognized by Factorio, or will error on import.", - "type": "string" - }, - "type": { - "description": "Must be one of the following values, or will error on import.", - "type": "string", - "enum": ["item", "fluid", "virtual"] - } - }, - "required": ["name", "type"], - "additionalProperties": False -} - -mapper_schema = { - "$id": "MAPPER_DICT", - "title": "Mapper dict", - "description": "JSON object that represents a mapper. Used in Upgrade Planners to describe their function.", - "type": "object", - "properties": { - "name": { - "description": "Must be a name recognized by Factorio, or will error on import.", - "type": "string" - }, - "type": { - "description": "Must be one of the following values, or will error on import. Item refers to modules, entity refers to everything else (as far as I've investigated; modded objects might change this behavior, but I have yet to find out)", - "type": "string", - "enum": ["item", "entity"] - } - }, - "required": ["name", "type"], - "additionalProperties": False -} - -icons_schema = { - "$id": "ICONS_ARRAY", - "title": "Icons list", - "description": "Format of the list of signals used to give blueprintable objects unique appearences. Only a maximum of 4 entries are allowed; indicies outside of the range [1, 4] will return 'Index out of bounds', and defining multiple icons that use the same index returns 'Icon already specified'.", - "type": "array", - "items": { - "type": "object", - "properties": { - "signal": { - "description": "Which signal icon to use.", - "$ref": "factorio-draftsman://SIGNAL_DICT" - }, - "index": { - "description": "What index to place the signal icon, 1-indexed.", - "type": "integer", - "minimum": 1, - "maximum": 4 - } - }, - "required": ["signal", "index"], - "additionalProperties": False - }, - "maxItems": 4 -} - -def _draftsman_uri_handler(item): - mapping = { - "factorio-draftsman://SIGNAL_DICT": signal_schema, - "factorio-draftsman://MAPPER_DICT": mapper_schema, - "factorio-draftsman://ICONS_ARRAY": icons_schema, - } - return mapping[item] + +class UpgradePlannerModel(BaseModel): + """ + TODO + Upgrade planner object schema. + """ + + item: str = Field("upgrade-planner", const=True) + label: signatures.Label = None + version: signatures.Version = None + + class Settings(BaseModel): + """ + TODO + """ + + description: signatures.Description = None + icons: signatures.Icons = None + mappers: signatures.Mappers = None + + settings: Settings = {} + + class Config: + extra = Extra.forbid + + @validator("item") + def correct_item(cls, v): + assert v == "upgrade-planner" + return v + class UpgradePlanner(Blueprintable): """ @@ -253,77 +219,18 @@ class UpgradePlanner(Blueprintable): items. """ - schema = { - "title": "Upgrade Planner Format", - "description": "The explicit format of a valid Upgrade Planner JSON dict.", - "type": "object", - "properties": { - "upgrade_planner": { - "description": "Root entry in the format.", - "type": "object", - "properties": { - "item": { - "description": "The associated item with this structure.", - "type": "string" - }, - "label": { - "description": "A user given name for this upgrade planner", - "type": "string" - }, - "version": { - "description": "The encoded version of Factorio this planner was created with/designed for.", - "type": "integer", - "minimum": 0, - "exclusiveMaximum": 2**64, - }, - "settings": { - "description": "Information relating to mappings, as well as additional descriptors.", - "type": "object", - "properties": { - "description": { - "description": "A user given description for this upgrade planner. Don't ask me why this is in the settings object.", - "type": "string" - }, - "icons": { - "description": "A set of signals to act as visual identification. Don't ask me why this is in the settings object.", - "$ref": "factorio-draftsman://ICONS_ARRAY" - }, - "mappers": { - "description": "A list of mapping objects that describe from what entity to upgrade and what entity to upgrade to.", - "type": "array", - "items": { - "description": "A single 'mapper' object.", - "type": "object", - "properties": { - "from": { - "$ref": "factorio-draftsman://MAPPER_DICT" - }, - "to": { - "$ref": "factorio-draftsman://MAPPER_DICT" - }, - "index": { - "description": "The location in the upgrade planner to display the mapping, 0-indexed. If the index is greater than or equal to the max mappers for this Upgrade Planner (24 by default) then such entries will be ignored when imported.", - "type": "integer", - "minimum": 0, - "exclusiveMaximum": 2**64, - } - }, - "required": ["index"], - "additionalProperties": False, - } - } - }, - "additionalProperties": False - } - }, - "required": ["item"], - "additionalProperties": False - } - }, - "required": ["upgrade_planner"], - "additionalProperties": False - } - validator = fastjsonschema.compile(schema, handlers={"factorio-draftsman": _draftsman_uri_handler}) + class Format(BaseModel): + """ + The full description of UpgradePlanner's formal schema. + """ + + upgrade_planner: UpgradePlannerModel + + class Config: + title = "UpgradePlanner" + extra = Extra.forbid + + # ========================================================================= @utils.reissue_warnings def __init__(self, upgrade_planner=None, unknown="error"): @@ -398,17 +305,9 @@ def icons(self): def icons(self, value): # type: (list[Union[dict, str]]) -> None if value is None: - try: - self._root["settings"].pop("icons", None) - except KeyError: - pass + self._root["settings"].pop("icons", None) else: - try: - self._root["settings"]["icons"] = signatures.ICONS.validate( - value - ) # TODO: remove - except SchemaError as e: - six.raise_from(DataFormatError(e), None) + self._root["settings"]["icons"] = value # ========================================================================= @@ -426,13 +325,14 @@ def mapper_count(self): @property def mappers(self): - # type: () -> list + # type: () -> list[dict] """ The list of mappings of one entity or item type to the other entity or item type. - :raises DataFormatError: If setting this attribute and any of the - entries in the list do not match the format specified above. + Using :py:meth:`.set_mapping()` will attempt to keep this list sorted + by each mapping's internal ``"index"``, but outside of this function + this behavior is not required or enforced. :getter: Gets the mappers dictionary, or ``None`` if not set. :setter: Sets the mappers dictionary, or deletes the dictionary if set @@ -445,23 +345,9 @@ def mappers(self): def mappers(self, value): # type: (Union[list[dict], list[tuple]]) -> None if value is None: - del self._root["settings"]["mappers"] - return - - self._root["settings"]["mappers"] = value - - # self._root["settings"]["mappers"] = [] - # for i, mapper in enumerate(value): - # if isinstance(mapper, dict): - # self.set_mapping( - # mapper.get("from", None), mapper.get("to", None), mapper["index"] - # ) - # elif isinstance(mapper, Sequence): - # self.set_mapping(mapper[0], mapper[1], i) - # else: - # raise DataFormatError( - # "{} cannot be resolved to a UpgradePlanner Mapping" - # ) + self._root["settings"].pop("mappers", None) + else: + self._root["settings"]["mappers"] = value # ========================================================================= @@ -469,129 +355,170 @@ def mappers(self, value): def set_mapping(self, from_obj, to_obj, index): # type: (Union[str, dict], Union[str, dict], int) -> None """ - Sets a single mapping in the :py:class:`.UpgradePlanner`. Setting both - ``from_obj`` and ``to_obj`` to ``None`` will remove all mapping entries - at ``index`` (if there is one or more at that index). - - :param from_obj: The :py:data:`.SIGNAL_ID` to convert entities/items + Sets a single mapping in the :py:class:`.UpgradePlanner`. Setting + multiple mappers at the same index overwrites the entry at that index + with the last set value. Both ``from_obj`` and ``to_obj`` can be set to + ``None`` which will create an unset mapping (and the resulting spot on + the in-game GUI will be blank). + + This function will also attempt to keep the list sorted by each mapper's + ``index`` key. This behavior is not enforced anywhere else, and if the + :py:attr:`.mappers` list is ever made unsorted, calling this function + does not resort said list and does not guarantee a correct sorted result. + + :param from_obj: The :py:data:`.MAPPING_ID` to convert entities/items from. Can be set to ``None`` which will leave it blank. - :param to_obj: The :py:data:`.SIGNAL_ID` to convert entities/items to. + :param to_obj: The :py:data:`.MAPPING_ID` to convert entities/items to. Can be set to ``None`` which will leave it blank. :param index: The location in the upgrade planner's mappers list. """ - # Check types of all parameters (SIGNAL_ID, SIGNAL_ID, int) - try: - from_obj = signatures.MAPPING_ID_OR_NONE.validate(from_obj) - to_obj = signatures.MAPPING_ID_OR_NONE.validate(to_obj) - index = signatures.INTEGER.validate(index) - except SchemaError as e: - six.raise_from(DataFormatError, e) + from_obj = signatures.mapping_dict(from_obj) + to_obj = signatures.mapping_dict(to_obj) + index = int(index) if self.mappers is None: self.mappers = [] - # TODO: delete if None, None, int - new_mapping = {"index": index} # Both 'from' and 'to' can be None and end up blank if from_obj is not None: new_mapping["from"] = from_obj if to_obj is not None: new_mapping["to"] = to_obj - # Idiot check to make sure we get no exact duplicates - if new_mapping not in self.mappers: - self.mappers.append(new_mapping) + + # Iterate over indexes to see where we should place the new mapping + for i, current_mapping in enumerate(self.mappers): + # If we find an exact index match, replace it + if current_mapping["index"] == index: + self.mappers[i] = new_mapping + return + # Otherwise, insert it sorted by index + # TODO: make backwards compatible + bisect.insort(self.mappers, new_mapping, key=lambda x: x["index"]) def remove_mapping(self, from_obj, to_obj, index=None): + # type: (Union[str, dict], Union[str, dict], int) -> None """ - Removes an upgrade mapping occurence of an upgrade mapping. If ``index`` - is specified, it will attempt to remove that specific mapping from that - specific index, otherwise it will search for the first occurence of a - matching mapping. No action is performed if no mapping matches the input - arguments. + Removes a specified upgrade planner mapping. If ``index`` is not + specified, the function searches for the first occurrence where both + ``from_obj`` and ``to_obj`` match. If ``index`` is also specified, the + algorithm will try to remove the first occurrence where all 3 criteria + match. + + .. NOTE:: + + ``index`` in this case refers to the index of the mapper in the + **UpgradePlanner's GUI**, *not* it's position in the + :py:attr:`.mappers` list; these two numbers are potentially disjunct. + For example, `upgrade_planner.mappers[0]["index"]` is not + necessarily ``0``. - :param from_obj: The :py:data:`.SIGNAL_ID` to to convert entities/items + :raises ValueError: If the specified mapping does not currently exist + in the :py:attr:`.mappers` list. + + :param from_obj: The :py:data:`.MAPPING_ID` to to convert entities/items from. - :param to_obj: The :py:data:`.SIGNAL_ID` to convert entities/items to. + :param to_obj: The :py:data:`.MAPPING_ID` to convert entities/items to. :param index: The index of the mapping in the mapper to search. """ - try: - from_obj = signatures.SIGNAL_ID_OR_NONE.validate(from_obj) - to_obj = signatures.SIGNAL_ID_OR_NONE.validate(to_obj) - index = signatures.INTEGER_OR_NONE.validate(index) - except SchemaError as e: - six.raise_from(DataFormatError, e) + from_obj = signatures.mapping_dict(from_obj) + to_obj = signatures.mapping_dict(to_obj) + index = int(index) if index is not None else None if index is None: # Remove the first occurence of the mapping, if there are multiple for i, mapping in enumerate(self.mappers): - if mapping["from"] == from_obj and mapping["to"] == to_obj: + if ( + mapping.get("from", None) == from_obj + and mapping.get("to", None) == to_obj + ): self.mappers.pop(i) + return + # Otherwise, raise ValueError if we didn't find a match + raise ValueError( + "Unable to find mapper from '{}' to '{}'".format(from_obj, to_obj) + ) else: mapper = {"from": from_obj, "to": to_obj, "index": index} - try: - self.mappers.remove(mapper) - except ValueError: - pass + self.mappers.remove(mapper) + + def pop_mapping(self, index): + # type: (int) -> signatures.MAPPER + """ + Removes a mapping at a specific mapper index. Note that this is not the + position of the mapper in the :py:attr:`.mappers` list; it is the value + if ``"index"`` key associated with one or more mappers. If there are + multiple mapper objects that share the same index, then the only the + first one is removed. + + :raises ValueError: If no matching mappers could be found that have a + matching index. + + :param index: The index of the mapping in the mapper to search. + """ + # TODO: maybe make index optional so that `UpgradePlaner.pop_mapping()` + # pops the mapper with the highest "index" value? + # TODO: should there be a second argument to supply a default similar + # to how `pop()` works generally? + + # Simple search and pop + for i, mapping in enumerate(self.mappers): + if mapping["index"] == index: + return self.mappers.pop(i) + + raise ValueError("Unable to find mapper with index '{}'".format(index)) def validate(self): # type: () -> None - # TODO - # if self.is_valid: - # return + if self.is_valid: + return - result = self.__class__.validator({self._root_item: self._root}) - self._root = result[self._root_item] + # TODO: wrap with DataFormatError or similar + self._root = UpgradePlannerModel.validate(self._root).dict( + by_alias=True, exclude_none=True + ) - # TODO - # self._is_valid = True + super().validate() def inspect(self): - # type: () -> tuple(list[Exception], list[Warning]) - error_list = [] - warn_list = [] - # error_list, warn_list = super(Blueprintable, self).inspect() # TODO: implement - - # Keep track to see if multiple entries exist with the same index - occupied_indices = {} + # type: () -> ValidationResult + result = super().inspect() - # By nature of necessity, we must ensure that all members of upgrade + # By nature of necessity, we must ensure that all members of upgrade # planner are in a correct and known format, so we must call: try: self.validate() - except fastjsonschema.JsonSchemaException as e: - error_list.append(DataFormatError(e.args[0])) - return (error_list, warn_list) + except Exception as e: + # If validation fails, it's in a format that we do not expect; and + # therefore unreasonable for us to assume that we can continue + # checking for errors relating to that non-existent format. + # Therefore, we add the errors to the error list and early exit + # TODO: figure out the proper way to reraise + result.error_list.append(DataFormatError(str(e))) + return result - # Check each mappers + # Keep track to see if multiple entries exist with the same index + occupied_indices = {} + # Check each mapper for mapper in self.mappers: - # We assert that index must exist in each mapper, but "from" and "to" - # may be omitted - if "from" in mapper and "to" in mapper: - # Ensure that "from" and "to" are a valid pair - reason = check_valid_upgrade_pair(mapper["from"], mapper["to"]) - if reason is not None: - warn_list.append(reason) - - # If the index is not a u64, then that will fail to import - if not 0 <= mapper["index"] < 2**64: - error_list.append( - IndexError( - "'index' ({}) for mapping '{}' to '{}' must be a u64 in range [0, 2**64)".format( - mapper["index"], mapper["from"], mapper["to"] - ) - ) - ) + # Ensure that "from" and "to" are a valid pair + # We assert that index must exist in each mapper, but both "from" + # and "to" may be omitted + reasons = check_valid_upgrade_pair( + mapper.get("from", None), mapper.get("to", None) + ) + if reasons is not None: + result.warning_list.extend(reasons) # If the index is greater than mapper_count, then the mapping will # be redundant if not mapper["index"] < self.mapper_count: - warn_list.append( + result.warning_list.append( IndexWarning( "'index' ({}) for mapping '{}' to '{}' must be in range [0, {}) or else it will have no effect".format( mapper["index"], - mapper["from"], - mapper["to"], + mapper["from"]["name"], + mapper["to"]["name"], self.mapper_count, ) ) @@ -601,41 +528,41 @@ def inspect(self): # mapping is used) if mapper["index"] in occupied_indices: occupied_indices[mapper["index"]]["count"] += 1 - occupied_indices[mapper["index"]]["final"] = mapper + occupied_indices[mapper["index"]]["mapper"] = mapper else: - occupied_indices[mapper["index"]] = {"count": 1, "final": mapper} + occupied_indices[mapper["index"]] = {"count": 0, "mapper": mapper} # Issue warnings if multiple mappers occupy the same index for spot in occupied_indices: entry = occupied_indices[spot] - if entry["count"] > 1: - warn_list.append( - IndexWarning( # TODO: more specific (maybe OverlappingIndexWarning?) - "Mapping at index {} was overwritten {} times; final mapping is '{}' to '{}'".format( - entry["mapping"]["index"], + if entry["count"] > 0: + result.warning_list.append( + IndexWarning( + "Mapping at index {} was overwritten {} time(s); final mapping is '{}' to '{}'".format( + spot, entry["count"], - entry["mapping"]["to"], - entry["mapping"]["from"], + entry["mapper"].get("from", {"name": None})["name"], + entry["mapper"].get("to", {"name": None})["name"], ) ) ) - return (error_list, warn_list) + return result def to_dict(self): # type: () -> dict + out_dict = self.__class__.Format.construct( # Performs no validation(!) + upgrade_planner=self._root + ).dict( + by_alias=True, # Some attributes are reserved words (type, from, + # etc.); this resolves that issue + exclude_none=True, # Trim if values are None + exclude_defaults=True, # Trim if values are defaults + ) - # Ensure that we're in a known state - # self.validate() - - # Create a copy so we don't change the original any further - out_dict = copy.deepcopy(self._root) - - # Prune excess values - # (No chance of KeyError because 'settings' should always be a key until - # we export) - # TODO: integrate this into generic interface like I did with Entity - if out_dict["settings"] == {}: - del out_dict["settings"] + # TODO: FIXME; this is scuffed, ideally it would be part of the last + # step, but there are some peculiarities with pydantic + if not out_dict[self._root_item].get("settings", True): + del out_dict[self._root_item]["settings"] - return {"upgrade_planner": out_dict} + return out_dict diff --git a/draftsman/classes/validatable.py b/draftsman/classes/validatable.py deleted file mode 100644 index 21e539f..0000000 --- a/draftsman/classes/validatable.py +++ /dev/null @@ -1,3 +0,0 @@ -# validatable.py - -# TODO \ No newline at end of file diff --git a/draftsman/data/entities.py b/draftsman/data/entities.py index 2326d7e..36a2eb8 100644 --- a/draftsman/data/entities.py +++ b/draftsman/data/entities.py @@ -16,82 +16,90 @@ with pkg_resources.open_binary(data, "entities.pkl") as inp: - _data = pickle.load(inp) + _data: dict = pickle.load(inp) # Aggregation of all the the entity dicts from data.raw collected in one # place. - raw = _data["raw"] + raw: dict[str, dict] = _data["raw"] # Whether or not each entity is flippable, indexed by their name. - flippable = _data["flippable"] - collision_sets = _data["collision_sets"] - - # Ordered lists of strings, each containing a valid name for that entity - # type, sorted by their Factorio order strings. - containers = _data["containers"] - storage_tanks = _data["storage_tanks"] - transport_belts = _data["transport_belts"] - underground_belts = _data["underground_belts"] - splitters = _data["splitters"] - inserters = _data["inserters"] - filter_inserters = _data["filter_inserters"] - loaders = _data["loaders"] - electric_poles = _data["electric_poles"] - pipes = _data["pipes"] - underground_pipes = _data["underground_pipes"] - pumps = _data["pumps"] - straight_rails = _data["straight_rails"] - curved_rails = _data["curved_rails"] - train_stops = _data["train_stops"] - rail_signals = _data["rail_signals"] - rail_chain_signals = _data["rail_chain_signals"] - locomotives = _data["locomotives"] - cargo_wagons = _data["cargo_wagons"] - fluid_wagons = _data["fluid_wagons"] + flippable: dict[str, bool] = _data["flippable"] + + # Indexes of unique collision sets for each entity. Shared between all + # entity instances, so we save memory by not including it in each Entity + # instance. + collision_sets: dict[str, CollisionSet] = _data["collision_sets"] + + # Lists of strings, each containing a valid name for that entity type, + # sorted by their Factorio order strings. + containers: list[str] = _data["containers"] + storage_tanks: list[str] = _data["storage_tanks"] + transport_belts: list[str] = _data["transport_belts"] + underground_belts: list[str] = _data["underground_belts"] + splitters: list[str] = _data["splitters"] + inserters: list[str] = _data["inserters"] + filter_inserters: list[str] = _data["filter_inserters"] + loaders: list[str] = _data["loaders"] + electric_poles: list[str] = _data["electric_poles"] + pipes: list[str] = _data["pipes"] + underground_pipes: list[str] = _data["underground_pipes"] + pumps: list[str] = _data["pumps"] + straight_rails: list[str] = _data["straight_rails"] + curved_rails: list[str] = _data["curved_rails"] + train_stops: list[str] = _data["train_stops"] + rail_signals: list[str] = _data["rail_signals"] + rail_chain_signals: list[str] = _data["rail_chain_signals"] + locomotives: list[str] = _data["locomotives"] + cargo_wagons: list[str] = _data["cargo_wagons"] + fluid_wagons: list[str] = _data["fluid_wagons"] artillery_wagons = _data["artillery_wagons"] - logistic_passive_containers = _data["logistic_passive_containers"] - logistic_active_containers = _data["logistic_active_containers"] - logistic_storage_containers = _data["logistic_storage_containers"] - logistic_buffer_containers = _data["logistic_buffer_containers"] - logistic_request_containers = _data["logistic_request_containers"] - roboports = _data["roboports"] - lamps = _data["lamps"] - arithmetic_combinators = _data["arithmetic_combinators"] - decider_combinators = _data["decider_combinators"] - constant_combinators = _data["constant_combinators"] - power_switches = _data["power_switches"] - programmable_speakers = _data["programmable_speakers"] - boilers = _data["boilers"] - generators = _data["generators"] - solar_panels = _data["solar_panels"] - accumulators = _data["accumulators"] - reactors = _data["reactors"] - heat_pipes = _data["heat_pipes"] - mining_drills = _data["mining_drills"] - offshore_pumps = _data["offshore_pumps"] - furnaces = _data["furnaces"] - assembling_machines = _data["assembling_machines"] - labs = _data["labs"] - beacons = _data["beacons"] - rocket_silos = _data["rocket_silos"] - land_mines = _data["land_mines"] - walls = _data["walls"] - gates = _data["gates"] - turrets = _data["turrets"] - radars = _data["radars"] - electric_energy_interfaces = _data["electric_energy_interfaces"] - linked_containers = _data["linked_containers"] - heat_interfaces = _data["heat_interfaces"] - linked_belts = _data["linked_belts"] - infinity_containers = _data["infinity_containers"] - infinity_pipes = _data["infinity_pipes"] - burner_generators = _data["burner_generators"] + logistic_passive_containers: list[str] = _data["logistic_passive_containers"] + logistic_active_containers: list[str] = _data["logistic_active_containers"] + logistic_storage_containers: list[str] = _data["logistic_storage_containers"] + logistic_buffer_containers: list[str] = _data["logistic_buffer_containers"] + logistic_request_containers: list[str] = _data["logistic_request_containers"] + roboports: list[str] = _data["roboports"] + lamps: list[str] = _data["lamps"] + arithmetic_combinators: list[str] = _data["arithmetic_combinators"] + decider_combinators: list[str] = _data["decider_combinators"] + constant_combinators: list[str] = _data["constant_combinators"] + power_switches: list[str] = _data["power_switches"] + programmable_speakers: list[str] = _data["programmable_speakers"] + boilers: list[str] = _data["boilers"] + generators: list[str] = _data["generators"] + solar_panels: list[str] = _data["solar_panels"] + accumulators: list[str] = _data["accumulators"] + reactors: list[str] = _data["reactors"] + heat_pipes: list[str] = _data["heat_pipes"] + mining_drills: list[str] = _data["mining_drills"] + offshore_pumps: list[str] = _data["offshore_pumps"] + furnaces: list[str] = _data["furnaces"] + assembling_machines: list[str] = _data["assembling_machines"] + labs: list[str] = _data["labs"] + beacons: list[str] = _data["beacons"] + rocket_silos: list[str] = _data["rocket_silos"] + land_mines: list[str] = _data["land_mines"] + walls: list[str] = _data["walls"] + gates: list[str] = _data["gates"] + turrets: list[str] = _data["turrets"] + radars: list[str] = _data["radars"] + electric_energy_interfaces: list[str] = _data["electric_energy_interfaces"] + linked_containers: list[str] = _data["linked_containers"] + heat_interfaces: list[str] = _data["heat_interfaces"] + linked_belts: list[str] = _data["linked_belts"] + infinity_containers: list[str] = _data["infinity_containers"] + infinity_pipes: list[str] = _data["infinity_pipes"] + burner_generators: list[str] = _data["burner_generators"] def add_entity( - name, entity_type, collision_box, collision_mask=None, hidden=False, **kwargs + name: str, + entity_type: str, + collision_box: PrimitiveAABB, + collision_mask: set[str] = None, + hidden: bool = False, + **kwargs ): - # type: (str, str, PrimitiveAABB, set[str], bool, **dict) -> None """ Temporarily adds an entity to :py:mod:`draftsman.data.entities`. diff --git a/draftsman/data/instruments.py b/draftsman/data/instruments.py index f3ae340..1f1d4c6 100644 --- a/draftsman/data/instruments.py +++ b/draftsman/data/instruments.py @@ -1,5 +1,4 @@ # instruments.py -# -*- encoding: utf-8 -*- import pickle @@ -13,11 +12,11 @@ with pkg_resources.open_binary(data, "instruments.pkl") as inp: - _data = pickle.load(inp) - raw = _data[0] - index = _data[1] - names = _data[2] + _data: list = pickle.load(inp) + raw: dict[str, list[dict]] = _data[0] + index: dict[str, dict[str, dict[str, int]]] = _data[1] + names: dict[str, dict[str, dict[int, str]]] = _data[2] -def add_instrument(name, notes): +def add_instrument(entity: str, name: str, notes: list[str]): raise NotImplementedError diff --git a/draftsman/data/items.py b/draftsman/data/items.py index 9761d17..752e092 100644 --- a/draftsman/data/items.py +++ b/draftsman/data/items.py @@ -1,5 +1,4 @@ # items.py -# -*- encoding: utf-8 -*- import pickle @@ -14,11 +13,10 @@ with pkg_resources.open_binary(data, "items.pkl") as inp: _data = pickle.load(inp) - raw = _data[0] - subgroups = _data[1] - groups = _data[2] + raw: dict[str, dict] = _data[0] + subgroups: dict[str, dict] = _data[1] + groups: dict[str, dict] = _data[2] -def add_item(name, subgroup, group): - # type: (str, str, str) -> None +def add_item(name: str, subgroup: str, group: str): raise NotImplementedError diff --git a/draftsman/data/mods.py b/draftsman/data/mods.py index 40ef358..f4d243c 100644 --- a/draftsman/data/mods.py +++ b/draftsman/data/mods.py @@ -13,4 +13,4 @@ with pkg_resources.open_binary(data, "mods.pkl") as inp: - mod_list = pickle.load(inp) + mod_list: dict[str, tuple] = pickle.load(inp) diff --git a/draftsman/data/modules.py b/draftsman/data/modules.py index 53a3cb8..84eacfb 100644 --- a/draftsman/data/modules.py +++ b/draftsman/data/modules.py @@ -14,10 +14,9 @@ with pkg_resources.open_binary(data, "modules.pkl") as inp: _data = pickle.load(inp) - raw = _data[0] - categories = _data[1] + raw: dict[str, dict] = _data[0] + categories: dict[str, list[str]] = _data[1] -def add_module(name, category): - # type: (str, str) -> None +def add_module(name: str, category: str): raise NotImplementedError diff --git a/draftsman/data/recipes.py b/draftsman/data/recipes.py index a00fcc9..4e7d0ef 100644 --- a/draftsman/data/recipes.py +++ b/draftsman/data/recipes.py @@ -16,16 +16,16 @@ with pkg_resources.open_binary(data, "recipes.pkl") as inp: _data = pickle.load(inp) - raw = _data[0] - categories = _data[1] - for_machine = _data[2] + raw: dict[str, dict] = _data[0] + categories: dict[str, list[str]] = _data[1] + for_machine: dict[str, list[str]] = _data[2] -def add_recipe(name, ingredients, result): - raise NotImplementedError +def add_recipe(name: str, ingredients: list[str], result: str): + raise NotImplementedError # TODO -def get_recipe_ingredients(recipe_name, expensive=False): +def get_recipe_ingredients(recipe_name: str, expensive: bool=False): # type: (str, bool) -> set[str] """ Returns a ``set`` of all item types that ``recipe_name`` requires. Discards diff --git a/draftsman/data/signals.py b/draftsman/data/signals.py index 1fa28ee..42518fb 100644 --- a/draftsman/data/signals.py +++ b/draftsman/data/signals.py @@ -4,10 +4,9 @@ from draftsman import data from draftsman.data import entities, modules -from draftsman.error import InvalidSignalError, InvalidMappingError +from draftsman.error import InvalidSignalError, InvalidMapperError import pickle -import six try: # pragma: no coverage import importlib.resources as pkg_resources # type: ignore @@ -18,23 +17,27 @@ with pkg_resources.open_binary(data, "signals.pkl") as inp: _data = pickle.load(inp) - raw = _data[0] - type_of = _data[1] - item = _data[2] - fluid = _data[3] - virtual = _data[4] + + raw: dict[str, dict] = _data[0] + + # Look up table for a particular signal's type + type_of: dict[str, str] = _data[1] + + # Lists of signal names organized by their type for easy iteration + item: list[str] = _data[2] + fluid: list[str] = _data[3] + virtual: list[str] = _data[4] pure_virtual = ["signal-everything", "signal-anything", "signal-each"] -def add_signal(name, type): - # type: (str, str) -> None +def add_signal(name: str, type: str): """ Temporarily adds a signal to :py:mod:`draftsman.data.signals`. This allows the user to specify custom signals so that Draftsman can deduce their type - without having to install a corresponding mod. More specifically, it - populates :py:data:`raw` and :py:data:`type_of` with the correct values, - and adds the name to either :py:data:`item`, :py:data:`fluid`, or - :py:data:`virtual` depending on ``type``. + without having to manually specify each time or install a corresponding mod. + More specifically, it populates :py:data:`raw` and :py:data:`type_of` with + the correct values, and adds the name to either :py:data:`.item`, + :py:data:`.fluid`, or :py:data:`.virtual` depending on ``type``. Note that this is not intended as a replacement for generating proper signal data using ``draftsman-update``; instead it offers a fast mechanism for @@ -60,8 +63,7 @@ def add_signal(name, type): virtual.append(name) -def get_signal_type(signal_name): - # type: (str) -> str +def get_signal_type(signal_name: str) -> str: """ Returns the type of the signal based on its ID string. @@ -75,23 +77,13 @@ def get_signal_type(signal_name): :exception InvalidSignalError: If the signal name is not contained within :py:mod:`draftsman.data.signals`, and thus it's type cannot be deduced. """ - # if signal_name in signals.virtual: - # return "virtual" - # elif signal_name in signals.fluid: - # return "fluid" - # elif signal_name in signals.item: - # return "item" - # else: - # raise InvalidSignalError("'{}'".format(str(signal_name))) - try: - return six.text_type(type_of[signal_name]) + return type_of[signal_name] except KeyError: raise InvalidSignalError("'{}'".format(signal_name)) -def signal_dict(signal_name): - # type: (str) -> dict +def signal_dict(signal: str) -> dict: """ Creates a SignalID ``dict`` from the given signal name. @@ -101,28 +93,36 @@ def signal_dict(signal_name): :returns: A dict with the ``"name"`` and ``"type"`` keys set. """ - return {"name": six.text_type(signal_name), "type": get_signal_type(signal_name)} + if signal is None or isinstance(signal, dict): + return signal + else: + return {"name": str(signal), "type": get_signal_type(signal)} -def get_mapping_type(mapping_name): - # type: (str) -> str +def get_mapper_type(mapper_name: str) -> str: """ TODO """ # TODO: actually check that this is the case (particularly with modded entities/items) - if mapping_name in modules.raw: # TODO: should probably change + if mapper_name in modules.raw: # TODO: should probably change return "item" - elif mapping_name in entities.raw: # TODO: should probably change + elif mapper_name in entities.raw: # TODO: should probably change return "entity" else: - raise InvalidMappingError("'{}'".format(mapping_name)) + raise InvalidMapperError("'{}'".format(mapper_name)) -def mapping_dict(mapping_name): - # type: (str) -> dict +def mapper_dict(mapper: str) -> dict: """ Creates a MappingID ``dict`` from the given mapping name. - TODO + Uses :py:func:`get_mapping_type` to get the type for the dictionary. + + :param signal_name: The name of the signal. + + :returns: A dict with the ``"name"`` and ``"type"`` keys set. """ - return {"name": six.text_type(mapping_name), "type": get_mapping_type(mapping_name)} + if mapper is None or isinstance(mapper, dict): + return mapper + else: + return {"name": str(mapper), "type": get_mapper_type(mapper)} diff --git a/draftsman/data/tiles.py b/draftsman/data/tiles.py index 7a82db1..2cc8528 100644 --- a/draftsman/data/tiles.py +++ b/draftsman/data/tiles.py @@ -12,11 +12,10 @@ with pkg_resources.open_binary(data, "tiles.pkl") as inp: - raw = pickle.load(inp) + raw: dict[str, dict] = pickle.load(inp) -def add_tile(name, collision_mask=set()): - # type: (str, set[str]) -> None +def add_tile(name: str, collision_mask: set[str]=set()): """ Temporarily adds a tile to :py:mod:`draftsman.data.tiles`. diff --git a/draftsman/error.py b/draftsman/error.py index 6c4f2bd..21741d1 100644 --- a/draftsman/error.py +++ b/draftsman/error.py @@ -276,9 +276,9 @@ class InvalidSignalError(DraftsmanError): pass -class InvalidMappingError(DraftsmanError): +class InvalidMapperError(DraftsmanError): """ - Raised when a mapping name does not match any valid entry currently + Raised when a mapper name does not match any valid entry currently recognized by Draftsman. """ diff --git a/draftsman/signatures.py b/draftsman/signatures.py index 3963fc5..6bd48e9 100644 --- a/draftsman/signatures.py +++ b/draftsman/signatures.py @@ -14,12 +14,12 @@ from draftsman.data.signals import signal_dict, mapping_dict from builtins import int +from enum import Enum +from pydantic import BaseModel, validator, root_validator, Field +from typing import List, Literal from schema import Schema, Use, Optional, Or, And +from typing import Optional as TrueOptional # TODO: fixme import six -import weakref - - -# TODO: separate CONTROL_BEHAVIOR into their individual signatures for each entity INTEGER = Schema(int) @@ -713,22 +713,175 @@ def normalize_request(filters): SCHEDULES = Schema([SCHEDULE]) -# def normalize_mappers(mappers): -# for i, mapper in enumerate(mappers): -# if isinstance(mapper, (tuple, list)): -# mappers[i] = {"index": i} -# if mapper[0]: -# mappers[i]["from"] = signal_dict(mapper[0]) -# if mapper[1]: -# mappers[i]["to"] = signal_dict(mapper[1]) +def normalize_mappers(mappers): + if mappers is None: + return mappers + for i, mapper in enumerate(mappers): + if isinstance(mapper, (tuple, list)): + mappers[i] = {"index": i} + if mapper[0]: + mappers[i]["from"] = mapping_dict(mapper[0]) + if mapper[1]: + mappers[i]["to"] = mapping_dict(mapper[1]) + return mappers -# MAPPERS = Schema( -# And( -# Use(normalize_mappers), -# Or( -# [{Optional("from"): SIGNAL_ID, Optional("to"): SIGNAL_ID, "index": int}], -# None, -# ), -# ) -# ) +MAPPERS = Schema( + And( + Use(normalize_mappers), + Or( + [ + { + Optional("from"): MAPPING_ID_OR_NONE, + Optional("to"): MAPPING_ID_OR_NONE, + "index": int, + } + ], + None, + ), + ) +) + + +# ============================================================================= +# Beyond be dragons + +signal_schema = { # TODO: rename + "$id": "SIGNAL_DICT", # TODO: rename + "title": "Signal dict", + "description": "JSON object that represents a signal. Used in the circuit network, but also used for blueprintable icons.", + "type": "object", + "properties": { + "name": { + "description": "Must be a name recognized by Factorio, or will error on import. Surprisingly, can actually be omitted; in that case will result in an empty signal.", + "type": "string", + }, + "type": { + "description": "Must be one of the following values, or will error on import.", + "type": "string", + "enum": ["item", "fluid", "virtual"], + }, + }, + "required": ["type"], + "additionalProperties": False, + "draftsman_conversion": lambda key, value: (key, normalize_signal_id(value)), +} + + +mapper_schema = { # TODO: rename + "$id": "MAPPER_DICT", # TODO: rename + "title": "Mapper dict", + "description": "JSON object that represents a mapper. Used in Upgrade Planners to describe their function.", + "type": "object", + "properties": { + "name": { + "description": "Must be a name recognized by Factorio, or will error on import.", + "type": "string", + }, + "type": { + "description": "Must be one of the following values, or will error on import. Item refers to modules, entity refers to everything else (as far as I've investigated; modded objects might change this behavior, but I have yet to take the time to find out)", + "type": "string", + "enum": ["item", "entity"], + }, + }, + "required": ["name", "type"], + "additionalProperties": False, + "draftsman_conversion": lambda key, value: (key, normalize_mapping_id(value)), +} + + +class MapperType(str, Enum): + entity = "entity" + item = "item" + + +class MapperID(BaseModel): + name: str + type: MapperType + + +class Mapper(BaseModel): + to: MapperID | None = None + from_: MapperID | None = Field(None, alias="from") # Damn you Python + index: int = Field(..., ge=0, lt=2**64) + + +class Mappers(BaseModel): + __root__: List[Mapper] | None + + # @validator("__root__", pre=True) + # def normalize_mappers(cls, mappers): + # if mappers is None: + # return mappers + # for i, mapper in enumerate(mappers): + # if isinstance(mapper, (tuple, list)): + # mappers[i] = {"index": i} + # if mapper[0]: + # mappers[i]["from"] = mapping_dict(mapper[0]) + # if mapper[1]: + # mappers[i]["to"] = mapping_dict(mapper[1]) + # return mappers + + +icons_schema = { # TODO: rename + "$id": "ICONS_ARRAY", # TODO: rename + "title": "Icons list", + "description": "Format of the list of signals used to give blueprintable objects unique appearences. Only a maximum of 4 entries are allowed; indicies outside of the range [1, 4] will return 'Index out of bounds', and defining multiple icons that use the same index returns 'Icon already specified'.", + "type": "array", + "items": { + "type": "object", + "properties": { + "signal": { + "description": "Which signal icon to use.", + "$ref": "factorio-draftsman://SIGNAL_DICT", + }, + "index": { + "description": "What index to place the signal icon, 1-indexed.", + "type": "integer", + "minimum": 1, + "maximum": 4, + }, + }, + "required": ["signal", "index"], + "additionalProperties": False, + }, + "maxItems": 4, + "draftsman_exportIf": "truthy", + "draftsman_conversion": lambda key, value: (key, normalize_icons(value)), +} + + +class SignalID(BaseModel): + name: TrueOptional[str] # Anyone's guess _why_ this is optional + type: str + + +class Icon(BaseModel): + signal: SignalID + index: int + + +class Icons(BaseModel): + __root__: List[Icon] | None = Field(..., max_items=4) + + # @root_validator(pre=True) + @validator("__root__", pre=True) + def normalize_icons(cls, icons): + if icons is None: + return icons + for i, icon in enumerate(icons): + if isinstance(icon, six.string_types): + icons[i] = {"index": i + 1, "signal": signal_dict(icon)} + return icons + + +class Label(BaseModel): + __root__: str = None + + +class Description(BaseModel): + __root__: str = None + + +class Version(BaseModel): + __root__: int = Field(None, ge=0, lt=2**64) diff --git a/draftsman/warning.py b/draftsman/warning.py index 6badf10..3d14992 100644 --- a/draftsman/warning.py +++ b/draftsman/warning.py @@ -169,7 +169,7 @@ class OverlappingObjectsWarning(DraftsmanWarning): class UselessOperationWarning(DraftsmanWarning): """ - Raised when an action of some kind is functionally useless, such as when a + Raised when an action of some kind is functionally useless, such as when a wall is connected with a circuit wire without an adjacent :py:class:`.Gate`. """ @@ -178,9 +178,9 @@ class UselessOperationWarning(DraftsmanWarning): class RedundantOperationWarning(DraftsmanWarning): """ - Raised when an action is performed who's operation would not have any + Raised when an action is performed who's operation would not have any noticable change, making it's execution needless. For example, setting a - mapping in an upgrade planner to upgrade "transport-belt" to + mapping in an upgrade planner to upgrade "transport-belt" to "transport-belt" is possible, but performs no upgrade operation when used. """ @@ -189,10 +189,10 @@ class RedundantOperationWarning(DraftsmanWarning): class UnrecognizedElementWarning(DraftsmanWarning): """ - Raised when Draftsman detects a entity/item/signal/tile or any other - Factorio construct that it cannot resolve under it's current data - configuration. This is usually either because the identifier was mistyped, - or because the element in question belongs to a mod that Draftsman has not + Raised when Draftsman detects a entity/item/signal/tile or any other + Factorio construct that it cannot resolve under it's current data + configuration. This is usually either because the identifier was mistyped, + or because the element in question belongs to a mod that Draftsman has not been updated to recognize. """ diff --git a/examples/item_requester.py b/examples/item_requester.py new file mode 100644 index 0000000..8025554 --- /dev/null +++ b/examples/item_requester.py @@ -0,0 +1,39 @@ +# item_requester.py + +""" +Simple command line interface script that modifies all entities in an input +blueprint string to request a certain amount of an item. Would normally be used +to request modules for assembling machines/beacons, but works with any entity +with an inventory which means that you can do interesting things with it. +""" + +from draftsman.blueprintable import Blueprint + + +def main(): + print("Input a blueprint string:") + + blueprint_string = input() + bp = Blueprint(blueprint_string) + + print("What entity do you want to request items to?") + + entity_name = input() + matching_entities = bp.find_entities_filtered(name=entity_name) + assert len(matching_entities) > 0, "No entities with name '{}' in blueprint".format(entity_name) + + print("What item would you like to request for that entity?") + item_name = input() + + print("How much of that item per entity?") + item_amount = int(input()) + + for entity in matching_entities: + entity.set_item_request(item_name, item_amount) + + print("Output:\n") + print(bp.to_string()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/revision_history_experiment.py b/examples/revision_history_experiment.py new file mode 100644 index 0000000..28ebbc9 --- /dev/null +++ b/examples/revision_history_experiment.py @@ -0,0 +1,36 @@ +# revision_history_experiment.py + +""" +Testing related to a vanilla-adjacent method to store git-style diffs with +ctime/mtime support. Spawned from a discussion on the Technical Factorio Discord +server. + +Works as long as the entities remain tile ghosts; once they're placed creating +a new blueprint from the entities loses specified tag information. A mod would +likely have to be developed alongside to make this persistent, but that is +outside the scope of Draftsman. +""" + +from draftsman.blueprintable import Blueprint + +from datetime import datetime + + +def main(): + # Export blueprint with custom data to Factorio + bp = Blueprint() + bp.entities.append("transport-belt") + bp.entities[0].tags["git_history"] = {"creation": str(datetime.now())} # or whatever + importable_string = bp.to_string() + print(importable_string) + + # If read back, extract such data from blueprint string + bp = Blueprint(importable_string) + if "git_history" in bp.entities[0].tags: + date = datetime.fromisoformat(bp.entities[0].tags["git_history"]["creation"]) + print(date) + + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test/entities/test_decider_combinator.py b/test/entities/test_decider_combinator.py index 89b285d..4fd6b31 100644 --- a/test/entities/test_decider_combinator.py +++ b/test/entities/test_decider_combinator.py @@ -460,4 +460,4 @@ def test_merge(self): "copy_count_from_input": False, } } - assert comb1.tags == None # Overwritten by comb2 + assert comb1.tags == {} # Overwritten by comb2 diff --git a/test/test_blueprint.py b/test/test_blueprint.py index 9b410c2..ea2844e 100644 --- a/test/test_blueprint.py +++ b/test/test_blueprint.py @@ -35,6 +35,7 @@ EntityNotCircuitConnectableError, DataFormatError, InvalidAssociationError, + InvalidSignalError, ) from draftsman.utils import encode_version, AABB from draftsman.warning import ( @@ -92,7 +93,7 @@ def test_constructor(self): "blueprint": {"item": "blueprint", "version": encode_version(1, 1, 54, 0)} } blueprint = Blueprint(example) - assert blueprint.to_dict()["blueprint"] == example + assert blueprint.to_dict() == example # # TypeError # with self.assertRaises(TypeError): @@ -142,8 +143,11 @@ def test_setup(self): blueprint = Blueprint() blueprint.setup( label="something", - label_color=(1.0, 0.0, 0.0), - icons=["signal-A", "signal-B"], + label_color={"r": 1.0, "g": 0.0, "b": 0.0}, + icons=[ + {"index": 1, "signal": {"name": "signal-A", "type": "virtual"}}, + {"index": 2, "signal": {"name": "signal-B", "type": "virtual"}}, + ], snapping_grid_size=(32, 32), snapping_grid_position=(16, 16), position_relative_to_grid=(-5, -7), @@ -186,7 +190,7 @@ def test_setup(self): def test_set_label(self): blueprint = Blueprint() - blueprint.version = (1, 1, 54, 0) + blueprint.set_version(1, 1, 54, 0) # String blueprint.label = "testing The LABEL" assert blueprint.label == "testing The LABEL" @@ -201,18 +205,15 @@ def test_set_label(self): "item": "blueprint", "version": encode_version(1, 1, 54, 0), } - # Other - with pytest.raises(TypeError): - blueprint.label = 100 # ========================================================================= def test_set_label_color(self): blueprint = Blueprint() - blueprint.version = (1, 1, 54, 0) + blueprint.set_version(1, 1, 54, 0) # Valid 3 args # Test for floating point conversion error by using 0.1 - blueprint.label_color = (0.5, 0.1, 0.5) + blueprint.set_label_color(0.5, 0.1, 0.5) assert blueprint.label_color == {"r": 0.5, "g": 0.1, "b": 0.5} assert blueprint.to_dict()["blueprint"] == { "item": "blueprint", @@ -220,7 +221,7 @@ def test_set_label_color(self): "version": encode_version(1, 1, 54, 0), } # Valid 4 args - blueprint.label_color = (1.0, 1.0, 1.0, 0.25) + blueprint.set_label_color(1.0, 1.0, 1.0, 0.25) assert blueprint.to_dict()["blueprint"] == { "item": "blueprint", "label_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 0.25}, @@ -235,14 +236,14 @@ def test_set_label_color(self): } # Invalid Data with pytest.raises(DataFormatError): - blueprint.label_color = ("red", blueprint, 5) + blueprint.set_label_color("red", blueprint, 5) # ========================================================================= def test_set_icons(self): blueprint = Blueprint() # Single Icon - blueprint.icons = ["signal-A"] + blueprint.set_icons("signal-A") assert blueprint.icons == [ {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1} ] @@ -250,12 +251,19 @@ def test_set_icons(self): {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1} ] # Multiple Icon - blueprint.icons = ["signal-A", "signal-B", "signal-C"] + blueprint.set_icons("signal-A", "signal-B", "signal-C") assert blueprint["icons"] == [ {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}, {"signal": {"name": "signal-B", "type": "virtual"}, "index": 2}, {"signal": {"name": "signal-C", "type": "virtual"}, "index": 3}, ] + + # Raw signal dicts: + blueprint.set_icons({"name": "some-signal", "type": "some-type"}) + assert blueprint["icons"] == [ + {"signal": {"name": "some-signal", "type": "some-type"}, "index": 1} + ] + # None blueprint.icons = None assert blueprint.icons == None @@ -265,19 +273,16 @@ def test_set_icons(self): } # Incorrect Signal Name - with pytest.raises(DataFormatError): - blueprint.icons = ["wrong!"] + with pytest.raises(InvalidSignalError): + blueprint.set_icons("wrong!") # Incorrect Signal Type - with pytest.raises(DataFormatError): - blueprint.icons = [123456, "uh-oh"] + with pytest.raises(InvalidSignalError): + blueprint.set_icons(123456, "uh-oh") # Incorrect Signal dict format - with pytest.raises(DataFormatError): - blueprint.icons = [{"incorrectly": "formatted"}] - - with pytest.raises(DataFormatError): - blueprint.icons = TypeError + # with pytest.raises(TypeError): + # blueprint.set_icons({"incorrectly": "formatted"}) # ========================================================================= @@ -288,14 +293,11 @@ def test_set_description(self): blueprint.description = None assert blueprint.description == None - with pytest.raises(TypeError): - blueprint.description = TypeError - # ========================================================================= def test_set_version(self): blueprint = Blueprint() - blueprint.version = (1, 0, 40, 0) + blueprint.set_version(1, 0, 40, 0) assert blueprint.version == 281474979332096 blueprint.version = None @@ -303,10 +305,10 @@ def test_set_version(self): assert blueprint.to_dict()["blueprint"] == {"item": "blueprint"} with pytest.raises(TypeError): - blueprint.version = TypeError + blueprint.set_version(TypeError, TypeError) with pytest.raises(TypeError): - blueprint.version = ("1", "0", "40", "0") + blueprint.set_version("1", "0", "40", "0") # ========================================================================= @@ -592,7 +594,7 @@ def test_rotate_entity(self): def test_add_tile(self): blueprint = Blueprint() - blueprint.version = (1, 1, 54, 0) + blueprint.set_version(1, 1, 54, 0) # Checkerboard grid for x in range(2): @@ -625,7 +627,7 @@ def test_add_tile(self): def test_version_tuple(self): blueprint = Blueprint() assert blueprint.version_tuple() == __factorio_version_info__ - blueprint.version = (0, 0, 0, 0) + blueprint.set_version(0, 0, 0, 0) assert blueprint.version_tuple() == (0, 0, 0, 0) # ========================================================================= @@ -633,7 +635,7 @@ def test_version_tuple(self): def test_version_string(self): blueprint = Blueprint() assert blueprint.version_string() == __factorio_version__ - blueprint.version = (0, 0, 0, 0) + blueprint.set_version(0, 0, 0, 0) assert blueprint.version_string() == "0.0.0.0" # ========================================================================= diff --git a/test/test_blueprint_book.py b/test/test_blueprint_book.py index fe2ed39..1a1ef82 100644 --- a/test/test_blueprint_book.py +++ b/test/test_blueprint_book.py @@ -167,7 +167,7 @@ def test_setup(self): blueprint_book = BlueprintBook() example = { "label": "a label", - "label_color": (50, 50, 50), + "label_color": {"r": 50, "g": 50, "b": 50}, "active_index": 0, "item": "blueprint-book", "blueprints": [], @@ -189,7 +189,7 @@ def test_setup(self): def test_set_label(self): blueprint_book = BlueprintBook() - blueprint_book.version = (1, 1, 54, 0) + blueprint_book.set_version(1, 1, 54, 0) # String blueprint_book.label = "testing The LABEL" assert blueprint_book.label == "testing The LABEL" @@ -210,16 +210,13 @@ def test_set_label(self): "version": encode_version(1, 1, 54, 0), } } - # Other - with pytest.raises(TypeError): - blueprint_book.label = 100 def test_set_label_color(self): blueprint_book = BlueprintBook() - blueprint_book.version = (1, 1, 54, 0) + blueprint_book.set_version(1, 1, 54, 0) # Valid 3 args # Test for floating point conversion error by using 0.1 - blueprint_book.label_color = (0.5, 0.1, 0.5) + blueprint_book.set_label_color(0.5, 0.1, 0.5) assert blueprint_book.label_color == {"r": 0.5, "g": 0.1, "b": 0.5} assert blueprint_book.to_dict() == { "blueprint_book": { @@ -230,7 +227,7 @@ def test_set_label_color(self): } } # Valid 4 args - blueprint_book.label_color = (1.0, 1.0, 1.0, 0.25) + blueprint_book.set_label_color(1.0, 1.0, 1.0, 0.25) assert blueprint_book.to_dict() == { "blueprint_book": { "item": "blueprint-book", @@ -251,33 +248,40 @@ def test_set_label_color(self): } with pytest.raises(DataFormatError): - blueprint_book.label_color = TypeError + blueprint_book.set_label_color(TypeError, TypeError, TypeError) # Invalid Data with pytest.raises(DataFormatError): - blueprint_book.label_color = ("red", blueprint_book, 5) + blueprint_book.set_label_color("red", blueprint_book, 5) def test_set_icons(self): - blueprint = BlueprintBook() + blueprint_book = BlueprintBook() # Single Icon - blueprint.icons = ["signal-A"] - assert blueprint.icons == [ + blueprint_book.set_icons("signal-A") + assert blueprint_book.icons == [ {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1} ] - assert blueprint["icons"] == [ + assert blueprint_book["icons"] == [ {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1} ] # Multiple Icon - blueprint.icons = ["signal-A", "signal-B", "signal-C"] - assert blueprint["icons"] == [ + blueprint_book.set_icons("signal-A", "signal-B", "signal-C") + assert blueprint_book["icons"] == [ {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}, {"signal": {"name": "signal-B", "type": "virtual"}, "index": 2}, {"signal": {"name": "signal-C", "type": "virtual"}, "index": 3}, ] + + # Raw signal dicts + blueprint_book.set_icons({"name": "some-signal", "type": "some-type"}) + assert blueprint_book["icons"] == [ + {"signal": {"name": "some-signal", "type": "some-type"}, "index": 1} + ] + # None - blueprint.icons = None - assert blueprint.icons == None - assert blueprint.to_dict() == { + blueprint_book.icons = None + assert blueprint_book.icons == None + assert blueprint_book.to_dict() == { "blueprint_book": { "item": "blueprint-book", "active_index": 0, @@ -286,19 +290,12 @@ def test_set_icons(self): } # Incorrect Signal Name - with pytest.raises(DataFormatError): - blueprint.icons = ["wrong!"] + with pytest.raises(InvalidSignalError): + blueprint_book.set_icons("wrong!") # Incorrect Signal Type - with pytest.raises(DataFormatError): - blueprint.icons = [123456, "uh-oh"] - - # Incorrect Signal dict format - with pytest.raises(DataFormatError): - blueprint.icons = [{"incorrectly": "formatted"}] - - with pytest.raises(DataFormatError): - blueprint.icons = TypeError + with pytest.raises(InvalidSignalError): + blueprint_book.set_icons(123456, "uh-oh") def test_set_active_index(self): blueprint_book = BlueprintBook() @@ -323,7 +320,7 @@ def test_set_active_index(self): def test_set_version(self): blueprint_book = BlueprintBook() - blueprint_book.version = (1, 0, 40, 0) + blueprint_book.set_version(1, 0, 40, 0) assert blueprint_book.version == 281474979332096 blueprint_book.version = None @@ -333,10 +330,10 @@ def test_set_version(self): } with pytest.raises(TypeError): - blueprint_book.version = TypeError + blueprint_book.set_version(TypeError) with pytest.raises(TypeError): - blueprint_book.version = ("1", "0", "40", "0") + blueprint_book.set_version("1", "0", "40", "0") def test_set_blueprints(self): blueprint_book = BlueprintBook() @@ -396,21 +393,21 @@ def test_set_blueprints(self): def test_version_tuple(self): blueprint_book = BlueprintBook() assert blueprint_book.version_tuple() == __factorio_version_info__ - blueprint_book.version = (0, 0, 0, 0) + blueprint_book.set_version(0, 0, 0, 0) assert blueprint_book.version_tuple() == (0, 0, 0, 0) def test_version_string(self): blueprint_book = BlueprintBook() assert blueprint_book.version_string() == __factorio_version__ - blueprint_book.version = (0, 0, 0, 0) + blueprint_book.set_version(0, 0, 0, 0) assert blueprint_book.version_string() == "0.0.0.0" def test_to_dict(self): - pass + pass # TODO def test_to_string(self): blueprint_book = BlueprintBook() - blueprint_book.version = (1, 1, 53, 0) + blueprint_book.set_version(1, 1, 53, 0) # self.assertEqual( # blueprint_book.to_string(), # "0eNqrVkrKKU0tKMrMK4lPys/PVrKqVsosSc1VskJI6IIldJQSk0syy1LjM/NSUiuUrAx0lMpSi4oz8/OUrIwsDE3MLY3MTQ1NDY3NDGprAVVBHPY=" diff --git a/test/test_deconstruction_planner.py b/test/test_deconstruction_planner.py index ba99b07..57863a7 100644 --- a/test/test_deconstruction_planner.py +++ b/test/test_deconstruction_planner.py @@ -59,7 +59,7 @@ def test_constructor(self): } with pytest.warns(DraftsmanWarning): - DeconstructionPlanner({"something": "incorrect"}) + DeconstructionPlanner({"deconstruction_planner": {"something": "incorrect"}}) def test_set_entity_filter_mode(self): decon_planner = DeconstructionPlanner() diff --git a/test/test_mixins.py b/test/test_mixins.py index cb4ee05..1656be9 100644 --- a/test/test_mixins.py +++ b/test/test_mixins.py @@ -912,8 +912,8 @@ def test_set_request_filter(self): # Errors with pytest.raises(TypeError): storage_chest.set_request_filter("incorrect", "iron-ore", 100) - with pytest.raises(InvalidItemError): - storage_chest.set_request_filter(1, "incorrect", 100) + # with pytest.raises(InvalidItemError): + # storage_chest.set_request_filter(1, "incorrect", 100) with pytest.raises(TypeError): storage_chest.set_request_filter(1, "iron-ore", "incorrect") with pytest.raises(IndexError): @@ -938,8 +938,8 @@ def test_set_request_filters(self): {"index": 1, "name": "iron-ore", "count": 200} ] # Errors - with pytest.raises(InvalidItemError): - storage_chest.set_request_filters([("iron-ore", 200), ("incorrect", 100)]) + # with pytest.raises(InvalidItemError): + # storage_chest.set_request_filters([("iron-ore", 200), ("incorrect", 100)]) # Make sure that filters are unchanged if command fails assert storage_chest.request_filters == [ {"index": 1, "name": "iron-ore", "count": 200} diff --git a/test/test_upgrade_planner.py b/test/test_upgrade_planner.py index 3bf4a18..aa91c32 100644 --- a/test/test_upgrade_planner.py +++ b/test/test_upgrade_planner.py @@ -5,24 +5,27 @@ from draftsman import __factorio_version_info__ from draftsman.classes.upgrade_planner import UpgradePlanner +from draftsman.classes.exportable import ValidationResult +from draftsman.data import entities from draftsman.error import ( IncorrectBlueprintTypeError, MalformedBlueprintStringError, DataFormatError, + InvalidMappingError ) from draftsman import utils -from draftsman.warning import DraftsmanWarning, ValueWarning +from draftsman.warning import ( + DraftsmanWarning, + IndexWarning, + RedundantOperationWarning, + UnrecognizedElementWarning +) -import sys +from pydantic import ValidationError import pytest -if sys.version_info >= (3, 3): # pragma: no coverage - import unittest -else: # pragma: no coverage - import unittest2 as unittest - -class UpgradePlannerTesting(unittest.TestCase): +class TestUpgradePlanner: # test_constructor_cases = deal.cases(UpgradePlanner) def test_constructor(self): @@ -30,7 +33,6 @@ def test_constructor(self): upgrade_planner = UpgradePlanner() assert upgrade_planner.to_dict()["upgrade_planner"] == { "item": "upgrade-planner", - "settings": None, "version": utils.encode_version(*__factorio_version_info__), } @@ -40,7 +42,6 @@ def test_constructor(self): ) assert upgrade_planner.to_dict()["upgrade_planner"] == { "item": "upgrade-planner", - "settings": None, "version": utils.encode_version(1, 1, 61), } @@ -64,8 +65,8 @@ def test_constructor(self): "settings": { "mappers": [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "fast-transport-belt", "type": "item"}, + "from": "transport-belt", + "to": "fast-transport-belt", "index": 0, } ] @@ -75,70 +76,176 @@ def test_constructor(self): # Warnings with pytest.warns(DraftsmanWarning): - UpgradePlanner({"unused": "keyword"}) + UpgradePlanner({"upgrade_planner": {"unused": "keyword"}}) - # # TypeError - # with self.assertRaises(deal.RaisesContractError): - # UpgradePlanner(TypeError) + # Correct format, but incorrect type + with pytest.raises(IncorrectBlueprintTypeError): + UpgradePlanner( + "0eNqrVkrKKU0tKMrMK1GyqlbKLEnNVbJCEtNRKkstKs7Mz1OyMrIwNDG3NDI3sTQ1MTc1rq0FAHmyE1c=" + ) - # # Correct format, but incorrect type - # with self.assertRaises(IncorrectBlueprintTypeError): - # UpgradePlanner( - # "0eNqrVkrKKU0tKMrMK1GyqlbKLEnNVbJCEtNRKkstKs7Mz1OyMrIwNDG3NDI3sTQ1MTc1rq0FAHmyE1c=" - # ) + # Incorrect format + with pytest.raises(MalformedBlueprintStringError): + UpgradePlanner("0lmaothisiswrong") - # # Incorrect format - # with self.assertRaises(MalformedBlueprintStringError): - # UpgradePlanner("0lmaothisiswrong") + def test_description(self): + upgrade_planner = UpgradePlanner() + + # Normal case + upgrade_planner.description = "some description" + assert upgrade_planner.description == "some description" + assert upgrade_planner["settings"]["description"] is upgrade_planner.description + assert upgrade_planner.to_dict()["upgrade_planner"] == { + "item": "upgrade-planner", + "version": utils.encode_version(*__factorio_version_info__), + "settings": { + "description": "some description" + } + } + + # None case + upgrade_planner.description = None + assert upgrade_planner.description == None + assert "description" not in upgrade_planner["settings"] + assert upgrade_planner.to_dict()["upgrade_planner"] == { + "item": "upgrade-planner", + "version": utils.encode_version(*__factorio_version_info__), + } - def test_upgrade_planner(self): + def test_icons(self): upgrade_planner = UpgradePlanner() - assert upgrade_planner.mapper_count == 24 + + # Explicit format + upgrade_planner.icons = [ + { + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"} + } + ] + assert upgrade_planner.icons == [ + { + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"} + } + ] + assert upgrade_planner["settings"]["icons"] is upgrade_planner.icons + assert upgrade_planner.to_dict()["upgrade_planner"] == { + "item": "upgrade-planner", + "version": utils.encode_version(*__factorio_version_info__), + "settings": { + "icons": [ + { + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"} + } + ] + } + } - def test_set_mappers(self): + # None case + upgrade_planner.icons = None + assert upgrade_planner.icons == None + assert "icons" not in upgrade_planner["settings"] + assert upgrade_planner.to_dict()["upgrade_planner"] == { + "item": "upgrade-planner", + "version": utils.encode_version(*__factorio_version_info__), + } + + def test_set_icons(self): upgrade_planner = UpgradePlanner() + + # Single known + upgrade_planner.set_icons("signal-A") + assert upgrade_planner.icons == [ + { + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"} + } + ] + assert upgrade_planner["settings"]["icons"] is upgrade_planner.icons + assert upgrade_planner.to_dict()["upgrade_planner"] == { + "item": "upgrade-planner", + "version": utils.encode_version(*__factorio_version_info__), + "settings": { + "icons": [ + { + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"} + } + ] + } + } - # Test full format - upgrade_planner.mappers = [ + # Multiple known + upgrade_planner.set_icons("signal-A", "signal-B", "signal-C") + assert upgrade_planner.icons == [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "fast-transport-belt", "type": "item"}, - "index": 0, + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"} }, { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "express-transport-belt", "type": "item"}, - "index": 23, + "index": 2, + "signal": {"name": "signal-B", "type": "virtual"} + }, + { + "index": 3, + "signal": {"name": "signal-C", "type": "virtual"} }, ] - assert upgrade_planner.mappers == [ + assert upgrade_planner["settings"]["icons"] is upgrade_planner.icons + assert upgrade_planner.to_dict()["upgrade_planner"] == { + "item": "upgrade-planner", + "version": utils.encode_version(*__factorio_version_info__), + "settings": { + "icons": [ + { + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"} + }, + { + "index": 2, + "signal": {"name": "signal-B", "type": "virtual"} + }, + { + "index": 3, + "signal": {"name": "signal-C", "type": "virtual"} + }, + ] + } + } + + # TODO: errors + + def test_mapper_count(self): + upgrade_planner = UpgradePlanner() + assert upgrade_planner.mapper_count == 24 + + def test_mappers(self): + upgrade_planner = UpgradePlanner() + + # Test full format + upgrade_planner.mappers = [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "fast-transport-belt", "type": "item"}, + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "fast-transport-belt", "type": "entity"}, "index": 0, }, { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "express-transport-belt", "type": "item"}, + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "express-transport-belt", "type": "entity"}, "index": 23, }, ] - - # Test abridged format - upgrade_planner.mappers = [ - ("transport-belt", "fast-transport-belt"), - ("transport-belt", "express-transport-belt"), - ] assert upgrade_planner.mappers == [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "fast-transport-belt", "type": "item"}, + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "fast-transport-belt", "type": "entity"}, "index": 0, }, { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "express-transport-belt", "type": "item"}, - "index": 1, + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "express-transport-belt", "type": "entity"}, + "index": 23, }, ] @@ -147,24 +254,6 @@ def test_set_mappers(self): assert upgrade_planner.mappers == None assert "mappers" not in upgrade_planner._root["settings"] - # Warnings - # Index out of range warning - # with pytest.warns(ValueWarning): - # upgrade_planner.mappers = [ - # { - # "from": {"name": "transport-belt", "type": "item"}, - # "to": {"name": "fast-transport-belt", "type": "item"}, - # "index": 24, - # }, - # ] - - # Errors - with pytest.raises(DataFormatError): - upgrade_planner.mappers = ("incorrect", "incorrect") - - with pytest.raises(DataFormatError): - upgrade_planner.mappers = [TypeError, TypeError] - def test_set_mapping(self): upgrade_planner = UpgradePlanner() upgrade_planner.set_mapping("transport-belt", "fast-transport-belt", 0) @@ -172,64 +261,47 @@ def test_set_mapping(self): assert len(upgrade_planner.mappers) == 2 assert upgrade_planner.mappers == [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "fast-transport-belt", "type": "item"}, + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "fast-transport-belt", "type": "entity"}, "index": 0, }, { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "express-transport-belt", "type": "item"}, + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "express-transport-belt", "type": "entity"}, "index": 1, }, ] - # Test no index - upgrade_planner.set_mapping("inserter", "fast-inserter", 2) + # Test replace + upgrade_planner.set_mapping("transport-belt", "fast-transport-belt", 0) assert upgrade_planner.mappers == [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "fast-transport-belt", "type": "item"}, + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "fast-transport-belt", "type": "entity"}, "index": 0, }, { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "express-transport-belt", "type": "item"}, + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "express-transport-belt", "type": "entity"}, "index": 1, }, - { - "from": {"name": "inserter", "type": "item"}, - "to": {"name": "fast-inserter", "type": "item"}, - "index": 2, - }, ] - # Test duplicate mapping - upgrade_planner.set_mapping("transport-belt", "fast-transport-belt", 0) + # None as argument values at specified index + upgrade_planner.set_mapping(None, None, 1) assert upgrade_planner.mappers == [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "fast-transport-belt", "type": "item"}, + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "fast-transport-belt", "type": "entity"}, "index": 0, }, { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "express-transport-belt", "type": "item"}, "index": 1, }, - { - "from": {"name": "inserter", "type": "item"}, - "to": {"name": "fast-inserter", "type": "item"}, - "index": 2, - }, ] - # Warnings - - # Duplicate indices - # TODO - # Errors - with pytest.raises(DataFormatError): + with pytest.raises(InvalidMappingError): upgrade_planner.set_mapping(TypeError, TypeError, TypeError) # ===================================================================== @@ -240,44 +312,39 @@ def test_set_mapping(self): upgrade_planner.remove_mapping("transport-belt", "fast-transport-belt", 0) assert upgrade_planner.mappers == [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "express-transport-belt", "type": "item"}, "index": 1, }, - { - "from": {"name": "inserter", "type": "item"}, - "to": {"name": "fast-inserter", "type": "item"}, - "index": 2, - }, ] - # Remove no longer existing - upgrade_planner.remove_mapping("transport-belt", "fast-transport-belt", 0) + # Remove missing at index + with pytest.raises(ValueError): + upgrade_planner.remove_mapping("transport-belt", "fast-transport-belt", 0) assert upgrade_planner.mappers == [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "express-transport-belt", "type": "item"}, "index": 1, }, + ] + + # Remove missing at any index + with pytest.raises(ValueError): + upgrade_planner.remove_mapping("transport-belt", "fast-transport-belt") + assert upgrade_planner.mappers == [ { - "from": {"name": "inserter", "type": "item"}, - "to": {"name": "fast-inserter", "type": "item"}, - "index": 2, + "index": 1, }, ] - # Remove first occurence of duplicates + # Remove first occurence of multiple + upgrade_planner.set_mapping("inserter", "fast-inserter", 2) upgrade_planner.set_mapping("inserter", "fast-inserter", 3) upgrade_planner.remove_mapping("inserter", "fast-inserter") assert upgrade_planner.mappers == [ { - "from": {"name": "transport-belt", "type": "item"}, - "to": {"name": "express-transport-belt", "type": "item"}, "index": 1, }, { - "from": {"name": "inserter", "type": "item"}, - "to": {"name": "fast-inserter", "type": "item"}, + "from": {"name": "inserter", "type": "entity"}, + "to": {"name": "fast-inserter", "type": "entity"}, "index": 3, }, ] @@ -289,24 +356,307 @@ def test_set_mapping(self): # upgrade_planner.remove_mapping("inserter", "fast-inserter", 24) # Errors - with pytest.raises(DataFormatError): + with pytest.raises(InvalidMappingError): upgrade_planner.remove_mapping("inserter", "incorrect") + with pytest.raises(ValueError): + upgrade_planner.remove_mapping("inserter", "fast-inserter", "incorrect") + + def test_pop_mapping(self): + upgrade_planner = UpgradePlanner() + + upgrade_planner.mappers = [ + { + "to": {"name": "transport-belt", "type": "entity"}, + "from": {"name": "express-transport-belt", "type": "entity"}, + "index": 1 + }, + { + "to": {"name": "assembling-machine-1", "type": "entity"}, + "from": {"name": "assembling-machine2", "type": "entity"}, + "index": 1 + }, + { + "to": {"name": "transport-belt", "type": "entity"}, + "from": {"name": "fast-transport-belt", "type": "entity"}, + "index": 0 + }, + ] + + # Remove mapping with index 0 + upgrade_planner.pop_mapping(0) + assert upgrade_planner.mappers == [ + { + "to": {"name": "transport-belt", "type": "entity"}, + "from": {"name": "express-transport-belt", "type": "entity"}, + "index": 1 + }, + { + "to": {"name": "assembling-machine-1", "type": "entity"}, + "from": {"name": "assembling-machine2", "type": "entity"}, + "index": 1 + }, + ] + + # Remove first mapping with specified index + upgrade_planner.pop_mapping(1) + assert upgrade_planner.mappers == [ + { + "to": {"name": "assembling-machine-1", "type": "entity"}, + "from": {"name": "assembling-machine2", "type": "entity"}, + "index": 1 + }, + ] + + # Remove mapping with index not in mappers + with pytest.raises(ValueError): + upgrade_planner.pop_mapping(10) + assert upgrade_planner.mappers == [ + { + "to": {"name": "assembling-machine-1", "type": "entity"}, + "from": {"name": "assembling-machine2", "type": "entity"}, + "index": 1 + }, + ] + + def test_validate(self): + upgrade_planner = UpgradePlanner() + + # Empty should validate + upgrade_planner.validate() + + # Ensure early-exit is_valid caching works + upgrade_planner.validate() + + # Errors + # TODO: more + with pytest.raises(ValidationError): + upgrade_planner.mappers = [{"from": "transport-belt", "to": "transport-belt", "index": 1}] + upgrade_planner.validate() + + with pytest.raises(ValidationError): + upgrade_planner.mappers = ("incorrect", "incorrect") + upgrade_planner.validate() + + with pytest.raises(ValidationError): + upgrade_planner.mappers = [TypeError, TypeError] + upgrade_planner.validate() + def test_inspect(self): + # Test validation failure upgrade_planner = UpgradePlanner() + upgrade_planner.set_mapping("transport-belt", "transport-belt", -1) + validation_result = upgrade_planner.inspect() + assert len(validation_result.error_list) > 0 + with pytest.raises(DataFormatError): + validation_result.reissue_all() - # Out of index - with pytest.warns(ValueWarning): - upgrade_planner.set_mapping("transport-belt", "fast-transport-belt", -1) + # Redundant mapping + upgrade_planner = UpgradePlanner() + upgrade_planner.set_mapping("transport-belt", "transport-belt", 1) + goal = ValidationResult( + error_list=[], + warning_list=[ + RedundantOperationWarning( + "Mapping entity/item 'transport-belt' to itself has no effect" + ) + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(RedundantOperationWarning): + validation_result.reissue_all() - with pytest.warns(ValueWarning): - upgrade_planner.set_mapping("fast-transport-belt", "express-transport-belt", 24) + # Normal upgrade_case + upgrade_planner = UpgradePlanner() + upgrade_planner.set_mapping("transport-belt", "fast-transport-belt", 0) + goal = ValidationResult(error_list=[], warning_list=[]) + validation_result = upgrade_planner.inspect() + assert validation_result == goal - goal = [ - ValueWarning( - "'index' must be in range [0, 24) for mapping between '{'name': 'transport-belt', 'type': 'item'}' and '{'name': 'fast-transport-belt', 'type': 'item'}'" - ) + # Unrecognized mapping names + upgrade_planner = UpgradePlanner() + upgrade_planner.set_mapping( + {"name": "unrecognized-A", "type": "entity"}, + {"name": "unrecognized-B", "type": "entity"}, + 0 + ) + goal = ValidationResult( + error_list=[], + warning_list=[ + UnrecognizedElementWarning( + "Unrecognized entity/item 'unrecognized-A'" + ), + UnrecognizedElementWarning( + "Unrecognized entity/item 'unrecognized-B'" + ), + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(UnrecognizedElementWarning): + validation_result.reissue_all() + + # dummy entity for testing purposes + + + # "not-upgradable" flag in from + upgrade_planner = UpgradePlanner() + entities.raw["dummy-entity-1"] = {"name": "dummy-entity-1"} + entities.raw["dummy-entity-1"]["flags"] = {"not-upgradable"} + upgrade_planner.set_mapping("dummy-entity-1", "fast-transport-belt", 0) + goal = ValidationResult( + error_list=[], + warning_list=[ + DraftsmanWarning( + "'dummy-entity-1' is not upgradable" + ) + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(DraftsmanWarning): + validation_result.reissue_all() + + # from is not minable + upgrade_planner = UpgradePlanner() + entities.raw["dummy-entity-2"] = {"name": "dummy-entity-2"} + upgrade_planner.set_mapping("dummy-entity-2", "fast-transport-belt", 0) + goal = ValidationResult( + error_list=[], + warning_list=[ + DraftsmanWarning( + "'dummy-entity-2' is not minable" + ) + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(DraftsmanWarning): + validation_result.reissue_all() + + # All mining results must not be hidden + upgrade_planner = UpgradePlanner() + entities.raw["dummy-entity-3"] = { + "name": "dummy-entity-3", + "minable": {"results": [{"name": "rocket-part", "amount": 1}]} + } + upgrade_planner.set_mapping("dummy-entity-3", "fast-transport-belt", 0) + goal = ValidationResult( + error_list=[], + warning_list=[ + DraftsmanWarning( + "Returned item 'rocket-part' when upgrading 'dummy-entity-3' is hidden" + ) + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(DraftsmanWarning): + validation_result.reissue_all() + + # Cannot upgrade rolling stock + upgrade_planner = UpgradePlanner() + upgrade_planner.set_mapping("cargo-wagon", "fluid-wagon", 0) + goal = ValidationResult( + error_list=[], + warning_list=[ + DraftsmanWarning( + "Cannot upgrade 'cargo-wagon' because it is RollingStock" + ) + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(DraftsmanWarning): + validation_result.reissue_all() + + # Differing collision boxes + upgrade_planner = UpgradePlanner() + upgrade_planner.set_mapping("transport-belt", "electric-furnace", 0) + goal = ValidationResult( + error_list=[], + warning_list=[ + DraftsmanWarning( + "Cannot upgrade 'transport-belt' to 'electric-furnace'; collision boxes differ" + ) + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(DraftsmanWarning): + validation_result.reissue_all() + + # Differing collision masks + upgrade_planner = UpgradePlanner() + upgrade_planner.set_mapping("gate", "stone-wall", 0) + goal = ValidationResult( + error_list=[], + warning_list=[ + DraftsmanWarning( + "Cannot upgrade 'gate' to 'stone-wall'; collision masks differ" + ) + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(DraftsmanWarning): + validation_result.reissue_all() + + # Differing fast replacable group + upgrade_planner = UpgradePlanner() + upgrade_planner.set_mapping("radar", "pumpjack", 0) + goal = ValidationResult( + error_list=[], + warning_list=[ + DraftsmanWarning( + "Cannot upgrade 'radar' to 'pumpjack'; fast replacable groups differ" + ) + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(DraftsmanWarning): + validation_result.reissue_all() + + + # Index outside of meaningful range + upgrade_planner = UpgradePlanner() + upgrade_planner.set_mapping("fast-transport-belt", "express-transport-belt", 24) + goal = ValidationResult( + error_list=[], + warning_list=[ + IndexWarning( + "'index' (24) for mapping 'fast-transport-belt' to 'express-transport-belt' must be in range [0, 24) or else it will have no effect" + ) + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(IndexWarning): + validation_result.reissue_all() + + # Multiple mappings sharing the same index + upgrade_planner = UpgradePlanner() + upgrade_planner.mappers = [ + { + "index": 0 + }, + { + "index": 0 + } ] - result = upgrade_planner.inspect() - for i in range(len(result)): - assert type(goal[i]) == type(result[i]) and goal[i].args == result[i].args + goal = ValidationResult( + error_list=[], + warning_list=[ + IndexWarning( + "Mapping at index 0 was overwritten 1 time(s); final mapping is 'None' to 'None'" + ), + ] + ) + validation_result = upgrade_planner.inspect() + assert validation_result == goal + with pytest.warns(IndexWarning): + validation_result.reissue_all() + +