Compare commits

...
41 Commits
Author SHA1 Message Date
aclist d40512dfa0 Merge pull request #474 from aclist/prerelease/7.0.0b24
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
chore: bump version
2026-09-09 17:13:41 +09:00
aclist f07e503b74 chore: bump version 2026-09-09 17:13:07 +09:00
aclist 3caeb823a2 Merge pull request #473 from aclist/feat/server-time-calc
feat: server time calc
2026-09-09 17:12:01 +09:00
aclist 6fcd887d56 Merge pull request #472 from aclist/fix/refresh-ping
fix: refresh ping column on filter action
2026-09-09 17:07:34 +09:00
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
aclist 32395812a0 change: support new dataclass format 2026-09-03 20:11:02 +09:00
aclist 6d046ee719 change: restructure Details dataclass 2026-09-03 20:10:50 +09:00
aclist 1ebf253e99 chore: apply linter changes 2026-09-03 20:10:20 +09:00
aclist ee31dce6e6 chore: add icon constant 2026-09-03 20:09:51 +09:00
aclist 4aaf7fe195 chore: update strings files 2026-09-03 20:08:32 +09:00
aclist b7e8982f40 feat: pack calculator in Gtk.Stack 2026-09-03 20:08:03 +09:00
aclist c3b41ed9b4 feat: add server time calculator 2026-09-03 20:07:30 +09:00
aclist e42fd26855 fix: enquote typehint 2026-09-02 01:36:48 +09:00
aclist 7a8ccca317 chore: clear typehinting errors 2026-09-02 00:48:25 +09:00
aclist 72e3054afb fix: wipe cache and redraw when filtering ServerTab.BROWSER 2026-09-02 00:45:44 +09:00
aclist 2f4606ede0 fix: focus OK button when dialog opens 2026-09-02 00:43:37 +09:00
aclist c6a75c1e78 chore: move refilter() call to controller 2026-09-02 00:43:13 +09:00
aclist bad7878c0a drop: remove old comments 2026-09-02 00:42:15 +09:00
aclist 5014402e39 Merge pull request #470 from aclist/docs/update-readme
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
docs: update readme with missing details from dzgui6
2026-09-01 15:37:29 +09:00
aclist baade5b721 docs: update readme with missing details from dzgui6 2026-09-01 15:36:53 +09:00
26 changed files with 766 additions and 90 deletions

No files matched your search

+1
View File
@@ -52,6 +52,7 @@
- Filter button tooltips - Filter button tooltips
- Expand/collapse changelog versions - Expand/collapse changelog versions
- Support local light/dark mode changes on system theme - Support local light/dark mode changes on system theme
- Server time calculator
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
+13 -5
View File
@@ -1,15 +1,23 @@
## What this is ## What this is
DZGUI is a turnkey browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI). DZGUI is a turnkey server browser and mod manager for DayZ on Linux. It allows you to connect to both official and modded/community DayZ servers on Linux via a graphical user interface (GUI).
This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like: This overcomes certain limitations in the Linux client and helps prepare the game to launch by providing features like:
- Search for and display global and LAN servers via an interactive table - Search for and display global and LAN servers via an interactive table
- Supports official, third-party, and modded DayZ servers - Supports official, third-party, and modded DayZ servers
- Automatically find and prepare mods being requested by servers - Automatically find and prepare mods being requested by servers
- Add/delete/manage favorite servers by IP or ID
- Quick-connect to favorite/recent servers - Quick-connect to favorite/recent servers
- Manage local mods and symlinks - Manage and delete local mods
- Prepare launch options to pass to Steam - And more
## Design philosophy
DZGUI follows a specific set of guidelines, enumerated below:
- Provide a free and open-source tool under GPL
- Do not use undocumented, third-party servers to aggregate data; no central "DZGUI" server
- All connections go to Steam and DayZ servers directly; the tool should function in perpetuity without intermediary endpoints
- Target Linux desktop and Steam deck specifically; not a cross-platform tool
## Setup and usage ## Setup and usage
@@ -18,7 +26,7 @@ Please refer to the documentation for installation and setup instructions:
- [GitHub](https://aclist.github.io/dzgui/index.html) - [GitHub](https://aclist.github.io/dzgui/index.html)
- [Codeberg (Mirror)](https://aclist.codeberg.page) - [Codeberg (Mirror)](https://aclist.codeberg.page)
![A screenshot of DZGUI](/images/example.png) ![A screenshot of DZGUI](/dzgui/data/images/example.png)
## Attribution ## Attribution
+20 -25
View File
@@ -54,19 +54,16 @@ class Res:
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class Details: class Details:
data: Union[list, None] data: list[list[str]]
name: str
description: str description: str
success: bool gametime: str
day_accel: float
night_accel: float
@dataclass(slots=True) @dataclass(slots=True, frozen=True)
class Record: class Record:
# TODO: investigate whether this enum is still being used;
# can this be frozen?
"""
The gameport field is manipulated by the RowType.CONN_BY_IP method
"""
ip: str ip: str
gameport: int gameport: int
qport: int qport: int
@@ -283,43 +280,41 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] |
info = a2s.info((ip, qport), timeout) info = a2s.info((ip, qport), timeout)
return source_info_to_dict(ip, qport, info) return source_info_to_dict(ip, qport, info)
except Exception as e: except Exception as e:
logger.warning(e) logger.debug(e)
return None return None
def get_details(record: Record) -> Details: def get_details(record: Record) -> Details | None:
# TODO: consolidate try/except statements
ip = record.ip ip = record.ip
qport = record.qport qport = record.qport
default_str = strings.none_provided default_str = strings.none_provided
try: try:
info = a2s.info((ip, qport)) info = a2s.info((ip, qport))
except (TimeoutError, a2s.exceptions.BrokenMessageError):
return Details(None, default_str, False)
try:
rules = dayzquery.dayz_rules((ip, qport)) rules = dayzquery.dayz_rules((ip, qport))
except Exception:
return Details(None, default_str, False)
try:
keywords = info.keywords.split(",") keywords = info.keywords.split(",")
except AttributeError: except Exception as e:
return Details(None, default_str, False) logger.critical(e)
return None
battleye = strings.disabled battleye = strings.disabled
if "battleye" in keywords: if "battleye" in keywords:
battleye = strings.enabled battleye = strings.enabled
day_accel = 0.0 name = info.server_name
night_accel = 0.0 day_accel = 1.0
night_accel = 1.0
gametime = "00:00"
for keyword in keywords: for keyword in keywords:
if "etm" in keyword: if "etm" in keyword:
day_accel = float(keyword.lstrip("etm")) day_accel = float(keyword.lstrip("etm"))
day_accel = float(f"{day_accel:g}") day_accel = float(f"{day_accel:g}")
if "entm" in keywords: if "entm" in keyword:
night_accel = float(keyword.lstrip("entm")) night_accel = float(keyword.lstrip("entm"))
night_accel = float(f"{night_accel:g}") night_accel = float(f"{night_accel:g}")
time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)"
if re.match(time_reg, keyword):
gametime = keyword
try: try:
password = info.password_protected password = info.password_protected
@@ -385,7 +380,7 @@ def get_details(record: Record) -> Details:
["Version", version], ["Version", version],
] ]
return Details(rows, description, True) return Details(rows, name, description, gametime, day_accel, night_accel)
def ping(ip: str, qport: int) -> int: def ping(ip: str, qport: int) -> int:
+3
View File
@@ -37,6 +37,7 @@ HEX_RED = "#FF0000"
HEX_ORANGE = "#FFAC1C" HEX_ORANGE = "#FFAC1C"
# FIXME: normalize icon names # FIXME: normalize icon names
CALCULATOR = "accessories-calculator-symbolic"
CARET_DOWN = "go-down-symbolic" CARET_DOWN = "go-down-symbolic"
CARET_UP = "go-up-symbolic" CARET_UP = "go-up-symbolic"
CLIPBOARD = "edit-copy-symbolic" CLIPBOARD = "edit-copy-symbolic"
@@ -54,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"
+7 -4
View File
@@ -7,11 +7,12 @@ import dzgui.util._json as JSON # noqa
from dzgui.const.enum import ( from dzgui.const.enum import (
FilterMode,
Preferences,
NotebookPage,
ButtonType, ButtonType,
ContextMenu, ContextMenu,
FilterMode,
NotebookPage,
Preferences,
ServerTab,
) )
from dzgui.config.userprefs import UserPrefs from dzgui.config.userprefs import UserPrefs
@@ -37,7 +38,6 @@ logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import Record from dzgui.api.servers import Record
from dzgui.const.enum import ServerTab
from dzgui.managers.connection import Prerequisites from dzgui.managers.connection import Prerequisites
from dzgui.managers.filter import FilterManager from dzgui.managers.filter import FilterManager
from dzgui.util.dist import Haversine from dzgui.util.dist import Haversine
@@ -498,6 +498,9 @@ class Controller(GObject.GObject):
def set_active_keyword(self, keyword: str) -> None: def set_active_keyword(self, keyword: str) -> None:
self.get_filter_man().set_active_keyword(keyword) self.get_filter_man().set_active_keyword(keyword)
ServerModelManager(self, self.get_active_treeview()).refilter(
FilterMode.TOGGLE_ON
)
def get_modtreeview(self) -> "ModTreeView": def get_modtreeview(self) -> "ModTreeView":
return self.mediator.modtreeview return self.mediator.modtreeview
+1
View File
@@ -52,6 +52,7 @@
- Filter button tooltips - Filter button tooltips
- Expand/collapse changelog versions - Expand/collapse changelog versions
- Support local light/dark mode changes on system theme - Support local light/dark mode changes on system theme
- Server time calculator
### Changed ### Changed
- Conform to PEP 440 versioning for beta versions - Conform to PEP 440 versioning for beta versions
+4
View File
@@ -24,6 +24,10 @@
.left-label { .left-label {
font-size: 1.3rem; font-size: 1.3rem;
} }
.server-subheading {
font-size: 1.1rem;
font-weight: 700;
}
.details-heading { .details-heading {
font-size: 1.2rem font-size: 1.2rem
} }
+1 -1
View File
@@ -235,7 +235,7 @@ class ConnectionManager:
@call_on_thread(dialog.querying) @call_on_thread(dialog.querying)
def query_details(self, record: Servers.Record) -> None: def query_details(self, record: Servers.Record) -> None:
details = Servers.get_details(record) details = Servers.get_details(record)
if details.success is False: if details is None:
self.thread_man.set_cleanup_func( self.thread_man.set_cleanup_func(
StoredFunc(self._server_timeout), destroy_first=True StoredFunc(self._server_timeout), destroy_first=True
) )
+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 -1
View File
@@ -21,7 +21,7 @@ from dzgui.views.dialogs.generic import ExceptionDialog
import gi import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa E402 from gi.repository import Gtk, GLib # noqa E402
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import A2SInfo, Record from dzgui.api.servers import A2SInfo, Record
@@ -390,6 +390,8 @@ class ServerModelManager:
def _cleanup_on_success(self) -> None: def _cleanup_on_success(self) -> None:
proxy = self.proxy_man.get_proxy_model() proxy = self.proxy_man.get_proxy_model()
self.tv.set_model(proxy) self.tv.set_model(proxy)
if self.tv.enum == ServerTab.BROWSER:
GLib.idle_add(self.tv.wipe_cache)
if self.preserve_on_fail is True: if self.preserve_on_fail is True:
self.proxy_man.wipe_cache() self.proxy_man.wipe_cache()
+7
View File
@@ -0,0 +1,7 @@
heading_local_time = "Local time"
heading_server_time = "In-game time"
heading_target_time = "Target time"
heading_remainder_time = "Remainder until target"
heading_adjusted_time = "Adjusted local time"
calculate = "Calculate"
disclaimer = "Times shown are approximations.\nServer time may differ based on time of year in game."
+5
View File
@@ -23,3 +23,8 @@ custom_mod_dialog = "Set location to a custom mods folder"
debug_heading = "Debug" debug_heading = "Debug"
debug_secondary = "Launch arguments" debug_secondary = "Launch arguments"
toggle_calc = "Toggle calculator"
server_details = "Server details"
server_message = "Server message"
-2
View File
@@ -11,7 +11,6 @@ delimiter = "␞"
# GenericDialog # GenericDialog
main_menu = "Return to main menu" main_menu = "Return to main menu"
exit_app = "Exit" exit_app = "Exit"
server_details = "Server details"
modlist = "Modlist" modlist = "Modlist"
input_required = "User input required" input_required = "User input required"
confirm = "Confirmation" confirm = "Confirmation"
@@ -59,7 +58,6 @@ browser_cols = [
] ]
# DetailsDialog # DetailsDialog
server_message = "Server message"
workshop = "Enter/double click a row to open in Steam Workshop." workshop = "Enter/double click a row to open in Steam Workshop."
# LanDialog # LanDialog
+1
View File
@@ -24,6 +24,7 @@ class VBox(GenericBox):
def __init__(self, spacing: int = 0) -> None: def __init__(self, spacing: int = 0) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing)
class ShortHBox(HBox): class ShortHBox(HBox):
def __init__(self, widget: Gtk.Widget) -> None: def __init__(self, widget: Gtk.Widget) -> None:
super().__init__(spacing=5) super().__init__(spacing=5)
-6
View File
@@ -9,8 +9,6 @@ from dzgui.const.constants import (
NO_PADDING, NO_PADDING,
SEARCH_ICON, SEARCH_ICON,
) )
from dzgui.const.enum import FilterMode
from dzgui.model.servers import ServerModelManager
from dzgui.util.format import format_exception from dzgui.util.format import format_exception
from dzgui.util.strings import all_maps from dzgui.util.strings import all_maps
from dzgui.views.components.maps_combo import MapsCombo from dzgui.views.components.maps_combo import MapsCombo
@@ -148,10 +146,6 @@ class KeywordEntry(Gtk.Entry):
self.controller.set_active_keyword(keyword) self.controller.set_active_keyword(keyword)
logger.info(f"User filtered by keyword '{keyword}'") logger.info(f"User filtered by keyword '{keyword}'")
ServerModelManager(
self.controller, self.controller.get_active_treeview()
).refilter(FilterMode.TOGGLE_ON)
class FilterPanel(Gtk.Box): class FilterPanel(Gtk.Box):
def __init__(self, controller: "Controller") -> None: def __init__(self, controller: "Controller") -> None:
+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)
+457
View File
@@ -0,0 +1,457 @@
import gi
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from typing import Literal
from dzgui.const.constants import DAY_ICON, NIGHT_ICON
from dzgui.strings import calc
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")
from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402
DAY_START_TIME = time(hour=6, minute=0, second=0)
DAY_END_TIME = time(hour=18, minute=0, second=0)
NIGHT_START_TIME = time(hour=18, minute=0, second=0)
NIGHT_END_TIME = time(hour=6, minute=0, second=0)
EXTENDED_TIME_FORMAT = "%H:%M:%S"
@dataclass(slots=True, frozen=True)
class Time:
time: str
day_accel: float
night_accel: float
class Emitter(GObject.GObject):
def __init__(self) -> None:
super().__init__()
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, object))
def local_time_incremented(self, time_now: datetime, elapsed: timedelta) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def server_time_incremented(self, server_time: datetime) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=())
def calculate_button_clicked(self) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def target_time_changed(self, time: timedelta) -> None:
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,))
def remaining_time_changed(self, time: datetime) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,))
def adjusted_local_time_changed(self, time: datetime) -> None:
pass
class LocalClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
self.start_time = datetime.now()
# NOTE: highest theoretical accel interval is 24 * 64
GLib.timeout_add(500, self.check_time)
def get_time(self) -> str:
return datetime.now().time().strftime(EXTENDED_TIME_FORMAT)
def get_elapsed_time(self) -> timedelta:
return datetime.now() - self.start_time
def check_time(self) -> Literal[True]:
time_now = datetime.now()
elapsed = datetime.now() - self.start_time
self.emitter.emit("local_time_incremented", time_now, elapsed)
return True
class ServerClock:
def __init__(
self, server_time: Time, emitter: Emitter, local_clock: LocalClock
) -> None:
self.local_clock = local_clock
self.emitter = emitter
hour, minute = self.split_time(server_time)
self.day_accel = server_time.day_accel
self.night_accel = server_time.night_accel
self.start_time = datetime.combine(datetime.today(), time(hour, minute))
self.cur_time = datetime.combine(datetime.today(), time(hour, minute))
self._last_elapsed = self.local_clock.get_elapsed_time()
self.emitter.connect("local_time_incremented", self._on_local_time_incremented)
@staticmethod
def split_time(server_time: Time) -> tuple[int, int]:
split = server_time.time.split(":")
return int(split[0]), int(split[1])
def is_day(self) -> bool:
if DAY_START_TIME <= self.cur_time.time() <= DAY_END_TIME:
return True
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)
return True
def get_accel_factor(self, dt: datetime) -> float:
return (
self.day_accel
if DAY_START_TIME <= dt.time() < DAY_END_TIME
else (self.night_accel * self.day_accel)
)
def get_values(self) -> tuple[int, int]:
return self.cur_time.hour, self.cur_time.minute
def get_datetime(self) -> datetime:
return self.cur_time
def get_time(self) -> time:
return self.cur_time.time()
def calc_delta(self, end_time: datetime) -> timedelta:
total = 0.0
current_time = self.cur_time
if current_time > end_time:
end_time += timedelta(days=1)
while current_time < end_time:
factor = self.get_accel_factor(current_time)
day_start = datetime.combine(current_time.date(), DAY_START_TIME)
day_end = datetime.combine(current_time.date(), DAY_END_TIME)
if current_time.time() < DAY_START_TIME:
edge = day_start
elif current_time.time() < DAY_END_TIME:
edge = day_end
else:
edge = datetime.combine(
current_time.date() + timedelta(days=1), DAY_START_TIME
)
chunk_end = min(end_time, edge)
total += (chunk_end - current_time).total_seconds() / factor
current_time = chunk_end
return timedelta(seconds=total)
class RemainderClock:
def __init__(self, emitter: Emitter) -> None:
self.emitter = emitter
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:
self.reset_time(True)
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
self.emitter.emit("adjusted_local_time_changed", adjusted)
def get_time(self) -> str:
return self.start_time.strftime(EXTENDED_TIME_FORMAT)
class VerticalSpinBox(Gtk.SpinButton):
def __init__(self, value: int, upper: int) -> None:
super().__init__(
adjustment=Gtk.Adjustment(
value=value,
lower=0,
upper=upper,
step_increment=1,
page_increment=1,
page_size=0,
),
orientation=Gtk.Orientation.VERTICAL,
)
class HourSpinBox(VerticalSpinBox):
def __init__(self, hour: int) -> None:
super().__init__(value=hour, upper=23)
class MinuteSpinBox(VerticalSpinBox):
def __init__(self, minute: int) -> None:
super().__init__(value=minute, upper=59)
self.get_adjustment().set_page_increment(10)
class TimePicker(Gtk.Box):
def __init__(self, hour: int, minute: int) -> None:
super().__init__(
orientation=Gtk.Orientation.HORIZONTAL,
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
self.hour_spin = HourSpinBox(hour)
self.minute_spin = MinuteSpinBox(minute)
self.add(self.hour_spin)
self.add(self.minute_spin)
self.hour_spin.connect("output", self._on_output)
self.minute_spin.connect("output", self._on_output)
def _on_output(self, spin_button: VerticalSpinBox) -> Literal[True]:
val = spin_button.get_value()
spin_button.set_text(f"{int(val):02d}")
# NOTE: indicates that rendering completed
return True
def get_values(self) -> tuple[int, int]:
hour = self.get_hour()
minute = self.get_minute()
return hour, minute
def get_hour(self) -> int:
return int(self.hour_spin.get_adjustment().get_value())
def get_minute(self) -> int:
return int(self.minute_spin.get_adjustment().get_value())
def get_time(self) -> datetime:
"""
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:
self.hour_spin.get_adjustment().set_value(hour)
self.minute_spin.get_adjustment().set_value(minute)
class AdjustedTimeFrame(Gtk.Frame):
def __init__(self, emitter: Emitter) -> None:
super().__init__(margin_start=50, margin_end=50)
self.emitter = emitter
self.emitter.connect(
"adjusted_local_time_changed", self._on_adjusted_time_changed
)
self.emitter.connect("remaining_time_changed", self._on_remainder_changed)
self.remainder_clock = RemainderClock(emitter)
self.remainder_heading = BoldLabel(calc.heading_remainder_time)
self.remaining_time = Gtk.Label(label=self.remainder_clock.get_time())
self.adjusted_clock = AdjustedClock(emitter)
self.adjusted_heading = BoldLabel(calc.heading_adjusted_time)
self.adjusted_local_time = Gtk.Label(label=self.adjusted_clock.get_time())
flowbox = Gtk.FlowBox(
row_spacing=20,
column_spacing=50,
min_children_per_line=2,
max_children_per_line=2,
margin=15,
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.NONE,
)
flowbox.add(self.remainder_heading)
flowbox.add(self.adjusted_heading)
flowbox.add(self.remaining_time)
flowbox.add(self.adjusted_local_time)
recalc = Gtk.Button(
label=calc.calculate, halign=Gtk.Align.CENTER, margin_bottom=20
)
recalc.connect("clicked", self._on_recalc_clicked)
vbox = VBox()
vbox.extend([flowbox, recalc])
self.add(vbox)
def _on_remainder_changed(self, emitter: Emitter, time: datetime) -> None:
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
self.remaining_time.set_text(strtime)
def _on_adjusted_time_changed(self, emitter: Emitter, time: datetime) -> None:
strtime = str(time.strftime(EXTENDED_TIME_FORMAT))
self.adjusted_local_time.set_text(strtime)
def _on_recalc_clicked(self, button: Gtk.Button) -> None:
self.emitter.emit("calculate_button_clicked")
class TimePickerFrame(Gtk.Frame):
def __init__(self, emitter: Emitter, stime: Time) -> None:
super().__init__(margin_start=50, margin_end=50)
self.emitter = emitter
self.emitter.connect("local_time_incremented", self._on_local_clock_increment)
self.emitter.connect("server_time_incremented", self._on_server_clock_increment)
self.emitter.connect(
"calculate_button_clicked", self._on_calculate_button_clicked
)
self.local_clock = LocalClock(self.emitter)
local_clock_time = self.local_clock.get_time()
self.server_time = ServerClock(stime, self.emitter, self.local_clock)
hour, minute = self.server_time.get_values()
self.picker = TimePicker(hour, minute)
self.local_timelabel = Gtk.Label(label=local_clock_time)
self.server_timelabel = Gtk.Label(label=stime.time, halign=Gtk.Align.CENTER)
h0 = BoldLabel(calc.heading_local_time)
h1 = BoldLabel(calc.heading_server_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(
row_spacing=10,
column_spacing=50,
min_children_per_line=3,
max_children_per_line=3,
margin=15,
halign=Gtk.Align.CENTER,
selection_mode=Gtk.SelectionMode.NONE,
)
flowbox.add(h0)
flowbox.add(h1)
flowbox.add(h2)
flowbox.add(self.local_timelabel)
flowbox.add(server_time_box)
flowbox.add(self.picker)
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:
t = time_now.time().strftime("%H:%M")
self.server_timelabel.set_text(t)
self.update_server_icon()
def _on_local_clock_increment(
self, emitter: Emitter, time_now: datetime, elapsed: timedelta
) -> None:
t = time_now.time().strftime(EXTENDED_TIME_FORMAT)
self.local_timelabel.set_text(t)
def _on_calculate_button_clicked(self, button: Gtk.Button) -> None:
if (self.picker.get_values()) == (self.server_time.get_values()):
total_time = timedelta(seconds=0)
self.emitter.emit("target_time_reset", total_time)
else:
picker_time = self.picker.get_time()
total_time = self.server_time.calc_delta(picker_time)
self.emitter.emit("target_time_changed", total_time)
class ServerTimeCalculator(Gtk.ScrolledWindow):
def __init__(self, stime: Time) -> None:
super().__init__(propagate_natural_width=True)
self.emitter = Emitter()
outerbox = Gtk.Box(
halign=Gtk.Align.CENTER,
valign=Gtk.Align.START,
spacing=20,
orientation=Gtk.Orientation.VERTICAL,
)
time_picker_frame = TimePickerFrame(self.emitter, stime)
adjusted_time_frame = AdjustedTimeFrame(self.emitter)
disclaimer = Gtk.Label(
label=calc.disclaimer,
justify=Gtk.Justification.CENTER,
)
for el in time_picker_frame, adjusted_time_frame, disclaimer:
outerbox.add(el)
self.add(outerbox)
+8
View File
@@ -309,6 +309,14 @@ class ExceptionDialog(TextBufferDialog):
content.set_spacing(0) content.set_spacing(0)
content.add(scrollable) content.add(scrollable)
self.connect("map", self._on_map)
def _on_map(self, dialog: Self) -> None:
button = self.get_widget_for_response(Gtk.ResponseType.OK)
if button is None:
return
button.grab_focus()
def _on_page_changed( def _on_page_changed(
self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int
) -> None: ) -> None:
+46 -25
View File
@@ -1,20 +1,21 @@
import gi
import html import html
from typing import Self, TYPE_CHECKING from typing import Self, TYPE_CHECKING
from dzgui.const.constants import EXPAND, FILL from dzgui.const.constants import CALCULATOR
from dzgui.const.enum import ContextMenuGroup from dzgui.const.enum import ContextMenuGroup
from dzgui.model.model_factory import ModelFactory from dzgui.model.model_factory import ModelFactory
from dzgui.util import css from dzgui.util import css
from dzgui.util import strings
from dzgui.util.format import format_hyperlinks, format_server_mods from dzgui.util.format import format_hyperlinks, format_server_mods
from dzgui.strings import server_mods from dzgui.strings import server_mods, dialogs
from dzgui.views.components.buttons import IconTextButton
from dzgui.views.components.box import VBox
from dzgui.views.dialogs.calc import Time, ServerTimeCalculator
from dzgui.views.dialogs.generic import GenericDialog from dzgui.views.dialogs.generic import GenericDialog
from dzgui.views.trees.tree_base import TreeView from dzgui.views.trees.tree_base import TreeView
import gi
gi.require_version("Gtk", "3.0") gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa from gi.repository import Gtk, Gdk, Pango # noqa
if TYPE_CHECKING: if TYPE_CHECKING:
from dzgui.api.servers import Details from dzgui.api.servers import Details
@@ -46,12 +47,19 @@ class ServerDialog(GenericDialog):
self.connect("response", self._on_response) self.connect("response", self._on_response)
self.view.connect("key-press-event", self._on_keypress) self.view.connect("key-press-event", self._on_keypress)
self.scrollable_tree = Gtk.ScrolledWindow() self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True)
self.scrollable_tree.add(self.view) self.scrollable_tree.add(self.view)
self.scrollable_tree.set_size_request(700, 400) self.scrollable_tree.set_size_request(700, 500)
self.stack = Gtk.Stack()
self.content_box = VBox(10)
self.content_box.set_valign(Gtk.Align.FILL)
self.content_box.add(self.scrollable_tree)
self.stack.add_named(self.content_box, "METADATA")
self.content = self.get_content_area() self.content = self.get_content_area()
self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) self.content.pack_start(self.stack, expand=True, fill=True, padding=0)
def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool: def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool:
# NOTE: ESC normally unfocuses treeview instead of destroying dialog # NOTE: ESC normally unfocuses treeview instead of destroying dialog
@@ -66,9 +74,7 @@ class ServerDialog(GenericDialog):
class ServerDetailsDialog(ServerDialog): class ServerDetailsDialog(ServerDialog):
def __init__(self, controller: "Controller", details: "Details"): def __init__(self, controller: "Controller", details: "Details"):
# TODO: server name should also be packed in details struct super().__init__(controller, dialogs.server_details, details.name, menu=None)
name = controller.get_server_name()
super().__init__(controller, strings.server_details, name, menu=None)
self.store = Gtk.ListStore(str, str, Pango.Weight) self.store = Gtk.ListStore(str, str, Pango.Weight)
self.view.connect("row-activated", self._on_row_activated) self.view.connect("row-activated", self._on_row_activated)
@@ -85,14 +91,9 @@ class ServerDetailsDialog(ServerDialog):
column.set_sort_column_id(i) column.set_sort_column_id(i)
column.set_expand(True) column.set_expand(True)
# NOTE: constrain tree area to logical height of metadata scrollable_message = Gtk.ScrolledWindow(vexpand=True)
# leaving room for server message desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START)
self.scrollable_tree.set_size_request(700, 150) css.add_class(desc, "server-subheading")
# TODO: make "Server message" text boldface
scrollable_message = Gtk.ScrolledWindow()
desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START)
css.add_class(desc, "details-heading")
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER)
self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True) self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True)
sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
@@ -100,11 +101,7 @@ class ServerDetailsDialog(ServerDialog):
for el in desc, sep, self.description: for el in desc, sep, self.description:
box.add(el) box.add(el)
scrollable_message.add(box) scrollable_message.add(box)
self.content_box.add(scrollable_message)
self.pack(scrollable_message)
if details.data is None:
return
for row in details.data: for row in details.data:
self.store.append(row + [Pango.Weight.BOLD]) self.store.append(row + [Pango.Weight.BOLD])
@@ -113,8 +110,32 @@ class ServerDetailsDialog(ServerDialog):
text = format_hyperlinks(text) text = format_hyperlinks(text)
self.description.set_markup(html.escape(text)) self.description.set_markup(html.escape(text))
calc_button = IconTextButton(CALCULATOR, dialogs.toggle_calc)
calc_button.connect("clicked", self._on_calc_pressed)
calc_button.set_halign(Gtk.Align.END)
calc_button.set_valign(Gtk.Align.START)
self.content.pack_start(calc_button, False, False, 0)
self.content.reorder_child(calc_button, 2)
self.scrollable_tree.set_size_request(700, 250)
self.calc = ServerTimeCalculator(
Time(details.gametime, details.day_accel, details.night_accel)
)
self.stack.add_named(self.calc, "CALCULATOR")
self.connect("map", self._on_map)
self.show_all() self.show_all()
def _on_map(self, dialog: Self) -> None:
if widget := self.get_widget_for_response(Gtk.ResponseType.OK):
widget.grab_focus()
def _on_calc_pressed(self, button: Gtk.Button) -> None:
if self.stack.get_visible_child_name() == "METADATA":
self.stack.set_visible_child_name("CALCULATOR")
else:
self.stack.set_visible_child_name("METADATA")
def _on_row_activated( def _on_row_activated(
self, self,
treeview: Gtk.TreeView, treeview: Gtk.TreeView,
+18 -11
View File
@@ -66,7 +66,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
self.handler_id: int self.handler_id: int
self.queue: Queue = Queue() self.queue: Queue = Queue()
self.seen_cache: list[str] = [] self.seen_cache: list[Record] = []
prefs = self.controller.get_prefs() prefs = self.controller.get_prefs()
columns = prefs.paths.columns columns = prefs.paths.columns
@@ -341,16 +341,24 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
control = self.proxy_man.get_control() control = self.proxy_man.get_control()
return model, control return model, control
@staticmethod
def ping_server( def ping_server(
self,
model: "FastInsertListStore", model: "FastInsertListStore",
_iter: Gtk.TreeIter, _iter: Gtk.TreeIter,
ip: str, record: Record,
qport: int,
ping_column: int,
) -> None: ) -> None:
_ping = ping(ip, qport) _ping = ping(record.ip, record.qport)
GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) GLib.idle_add(self._redraw, model, _iter, _ping)
def _redraw(
self, model: "FastInsertListStore", _iter: Gtk.TreeIter, _ping: int
) -> None:
ping_column = 9
model.set(_iter, ping_column, _ping)
self.queue_draw()
def wipe_cache(self) -> None:
self.seen_cache = []
def _get_ping( def _get_ping(
self, self,
@@ -363,13 +371,12 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
addr_column = 7 addr_column = 7
qport_column = 8 qport_column = 8
ping_column = 9
addr = model.get_value(_iter, addr_column).split(":") addr = model.get_value(_iter, addr_column).split(":")
ip = addr[0] ip = addr[0]
gameport = addr[1] gameport = int(addr[1])
qport = model.get_value(_iter, qport_column) qport = model.get_value(_iter, qport_column)
record = f"{addr}:{gameport}:{qport}" record = Record(ip, gameport, qport)
if record in self.seen_cache: if record in self.seen_cache:
return return
@@ -378,7 +385,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore
thread = threading.Thread( thread = threading.Thread(
daemon=True, daemon=True,
target=self.ping_server, target=self.ping_server,
args=(model, _iter, ip, qport, ping_column), args=(model, _iter, record),
) )
thread.start() thread.start()
+2 -1
View File
@@ -4,7 +4,7 @@ description = "DayZ server browser and mod manager for Linux"
authors = [ authors = [
{name = "aclist"} {name = "aclist"}
] ]
version = "7.0.0b23" version = "7.0.0b24"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
license-files = ["LICENSE"] license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
@@ -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)