Compare commits

...
Author SHA1 Message Date
aclist c15b77d470 chore: add comments regarding time interpolation
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-09-08 05:07:41 +09:00
aclist cee1e3b8af chore: move comment 2026-09-08 02:58:39 +09:00
aclist e8ccdd7cbb fix: local clock drives virtual clock 2026-09-08 02:56:24 +09:00
aclist 2629fe254e fix: superclass type collision 2026-09-08 02:29:04 +09:00
aclist 3b15bf58e7 feat: add time of day icon 2026-09-08 02:28:04 +09:00
aclist e773a4909b fix: restore adjusted time to current time on reset 2026-09-08 01:02:23 +09:00
aclist 95380681f9 chore: update test to new Details dataclass format 2026-09-08 00:51:26 +09:00
aclist 18192a4667 chore: remove debug mark 2026-09-08 00:46:22 +09:00
aclist f07a2dea5f chore: add calculator reference test 2026-09-08 00:45:30 +09:00
aclist 0e58cf514e fix: date traversal logic 2026-09-08 00:45:07 +09:00
aclist 84c6a3868b change: normalize time boundaries 2026-09-08 00:44:32 +09:00
aclist 7c0981981f chore: drop unused import 2026-09-08 00:43:48 +09:00
aclist e653c61955 chore: remove debug mark 2026-09-08 00:43:27 +09:00
aclist d4bf9f71f6 chore: clear typehinting errors 2026-09-04 19:28:12 +09:00
aclist cb5c233f45 change: simplify calculation logic 2026-09-04 19:25:37 +09:00
aclist ac286f4a72 chore: add metadata test
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-09-04 04:00:34 +09:00
aclist e254821105 change: update time range assertions 2026-09-04 01:03:22 +09:00
aclist 32b8c5cbc5 chore: remove resolved comments 2026-09-04 00:40:25 +09:00
aclist 76ed7f2451 change: use greater precision only when setting target time 2026-09-04 00:38:37 +09:00
aclist f135f6a35d drop: GenericAdjustedClock 2026-09-03 23:26:45 +09:00
aclist eb5d5d5181 fix: reset time on subsequent invocations 2026-09-03 23:26:01 +09:00
aclist fef8c69f38 change: synchronize local and remainder clocks 2026-09-03 23:21:11 +09:00
11 changed files with 316 additions and 149 deletions

No files matched your search

+2
View File
@@ -55,6 +55,8 @@ VIEW_CONCEAL = "view-conceal-symbolic"
VIEW_REVEAL = "view-reveal-symbolic" VIEW_REVEAL = "view-reveal-symbolic"
WARNING = "dialog-warning-symbolic" WARNING = "dialog-warning-symbolic"
WEB_BROWSER = "web-browser-symbolic" WEB_BROWSER = "web-browser-symbolic"
DAY_ICON = "weather-clear-symbolic"
NIGHT_ICON = "weather-clear-night-symbolic"
SEPARATOR = "SEPARATOR" SEPARATOR = "SEPARATOR"
+2 -2
View File
@@ -32,10 +32,10 @@ class FilterManager:
# TODO: strings # TODO: strings
self.tooltips = { self.tooltips = {
strings.filter_1pp: "First-person perspective", strings.filter_1pp: "First-person perspective",
strings.filter_day: "In-game time between 0700 and 1659", strings.filter_day: "In-game time between 0600 and 1759",
strings.filter_empty: "Servers with no players", strings.filter_empty: "Servers with no players",
strings.filter_3pp: "Third-person perspective", strings.filter_3pp: "Third-person perspective",
strings.filter_night: "In-game time between 1700 and 0659", strings.filter_night: "In-game time between 1800 and 0559",
strings.filter_full: "Servers with no open slots", strings.filter_full: "Servers with no open slots",
strings.filter_official: "Bohemia official servers", strings.filter_official: "Bohemia official servers",
strings.filter_nonascii: "Server names using non-standard, complex glyphs", strings.filter_nonascii: "Server names using non-standard, complex glyphs",
+2 -2
View File
@@ -12,8 +12,8 @@ if TYPE_CHECKING:
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.model.servers import NewPlayerCount from dzgui.model.servers import NewPlayerCount
DAY_REG = r"([0][7-9]|[1][0-6])" DAY_REG = r"([0][6-9]|[1][0-7])"
NIGHT_REG = r"([0][0-6]|[1][7-9]|[2][0-3])" NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])"
class ProxyModelManager: class ProxyModelManager:
+3
View File
@@ -9,3 +9,6 @@ class Icon(Gtk.Image):
super().__init__( super().__init__(
icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin
) )
def set_icon_name(self, name: str) -> None:
super().set_from_icon_name(name, Gtk.IconSize.BUTTON)
+140 -137
View File
@@ -3,17 +3,19 @@ from dataclasses import dataclass
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from typing import Literal from typing import Literal
from dzgui.views.components.labels import BoldLabel from dzgui.const.constants import DAY_ICON, NIGHT_ICON
from dzgui.strings import calc from dzgui.strings import calc
from dzgui.views.components.box import VBox from dzgui.views.components.icon import Icon
from dzgui.views.components.labels import BoldLabel
from dzgui.views.components.box import VBox, HBox
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402 from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402
DAY_START_TIME = time(hour=6, minute=0, second=0) DAY_START_TIME = time(hour=6, minute=0, second=0)
DAY_END_TIME = time(hour=17, minute=59, second=59) DAY_END_TIME = time(hour=18, minute=0, second=0)
NIGHT_START_TIME = time(hour=18, minute=0, second=0) NIGHT_START_TIME = time(hour=18, minute=0, second=0)
NIGHT_END_TIME = time(hour=5, minute=59, second=59) NIGHT_END_TIME = time(hour=6, minute=0, second=0)
EXTENDED_TIME_FORMAT = "%H:%M:%S" EXTENDED_TIME_FORMAT = "%H:%M:%S"
@@ -44,6 +46,10 @@ class Emitter(GObject.GObject):
def target_time_changed(self, time: timedelta) -> None: def target_time_changed(self, time: timedelta) -> None:
pass pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def target_time_reset(self, time: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def remaining_time_changed(self, time: datetime) -> None: def remaining_time_changed(self, time: datetime) -> None:
pass pass
@@ -57,9 +63,9 @@ class LocalClock:
def __init__(self, emitter: Emitter) -> None: def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter self.emitter = emitter
self.start_time = datetime.now() self.start_time = datetime.now()
# NOTE: highest theoretical accel interval is 24 * 64
GLib.timeout_add(500, self.check_time) GLib.timeout_add(500, self.check_time)
# FIXME: sometimes skips 2s
def get_time(self) -> str: def get_time(self) -> str:
return datetime.now().time().strftime(EXTENDED_TIME_FORMAT) return datetime.now().time().strftime(EXTENDED_TIME_FORMAT)
@@ -88,158 +94,136 @@ class ServerClock:
self.start_time = datetime.combine(datetime.today(), time(hour, minute)) self.start_time = datetime.combine(datetime.today(), time(hour, minute))
self.cur_time = datetime.combine(datetime.today(), time(hour, minute)) self.cur_time = datetime.combine(datetime.today(), time(hour, minute))
# NOTE: at highest possible accel interval (64 * 64), real time is 0.876 seconds self._last_elapsed = self.local_clock.get_elapsed_time()
if self.is_night(): self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
GLib.timeout_add(500, self.increment_night)
else:
GLib.timeout_add(500, self.increment_day)
@staticmethod @staticmethod
def split_time(server_time: Time) -> tuple[int, int]: def split_time(server_time: Time) -> tuple[int, int]:
split = server_time.time.split(":") split = server_time.time.split(":")
return int(split[0]), int(split[1]) return int(split[0]), int(split[1])
def _increment_with_offset(self, offset: float) -> None: def is_day(self) -> bool:
elapsed = self.local_clock.get_elapsed_time() if DAY_START_TIME <= self.cur_time.time() <= DAY_END_TIME:
virtual_elapsed = elapsed * offset return True
self.cur_time = self.start_time + virtual_elapsed return False
def _on_local_time_incremented(
self,
emitter: Emitter,
time: datetime,
elapsed: timedelta,
) -> Literal[True]:
delta = elapsed - self._last_elapsed
self._last_elapsed = elapsed
factor = self.get_accel_factor(self.cur_time)
self.cur_time += delta * factor
self.emitter.emit("server_time_incremented", self.cur_time) self.emitter.emit("server_time_incremented", self.cur_time)
def increment_day(self) -> bool:
if self.is_night():
GLib.timeout_add(500, self.increment_night)
return False
offset = self.day_accel
self._increment_with_offset(offset)
return True return True
def increment_night(self) -> bool: def get_accel_factor(self, dt: datetime) -> float:
if not self.is_night():
GLib.timeout_add(500, self.increment_day)
return False
offset = self.night_accel * self.day_accel
self._increment_with_offset(offset)
return True
def is_night(self) -> bool:
return ( return (
self.cur_time.time() >= NIGHT_START_TIME self.day_accel
or self.cur_time.time() <= NIGHT_END_TIME if DAY_START_TIME <= dt.time() < DAY_END_TIME
else (self.night_accel * self.day_accel)
) )
def get_values(self) -> tuple[int, int]: def get_values(self) -> tuple[int, int]:
return self.cur_time.hour, self.cur_time.minute return self.cur_time.hour, self.cur_time.minute
def get_datetime(self) -> datetime:
return self.cur_time
def get_time(self) -> time: def get_time(self) -> time:
return self.cur_time.time() return self.cur_time.time()
def get_cycle_remainder(self, accel: bool = True) -> float: def calc_delta(self, end_time: datetime) -> timedelta:
# NOTE: calc from cycle start time to current point total = 0.0
if self.is_night(): current_time = self.cur_time
cutoff = DAY_START_TIME
offset = self.day_accel * self.night_accel
else:
cutoff = NIGHT_START_TIME
offset = self.day_accel
start = datetime.combine(self.cur_time, cutoff)
if accel is False: if current_time > end_time:
offset = 1 end_time += timedelta(days=1)
return (start - self.cur_time).total_seconds() / offset
def get_full_cycle(self, end_time: time, start_time: time) -> float: while current_time < end_time:
end = datetime.combine(self.cur_time, end_time) factor = self.get_accel_factor(current_time)
start = datetime.combine(self.cur_time, start_time) day_start = datetime.combine(current_time.date(), DAY_START_TIME)
if start > end: day_end = datetime.combine(current_time.date(), DAY_END_TIME)
end += timedelta(days=1)
return (end - start).total_seconds()
def get_day_cycle(self) -> float: if current_time.time() < DAY_START_TIME:
return self.get_full_cycle(DAY_END_TIME, DAY_START_TIME) edge = day_start
elif current_time.time() < DAY_END_TIME:
def get_night_cycle(self) -> float: edge = day_end
return self.get_full_cycle(NIGHT_END_TIME, NIGHT_START_TIME)
def get_delta(self, picker_time: time, start_time: time) -> float:
start = datetime.combine(self.cur_time, start_time)
picker_date = datetime.combine(self.cur_time, picker_time)
if start > picker_date:
picker_date += timedelta(days=1)
delta = (picker_date - start).total_seconds()
return delta
def get_next_cycle(self) -> float:
if self.is_night():
return self.get_day_cycle() / self.day_accel
else:
return self.get_night_cycle() / (self.day_accel * self.night_accel)
def get_remaining_delta(self, picker_time: time, is_night: bool) -> float:
if is_night:
start_time = NIGHT_START_TIME
offset = self.day_accel * self.night_accel
else:
start_time = DAY_START_TIME
offset = self.day_accel
return self.get_delta(picker_time, start_time) / offset
def calc_delta(self, picker_time: time, is_night: bool) -> timedelta:
# NOTE: time spans two full cycles
if self.get_time() > picker_time:
to_end = self.get_cycle_remainder()
next_cycle = self.get_next_cycle()
delta = self.get_remaining_delta(picker_time, is_night)
total = to_end + next_cycle + delta
else:
# TODO: fix calculation
picker_date = datetime.combine(self.cur_time, picker_time)
# NOTE: strip sub-minute values
new = self.cur_time.replace(second=0, microsecond=0)
delta = (picker_date - new).total_seconds()
remainder_till_cycle_end = self.get_cycle_remainder(accel=False)
if delta <= remainder_till_cycle_end:
if self.is_night():
total = delta / (self.day_accel * self.night_accel)
else:
total = delta / self.day_accel
else: else:
total = self.get_remaining_delta(picker_time, is_night) edge = datetime.combine(
current_time.date() + timedelta(days=1), DAY_START_TIME
)
total = round(total) chunk_end = min(end_time, edge)
td = timedelta(seconds=total) total += (chunk_end - current_time).total_seconds() / factor
return td current_time = chunk_end
return timedelta(seconds=total)
class GenericAdjustedClock: class RemainderClock:
def __init__(self, emitter: Emitter, signal: str) -> None: def __init__(self, emitter: Emitter) -> None:
self.signal = signal
self.emitter = emitter self.emitter = emitter
self.emitter.connect("target_time_changed", self._on_target_time_changed) self.emitter.connect("target_time_changed", self._on_target_time_changed)
self.emitter.connect("target_time_reset", self._on_target_time_reset)
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
self.reset_time(True)
self.previous = timedelta(0)
def reset_time(self, zero_out: bool = False) -> None:
now = datetime.now()
second, ms = (0, 0) if zero_out else (now.second, now.microsecond)
self.time = datetime.combine(
now, time(hour=0, minute=0, second=second, microsecond=ms)
)
def _on_local_time_incremented(
self, emitter: Emitter, time: datetime, elapsed: timedelta
) -> None:
if self.time == datetime.min.time():
return
delta = elapsed - self.previous
# NOTE: clamp to midnight
self.time = max(
self.time - delta, self.time.replace(hour=0, minute=0, second=0)
)
self.previous = elapsed
self.emitter.emit("remaining_time_changed", self.time)
def _on_target_time_reset(self, emitter: Emitter, time: timedelta) -> None:
self.reset_time(True)
self.emitter.emit("remaining_time_changed", self.time)
# NOTE: restore adjusted time to current time
self.emitter.emit("adjusted_local_time_changed", datetime.now())
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None: def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
if isinstance(self, AdjustedClock): self.reset_time(True)
self.start_time = datetime.now() self.time += time
self.emitter.emit("remaining_time_changed", self.time)
def get_time(self) -> str:
return self.time.strftime(EXTENDED_TIME_FORMAT)
class AdjustedClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.emitter.connect("target_time_changed", self._on_target_time_changed)
self.start_time = datetime.now()
def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None:
self.start_time = datetime.now()
adjusted = self.start_time + time adjusted = self.start_time + time
self.emitter.emit(self.signal, adjusted) self.emitter.emit("adjusted_local_time_changed", adjusted)
def get_time(self) -> str: def get_time(self) -> str:
return self.start_time.strftime(EXTENDED_TIME_FORMAT) return self.start_time.strftime(EXTENDED_TIME_FORMAT)
class RemainderClock(GenericAdjustedClock):
def __init__(self, emitter: Emitter) -> None:
super().__init__(emitter, signal="remaining_time_changed")
self.start_time = datetime.combine(
datetime.now(), time(hour=0, minute=0, second=0)
)
class AdjustedClock(GenericAdjustedClock):
def __init__(self, emitter: Emitter) -> None:
super().__init__(emitter, signal="adjusted_local_time_changed")
self.start_time = datetime.now()
class VerticalSpinBox(Gtk.SpinButton): class VerticalSpinBox(Gtk.SpinButton):
def __init__(self, value: int, upper: int) -> None: def __init__(self, value: int, upper: int) -> None:
super().__init__( super().__init__(
@@ -299,17 +283,29 @@ class TimePicker(Gtk.Box):
def get_minute(self) -> int: def get_minute(self) -> int:
return int(self.minute_spin.get_adjustment().get_value()) return int(self.minute_spin.get_adjustment().get_value())
def get_time(self) -> time: def get_time(self) -> datetime:
return time(hour=self.get_hour(), minute=self.get_minute()) """
NOTE: interpolating seconds/microseconds from the current time
may prevent abrupt visual changes when setting a new TimePicker time
while a clock tick is updating the local remainder label.
However, this can introduce false negatives when running tests due to microsecond drift.
Ideally, tests should be decoupled from this logic.
"""
d = datetime.now()
return datetime.combine(
d,
time(
hour=self.get_hour(),
minute=self.get_minute(),
second=0,
microsecond=0,
),
)
def set_time(self, hour: int, minute: int) -> None: def set_time(self, hour: int, minute: int) -> None:
self.hour_spin.get_adjustment().set_value(hour) self.hour_spin.get_adjustment().set_value(hour)
self.minute_spin.get_adjustment().set_value(minute) self.minute_spin.get_adjustment().set_value(minute)
def is_night(self) -> bool:
t = self.get_time()
return t >= NIGHT_START_TIME or t <= NIGHT_END_TIME
class AdjustedTimeFrame(Gtk.Frame): class AdjustedTimeFrame(Gtk.Frame):
def __init__(self, emitter: Emitter) -> None: def __init__(self, emitter: Emitter) -> None:
@@ -389,6 +385,13 @@ class TimePickerFrame(Gtk.Frame):
h1 = BoldLabel(calc.heading_server_time) h1 = BoldLabel(calc.heading_server_time)
h2 = BoldLabel(calc.heading_target_time) h2 = BoldLabel(calc.heading_target_time)
self.icon_indicator = Icon(DAY_ICON)
self.update_server_icon()
server_time_box = HBox(10)
server_time_box.set_halign(Gtk.Align.CENTER)
server_time_box.extend([self.server_timelabel, self.icon_indicator])
flowbox = Gtk.FlowBox( flowbox = Gtk.FlowBox(
row_spacing=10, row_spacing=10,
column_spacing=50, column_spacing=50,
@@ -402,14 +405,19 @@ class TimePickerFrame(Gtk.Frame):
flowbox.add(h1) flowbox.add(h1)
flowbox.add(h2) flowbox.add(h2)
flowbox.add(self.local_timelabel) flowbox.add(self.local_timelabel)
flowbox.add(self.server_timelabel) flowbox.add(server_time_box)
flowbox.add(self.picker) flowbox.add(self.picker)
self.add(flowbox) self.add(flowbox)
def update_server_icon(self) -> None:
icon = DAY_ICON if self.server_time.is_day() else NIGHT_ICON
self.icon_indicator.set_icon_name(icon)
def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None: def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None:
t = time_now.time().strftime("%H:%M") t = time_now.time().strftime("%H:%M")
self.server_timelabel.set_text(t) self.server_timelabel.set_text(t)
self.update_server_icon()
def _on_local_clock_increment( def _on_local_clock_increment(
self, emitter: Emitter, time_now: datetime, elapsed: timedelta self, emitter: Emitter, time_now: datetime, elapsed: timedelta
@@ -420,11 +428,11 @@ class TimePickerFrame(Gtk.Frame):
def _on_calculate_button_clicked(self, button: Gtk.Button) -> None: def _on_calculate_button_clicked(self, button: Gtk.Button) -> None:
if (self.picker.get_values()) == (self.server_time.get_values()): if (self.picker.get_values()) == (self.server_time.get_values()):
total_time = timedelta(seconds=0) total_time = timedelta(seconds=0)
self.emitter.emit("target_time_reset", total_time)
else: else:
picker_time = self.picker.get_time() picker_time = self.picker.get_time()
is_night = self.picker.is_night() total_time = self.server_time.calc_delta(picker_time)
total_time = self.server_time.calc_delta(picker_time, is_night) self.emitter.emit("target_time_changed", total_time)
self.emitter.emit("target_time_changed", total_time)
class ServerTimeCalculator(Gtk.ScrolledWindow): class ServerTimeCalculator(Gtk.ScrolledWindow):
@@ -447,8 +455,3 @@ class ServerTimeCalculator(Gtk.ScrolledWindow):
for el in time_picker_frame, adjusted_time_frame, disclaimer: for el in time_picker_frame, adjusted_time_frame, disclaimer:
outerbox.add(el) outerbox.add(el)
self.add(outerbox) self.add(outerbox)
# TEST: write tests with/without live clock increment
# FIXME: with accel of (1, 1), a 60min change converts to 55 mins instead of 59/60
# FIXME: real time until target time is not zero padded
+4 -3
View File
@@ -91,7 +91,6 @@ class ServerDetailsDialog(ServerDialog):
column.set_sort_column_id(i) column.set_sort_column_id(i)
column.set_expand(True) column.set_expand(True)
scrollable_message = Gtk.ScrolledWindow(vexpand=True) scrollable_message = Gtk.ScrolledWindow(vexpand=True)
desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START) desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START)
css.add_class(desc, "server-subheading") css.add_class(desc, "server-subheading")
@@ -119,14 +118,16 @@ class ServerDetailsDialog(ServerDialog):
self.content.reorder_child(calc_button, 2) self.content.reorder_child(calc_button, 2)
self.scrollable_tree.set_size_request(700, 250) self.scrollable_tree.set_size_request(700, 250)
self.calc = ServerTimeCalculator(Time(details.gametime, details.day_accel, details.night_accel)) self.calc = ServerTimeCalculator(
Time(details.gametime, details.day_accel, details.night_accel)
)
self.stack.add_named(self.calc, "CALCULATOR") self.stack.add_named(self.calc, "CALCULATOR")
self.connect("map", self._on_map) self.connect("map", self._on_map)
self.show_all() self.show_all()
def _on_map(self, dialog: Self) -> None: def _on_map(self, dialog: Self) -> None:
if (widget := self.get_widget_for_response(Gtk.ResponseType.OK)): if widget := self.get_widget_for_response(Gtk.ResponseType.OK):
widget.grab_focus() widget.grab_focus()
def _on_calc_pressed(self, button: Gtk.Button) -> None: def _on_calc_pressed(self, button: Gtk.Button) -> None:
+1
View File
@@ -47,6 +47,7 @@ docs = [
] ]
test = [ test = [
"black==25.1.0", "black==25.1.0",
"hypothesis==6.167.1",
"pre-commit==4.4.0", "pre-commit==4.4.0",
"pytest==8.4.2", "pytest==8.4.2",
"pytest-cov==7.0.0", "pytest-cov==7.0.0",
+8 -3
View File
@@ -22,9 +22,14 @@ class MockController:
def test_html_escaping() -> None: def test_html_escaping() -> None:
text = "<Server description> Game & mods" name = "Test server"
details = Details([["0", "1"]], text, True) desc = "<Server description> Game & mods"
gametime = "05:30"
day_accel = 5.0
night_accel = 1.0
details = Details([["0", "1"]], name, desc, gametime, day_accel, night_accel)
controller = MockController() controller = MockController()
dialog = ServerDetailsDialog(controller, details) dialog = ServerDetailsDialog(controller, details)
dialog.destroy()
assert dialog.description.get_text() == text assert dialog.description.get_text() == desc
+45
View File
@@ -0,0 +1,45 @@
import pytest
import re
from dzgui.api.servers import Details, Record, get_details
@pytest.fixture
def details() -> Details:
"""
Static official server, metadata is expected to be invariate
"""
r = Record("85.190.157.111", 10400, 10401)
return get_details(r)
def test_invalid_server() -> None:
r = Record("127.0.0.1", 10400, 10401)
assert get_details(r) is None
@pytest.mark.parametrize("key, expect", [("day_accel", 2.3), ("night_accel", 6.8)])
def test_time_accel(details, key, expect) -> None:
assert getattr(details, key) == expect
def test_gametime(details) -> None:
time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)"
match = re.match(time_reg, details.gametime)
assert match is not None
def test_metadata(details) -> None:
for row in details.data:
label, value = row
match label:
case "Password":
assert value == "Disabled"
case "Platform":
assert value == "Windows"
case "Battleye":
assert value == "Enabled"
case "Valve Anti-Cheat":
assert value == "Enabled"
case _:
pass
+108
View File
@@ -0,0 +1,108 @@
import pytest
from hypothesis import given, strategies as st
from datetime import datetime, timedelta
from dzgui.views.dialogs.calc import (
LocalClock,
ServerClock,
Time,
TimePicker,
DAY_START_TIME,
DAY_END_TIME,
)
class DummyEmitter:
pass
@pytest.fixture(params=[("05:00", 10.0), ("15:30", 5.0)])
def accel_server_clock(request) -> None:
clock_time, expect = request.param
cur_time = Time(clock_time, 5.0, 2.0)
emitter = DummyEmitter()
local_clock = LocalClock(emitter)
server_clock = ServerClock(cur_time, emitter, local_clock)
return expect, server_clock
def test_accel_factor(accel_server_clock) -> None:
"""Static test checking times at known boundaries"""
expect, clock = accel_server_clock
assert clock.get_accel_factor(clock.cur_time) == expect
def reference_calc(start, end, day_factor, night_factor):
"""
Reference implementation to test validity of UI-side calculator
Aggregates total seconds and applies accel factors after
"""
day_start = datetime.combine(start.date(), DAY_START_TIME)
day_end = datetime.combine(start.date(), DAY_END_TIME)
night_factor = day_factor * night_factor
if end < start:
end += timedelta(days=1)
day_max = max(start, day_start)
day_min = min(end, day_end)
next_day_start = datetime.combine(end.date(), DAY_START_TIME)
next_day_end = datetime.combine(end.date(), DAY_END_TIME)
range1 = (day_end - day_max).total_seconds()
range2 = (min(end, next_day_end) - next_day_start).total_seconds()
if end.date() > start.date():
day_seconds = max(0, range1) + max(0, range2)
else:
day_seconds = max(0, (day_min - day_max).total_seconds())
total_seconds = (end - start).total_seconds()
night_seconds = (total_seconds - day_seconds) / night_factor
day_seconds = day_seconds / day_factor
total = night_seconds + day_seconds
return timedelta(seconds=total)
day_accel = st.floats(
min_value=1.0, max_value=24.0, allow_nan=False, allow_infinity=False
)
night_accel = st.floats(
min_value=1.0, max_value=64.0, allow_nan=False, allow_infinity=False
)
@st.composite
def time_ranges(draw):
start_time = draw(st.times())
deltas = st.timedeltas(min_value=timedelta(0), max_value=timedelta(days=1))
duration = draw(deltas)
start = datetime.combine(datetime.now(), start_time)
end = start + duration
return start, end
@given(span=time_ranges(), day_factor=day_accel, night_factor=night_accel)
def test_time_aggregation(span, day_factor, night_factor) -> None:
start, end = span
strtime = start.strftime("%H:%M")
t = Time(strtime, day_factor, night_factor)
emitter = DummyEmitter()
local_clock = LocalClock(emitter)
server_clock = ServerClock(t, emitter, local_clock)
h = end.time().hour
m = end.time().minute
picker = TimePicker(h, m)
# NOTE: truncates microseconds to mimic widget HH:MM selection behavior
start = server_clock.get_datetime()
end = picker.get_time()
actual = server_clock.calc_delta(end)
expect = reference_calc(start, end, day_factor, night_factor)
assert actual == expect
+1 -2
View File
@@ -2,7 +2,6 @@ import re
from dzgui.model.proxy_model import DAY_REG, NIGHT_REG from dzgui.model.proxy_model import DAY_REG, NIGHT_REG
def iterate(h: str, r: str) -> None: def iterate(h: str, r: str) -> None:
for m in range(60): for m in range(60):
time = f"{h:02}:{m:02}" time = f"{h:02}:{m:02}"
@@ -18,6 +17,6 @@ def test_day() -> None:
def test_night() -> None: def test_night() -> None:
for h in range(24): for h in range(24):
if 6 < h < 17: if 5 < h < 18:
continue continue
iterate(h, NIGHT_REG) iterate(h, NIGHT_REG)