mirror of
https://github.com/Dasharo/roadmapper.git
synced 2026-06-13 19:16:15 -07:00
Add Alignment class, refactor milestone to use it.
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
from dataclasses import asdict, astuple, dataclass
|
||||
from enum import Enum, EnumMeta
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
StrOrAlignment = Union[str, "Alignment"]
|
||||
|
||||
|
||||
class CaseInsensitiveEnumMeta(EnumMeta):
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, str):
|
||||
key = key.upper()
|
||||
return super().__getitem__(key)
|
||||
|
||||
|
||||
class AlignmentDirection(Enum, metaclass=CaseInsensitiveEnumMeta):
|
||||
CENTER = 1
|
||||
CENTRE = 1
|
||||
LEFT = 2
|
||||
RIGHT = 3
|
||||
|
||||
|
||||
class OffsetType(Enum, metaclass=CaseInsensitiveEnumMeta):
|
||||
UNIT = 1
|
||||
PERCENT = 2
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Alignment:
|
||||
direction: AlignmentDirection = (AlignmentDirection.CENTER,)
|
||||
offset_type: Optional[OffsetType] = None
|
||||
offset: Optional[Union[int, float]] = None
|
||||
|
||||
@classmethod
|
||||
def from_value(
|
||||
cls,
|
||||
alignment: Optional[StrOrAlignment],
|
||||
default_offset_type: Optional[OffsetType] = None,
|
||||
default_offset: Optional[float] = None,
|
||||
) -> "Alignment":
|
||||
if alignment is None:
|
||||
return cls(offset_type=default_offset_type, offset=default_offset)
|
||||
if isinstance(alignment, Alignment):
|
||||
return cls.from_alignment(alignment)
|
||||
if isinstance(alignment, str):
|
||||
return cls.from_string(alignment)
|
||||
else:
|
||||
raise ValueError(
|
||||
'Invalid argument "alignment": expected None, str, or Alignment instance,'
|
||||
f" got {type(alignment).__name__}."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_alignment(cls, alignment: "Alignment") -> "Alignment":
|
||||
kwargs = asdict(alignment)
|
||||
new = cls(**kwargs)
|
||||
return new
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, alignment: str) -> "Alignment":
|
||||
new = cls()
|
||||
new.update_from_alignment_string(alignment)
|
||||
return new
|
||||
|
||||
@staticmethod
|
||||
def parse_offset(offset: str) -> Tuple[Union[int, float], OffsetType]:
|
||||
if offset.endswith("%"):
|
||||
return (float(offset[:-1]) / 100, OffsetType.PERCENT)
|
||||
else:
|
||||
return (int(offset), OffsetType.UNIT)
|
||||
|
||||
def update_from_alignment_string(self, alignment: str) -> None:
|
||||
parts = alignment.split(":")
|
||||
|
||||
try:
|
||||
self.direction = AlignmentDirection[parts[0]]
|
||||
except KeyError as e:
|
||||
raise ValueError(
|
||||
f'Invalid alignment direction "{parts[0]}".'
|
||||
f" Valid alignment directions are {[d.name for d in AlignmentDirection]}"
|
||||
) from e
|
||||
|
||||
if len(parts) == 2:
|
||||
self.offset, self.offset_type = self.parse_offset(parts[1])
|
||||
|
||||
def as_tuple(
|
||||
self,
|
||||
) -> Tuple[AlignmentDirection, Optional[OffsetType], Optional[Union[int, float]]]:
|
||||
return astuple(self)
|
||||
|
||||
def percent_of(self, whole: Union[int, float]) -> float:
|
||||
if self.offset_type != OffsetType.PERCENT:
|
||||
raise ValueError("Cannot return percent_of when offset_type != 'PERCENT'")
|
||||
return whole * self.offset
|
||||
|
||||
def __str__(self):
|
||||
offset_str = ""
|
||||
if self.offset is not None:
|
||||
offset_str = ":"
|
||||
offset_str += (
|
||||
f"{self.offset * 100}%"
|
||||
if self.offset_type == OffsetType.PERCENT
|
||||
else str(self.offset)
|
||||
)
|
||||
return f"{self.direction.name.lower()}{offset_str}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result1 = Alignment.from_value("left:50%")
|
||||
print(result1)
|
||||
|
||||
result2 = Alignment.from_value(result1)
|
||||
print(result2.as_tuple())
|
||||
|
||||
result3 = Alignment.from_value("Center")
|
||||
print(result3)
|
||||
|
||||
# expect ValueError about type of argument.
|
||||
Alignment.from_value(1)
|
||||
|
||||
# Expect ValueError about alignment direction.
|
||||
Alignment.from_value("widdershins:50")
|
||||
+27
-30
@@ -20,8 +20,11 @@
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
|
||||
from .alignment import Alignment, AlignmentDirection, OffsetType
|
||||
from .painter import Painter
|
||||
|
||||
|
||||
@@ -35,7 +38,7 @@ class Milestone:
|
||||
font_size: int = field(init=True, default=None)
|
||||
font_colour: str = field(init=True, default=None)
|
||||
fill_colour: str = field(init=True, default=None)
|
||||
text_alignment: str = field(init=True, default=None)
|
||||
text_alignment: Union[str, Alignment] = field(init=True, default=None)
|
||||
|
||||
diamond_x: int = field(init=False, default=0)
|
||||
diamond_y: int = field(init=False, default=0)
|
||||
@@ -59,36 +62,13 @@ class Milestone:
|
||||
self.fill_colour,
|
||||
)
|
||||
|
||||
alignment = (
|
||||
self.text_alignment.lower() if self.text_alignment is not None else None
|
||||
alignment = Alignment.from_value(
|
||||
alignment=self.text_alignment,
|
||||
default_offset_type=OffsetType.PERCENT,
|
||||
default_offset=0.5,
|
||||
)
|
||||
|
||||
if alignment is None or alignment == "centre":
|
||||
pass # Text is already "centre" if we change nothing
|
||||
elif "left" in alignment or "right" in alignment: # Handle left/right
|
||||
text_width, _ = painter.get_text_dimension(
|
||||
text=self.text, font=self.font, font_size=self.font_size
|
||||
)
|
||||
|
||||
if ":" in alignment: # Split if ":" in str
|
||||
alignment, modifier = alignment.split(":")
|
||||
|
||||
if "%" in modifier: # If "%" treat as percent of text_width
|
||||
offset = (int(modifier.strip("%")) / 100) * text_width
|
||||
else: # Treat as units
|
||||
offset = int(modifier)
|
||||
else: # If no ":", default to half of text width
|
||||
offset = 0.5 * text_width
|
||||
|
||||
if alignment == "right":
|
||||
self.text_x += offset
|
||||
elif alignment == "left":
|
||||
self.text_x -= offset
|
||||
else:
|
||||
raise ValueError(
|
||||
'text_alignment must be: "centre", "left", "right" or None.'
|
||||
f"\n\tGot {self.text_alignment=}"
|
||||
)
|
||||
self.apply_offset(alignment=alignment, painter=painter)
|
||||
|
||||
if (self.text_x != 0) and (self.text_y != 0):
|
||||
painter.draw_text(
|
||||
@@ -99,3 +79,20 @@ class Milestone:
|
||||
self.font_size,
|
||||
self.font_colour,
|
||||
)
|
||||
|
||||
def apply_offset(self, alignment: Alignment, painter: Painter) -> None:
|
||||
direction, offset_type, offset = alignment.as_tuple()
|
||||
|
||||
if direction is None or direction == AlignmentDirection.CENTER:
|
||||
return # Center does not require an offset
|
||||
|
||||
if offset_type == OffsetType.PERCENT:
|
||||
text_width, _ = painter.get_text_dimension(
|
||||
text=self.text, font=self.font, font_size=self.font_size
|
||||
)
|
||||
offset = alignment.percent_of(text_width)
|
||||
|
||||
if direction == AlignmentDirection.RIGHT:
|
||||
self.text_x += offset
|
||||
elif alignment == AlignmentDirection.LEFT:
|
||||
self.text_x -= offset
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import pytest
|
||||
|
||||
from src.roadmapper.alignment import Alignment, AlignmentDirection, OffsetType
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_case_insensitivity():
|
||||
assert AlignmentDirection["center"] == AlignmentDirection.CENTER
|
||||
assert AlignmentDirection["LEFT"] == AlignmentDirection.LEFT
|
||||
assert OffsetType["unit"] == OffsetType.UNIT
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_enum_direction_synonyms():
|
||||
assert AlignmentDirection.CENTRE == AlignmentDirection.CENTER
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_alignment_from_string():
|
||||
alignment = Alignment.from_value("left:50%")
|
||||
assert alignment.direction == AlignmentDirection.LEFT
|
||||
assert alignment.offset_type == OffsetType.PERCENT
|
||||
assert alignment.offset == 0.5
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_from_string():
|
||||
alignment = Alignment.from_string("right:30%")
|
||||
assert alignment.direction == AlignmentDirection.RIGHT
|
||||
assert alignment.offset_type == OffsetType.PERCENT
|
||||
assert alignment.offset == 0.3
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_from_alignment():
|
||||
alignment = Alignment(direction=AlignmentDirection.LEFT)
|
||||
new_alignment = Alignment.from_alignment(alignment)
|
||||
assert new_alignment.direction == AlignmentDirection.LEFT
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_parse_offset():
|
||||
offset, offset_type = Alignment.parse_offset("50%")
|
||||
assert offset == 0.5
|
||||
assert offset_type == OffsetType.PERCENT
|
||||
|
||||
offset, offset_type = Alignment.parse_offset("30")
|
||||
assert offset == 30
|
||||
assert offset_type == OffsetType.UNIT
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_from_alignment_string():
|
||||
alignment = Alignment()
|
||||
alignment.update_from_alignment_string("centre:20%")
|
||||
assert alignment.direction == AlignmentDirection.CENTRE
|
||||
assert alignment.offset_type == OffsetType.PERCENT
|
||||
assert alignment.offset == 0.2
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_alignment_from_alignment_object():
|
||||
original_alignment = Alignment(
|
||||
direction=AlignmentDirection.RIGHT, offset_type=OffsetType.UNIT, offset=10
|
||||
)
|
||||
new_alignment = Alignment.from_value(original_alignment)
|
||||
assert new_alignment.direction == AlignmentDirection.RIGHT
|
||||
assert new_alignment.offset_type == OffsetType.UNIT
|
||||
assert new_alignment.offset == 10
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_as_tuple():
|
||||
alignment = Alignment(
|
||||
direction=AlignmentDirection.RIGHT, offset_type=OffsetType.UNIT, offset=15
|
||||
)
|
||||
assert alignment.as_tuple() == (AlignmentDirection.RIGHT, OffsetType.UNIT, 15)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_percent_of():
|
||||
alignment = Alignment(offset_type=OffsetType.PERCENT, offset=0.5)
|
||||
assert alignment.percent_of(100) == 50
|
||||
|
||||
alignment = Alignment(offset_type=OffsetType.UNIT, offset=30)
|
||||
with pytest.raises(ValueError):
|
||||
alignment.percent_of(100)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_direction():
|
||||
with pytest.raises(ValueError):
|
||||
Alignment.from_value("widdershins:50")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_invalid_type():
|
||||
with pytest.raises(ValueError):
|
||||
Alignment.from_value(1)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_str_method():
|
||||
alignment = Alignment(
|
||||
direction=AlignmentDirection.LEFT, offset_type=OffsetType.PERCENT, offset=0.25
|
||||
)
|
||||
assert str(alignment) == "left:25.0%"
|
||||
|
||||
alignment = Alignment(
|
||||
direction=AlignmentDirection.RIGHT, offset_type=OffsetType.UNIT, offset=10
|
||||
)
|
||||
assert str(alignment) == "right:10"
|
||||
@@ -1,5 +1,7 @@
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from src.roadmapper.milestone import Milestone
|
||||
|
||||
|
||||
@@ -60,7 +62,6 @@ def painter():
|
||||
return MockPainter()
|
||||
|
||||
|
||||
# Text test cases
|
||||
@pytest.mark.parametrize("text_alignment", [None, "centre"])
|
||||
def test_draw_milestone_centre_alignment(milestone, painter, text_alignment):
|
||||
milestone.text_alignment = text_alignment
|
||||
|
||||
Reference in New Issue
Block a user