Compare commits

...

21 Commits

Author SHA1 Message Date
aclist
ad85bb7343 drop collision check for custom mods
Some checks are pending
Mirror to Codeberg / mirror-to-codeberg (push) Waiting to run
Mod hashes are taken from the directory names, which cannot be the same
at the filesystem level. Custom mod hashes are also unique from local
mods because they are given a different namespace.
2026-06-11 16:17:02 +09:00
aclist
77df66979a chore: cleanup 2026-06-11 15:40:09 +09:00
aclist
d4825390f7 chore: update strings 2026-06-11 15:39:52 +09:00
aclist
d5a857fe3a fix: parse tree iters correctly 2026-06-11 15:31:49 +09:00
aclist
241df72bf3 fix: pass custom flag 2026-06-11 15:31:17 +09:00
aclist
4f51d962e7 fix: parse filepath correctly 2026-06-11 15:15:43 +09:00
aclist
98b550cec6 chore: dedicated mission frame 2026-06-11 15:13:09 +09:00
aclist
20c850a977 chore: drop print statement 2026-06-11 15:12:13 +09:00
aclist
5a8d85ac92 fix: less brittle meta parsing 2026-06-11 15:11:38 +09:00
aclist
450d54d7fd fix: swap logic 2026-06-11 12:37:27 +09:00
aclist
65fb07c8da chore: clear type annotation errors 2026-06-11 12:10:37 +09:00
aclist
8123893a90 chore: clean up offline mod manager 2026-06-11 12:03:44 +09:00
aclist
3c9b5c9ed3 chore: update strings 2026-06-11 12:03:22 +09:00
aclist
f9be6dfae4 chore: drop unused method 2026-06-11 12:02:44 +09:00
aclist
586f7e6c66 feat: parse mission and mods 2026-06-11 12:02:28 +09:00
aclist
49eb3caf0a fix: handle empty store 2026-06-11 12:02:05 +09:00
aclist
56da5717fa feat: add emitter signal 2026-06-11 12:01:32 +09:00
aclist
9983e91526 feat: test mission integrity 2026-06-11 12:01:17 +09:00
aclist
820deb2955 fix: incorrect error state 2026-06-11 10:52:49 +09:00
aclist
0137bf4610 fix: process escape key 2026-06-11 10:47:32 +09:00
aclist
687b1a915e feat: capture escape signal in BootDialog 2026-06-11 10:43:52 +09:00
17 changed files with 241 additions and 135 deletions

View File

@ -1,6 +1,6 @@
import hashlib
import logging
import shlex
import re
from concurrent.futures import wait
from concurrent.futures import ThreadPoolExecutor
@ -48,36 +48,29 @@ def get_local_mods(workshop_path: Path) -> list[Path]:
mods = [file for file in workshop_path.iterdir() if file.is_dir()]
return mods
def is_mission(path: Path) -> bool:
# TODO: parse integrity of other files
file = path / "init.c"
return file.exists()
def parse_meta(file: Path) -> ModMeta | None:
mod = file / "meta.cpp"
if mod.exists() is False:
def tokenize(mod: Path) -> dict[Any] | None:
file = mod.joinpath("meta.cpp")
delimiter=r"\s*=\s*"
modmeta = {}
try:
with open(file, "r", encoding="utf-8") as file:
for line in file:
line = line.strip().rstrip(";")
if not line:
continue
els = re.split(delimiter, line, maxsplit=1)
if len(els) == 2:
key, value = els
modmeta[key.strip()] = value.strip('"')
return modmeta
except Exception as e:
logger.critical(e)
return None
with open(file / "meta.cpp", "r") as f:
st = f.read()
lex = shlex.shlex(st)
lex.whitespace += "=;"
v = []
while True:
tok = lex.get_token()
if not tok:
break
if tok == "protocol" or tok == "publishedid":
ntok = lex.get_token()
elif tok == "timestamp":
# NOTE: some malformed .NET tick conversions result in numbers < 0
ntok = lex.get_token()
if ntok == "-":
ntok += str(lex.get_token())
elif tok == "name":
ntok = lex.get_token()
if ntok is not None:
ntok = ntok.split('"')[1]
if ntok is not None:
v.append(ntok)
meta = ModMeta(*v)
return meta
def get_mod_size(path: Path) -> float:
s = 0
@ -90,21 +83,20 @@ def get_mod_size(path: Path) -> float:
def get_custom_mods(path: Path) -> list[Any]:
mods = get_local_mods(path)
# TODO: error handling
return parse_mods(mods)
return parse_mods(mods, use_custom=True)
def parse_mods(mods: list[Path]) -> list[Any]:
def parse_mods(mods: list[Path], use_custom: bool = False) -> list[Any]:
clean = []
for mod in mods:
mod_dir = mod.name
symlink = _hash(mod_dir)
# FIXME: malformed .cpp files could break this
meta = parse_meta(mod)
symlink = _hash(mod_dir, use_custom)
meta = tokenize(mod)
if meta is None:
continue
size = get_mod_size(mod)
# NOTE: final col is cell renderer highlight toggle
clean.append([meta.name, symlink, mod_dir, size, False])
clean.append([meta["name"], symlink, mod_dir, size, False])
clean.sort(key=lambda row: str(row[0]).casefold())
return clean

View File

@ -156,5 +156,9 @@ class Emitter(GObject.GObject):
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, str, bool))
def custom_mods_loaded(self, store: "FastInsertListStore", folder: str, has_duplicates: bool) -> None:
def custom_mods_loaded(self, store: "FastInsertListStore", folder: str) -> None:
pass
@GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(str, bool))
def custom_mission_loaded(self, folder: str, is_valid: bool) -> None:
pass

View File

@ -25,7 +25,7 @@ from dzgui.model.servers import ServerModelManager
from dzgui.util.diag import write_diagnostic
from dzgui.util.format import format_player_count
from dzgui.util.open_links import open_user_workshop, open_workshop_page
from dzgui.views.dialogs.filepicker import FilePicker, FolderPicker
from dzgui.views.dialogs.filepicker import FilePicker
from dzgui.views.dialogs.generic import ExceptionDialog
import gi
@ -36,7 +36,6 @@ from gi.repository import Gtk, Gdk, GLib, GObject # noqa E402
logger = logging.getLogger(APP_NAME)
if TYPE_CHECKING:
from pathlib import Path
from dzgui.api.servers import Record
from dzgui.const.enum import ServerTab
from dzgui.managers.connection import Prerequisites
@ -301,12 +300,6 @@ class Controller(GObject.GObject):
dialog = ExceptionDialog(self, str(e))
dialog.run()
def set_custom_folder(self) -> Union["Path", None]:
picker = FolderPicker(self.mediator.window)
folder = picker.pick_folder()
picker.destroy()
return folder
def update_api_key(self, key: Preferences, text: str) -> None:
self.config_man.update_api_key(key, text)

View File

@ -48,7 +48,7 @@ class ModManager:
self.prefs = controller.get_prefs()
self.path = controller.query_config(Preferences.DEFAULT)
self.store: FastInsertListStore
self.store: FastInsertListStore | None = None
self.thread_man = ThreadingManager(controller)
@ -68,6 +68,8 @@ class ModManager:
def _on_mods_loaded(self) -> None:
self.treeview.set_model(self.store)
if self.store is None:
return
msg = self.format_mod_statusbar()
total_mods = len(self.store)
self.emitter.emit("mods_updated", msg, total_mods)
@ -124,6 +126,8 @@ class ModManager:
pass
def _on_mods_deleted(self, iters: list[Gtk.TreeIter]) -> None:
if self.store is None:
return
for _iter in iters:
self.store.remove(_iter)
remove_stale_signatures(self.prefs.paths.config, self.prefs.paths.version)

View File

@ -1,13 +1,14 @@
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Union
from dzgui.api.mods import get_custom_mods
from dzgui.api.mods import is_mission, get_custom_mods
from dzgui.api.steam import launch_offline
from dzgui.const.enum import Preferences
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.model.model_factory import ModelFactory
from dzgui.strings import dialogs
from dzgui.util.symlink import clone_symlinks
from dzgui.views.dialogs.filepicker import FolderPicker
if TYPE_CHECKING:
from dzgui.controllers.mc import Controller
@ -29,20 +30,31 @@ class OfflineManager:
self.local_mods: list[str] | None
self.custom_mods: list[str] | None
# TODO: strings
@call_on_thread("parsing")
def get_mission(self) -> None:
folder = self.open_folderpicker()
if folder is None:
return
is_valid = is_mission(folder)
# TODO: stop storing values in here
if is_valid:
# TODO: str
self.mission_folder = str(folder)
self.emitter.emit("custom_mission_loaded", str(folder), is_valid)
def get_custom_mods(self, local_mods: list[str]) -> None:
folder = self.open_folderpicker()
if folder is None:
return
self.parse_custom_mods(local_mods, folder)
@call_on_thread(dialogs.parsing_mods)
def parse_custom_mods(self, local_mods: list[str], folder: str) -> None:
mods = get_custom_mods(Path(folder))
store = ModelFactory().make_mod_store()
store.extend(mods)
has_duplicates = False
for row in store:
if row[1] in local_mods:
row[-1]=True
has_duplicates = True
func = StoredFunc(lambda: self.emitter.emit("custom_mods_loaded", store, folder, has_duplicates))
func = StoredFunc(lambda: self.emitter.emit("custom_mods_loaded", store, folder))
self.thread_man.set_cleanup_func(func)
def setup(
@ -54,7 +66,6 @@ class OfflineManager:
) -> None:
self.appid = appid
self.mission_folder = mission
self.local_mods = local_mods
self.custom_mods = custom_mods
@ -79,3 +90,9 @@ class OfflineManager:
combined_mods.extend(new_symlinks)
launch_offline(client, self.appid, name, combined_mods, self.mission_folder)
def open_folderpicker(self) -> Union["Path", None]:
picker = FolderPicker(self.controller.get_window())
folder = picker.pick_folder()
picker.destroy()
return folder

View File

@ -9,5 +9,10 @@ load_error_lan = "Failed to find any servers on your network.\nCheck the server
fetching_mods = "Fetching mod metadata"
deleting_mods = "Deleting mods"
scanning_mods = "Scanning mods"
parsing_mods = "Parsing mods"
checking_api = "Checking API key"
running = "Running"
failed = "FAILED"
ok = "OK"

View File

@ -9,3 +9,6 @@ custom_frame = "Custom mods"
custom_button = "Set custom mod folder"
local_frame = "Installed mods"
no_mods = "No valid mods found"
no_mission = "Not a valid mission"

View File

@ -1,9 +1,8 @@
import sys
from typing import Literal, TYPE_CHECKING
from typing import Any, Literal, Self, TYPE_CHECKING
# import time
from enum import Enum
from typing import Any, Self
# TODO: import dialog titles
from dzgui.api.mods import remove_stale_signatures as remove_stale
@ -14,7 +13,7 @@ from dzgui.init.dayz import is_dayz_installed
from dzgui.init.update import check_updates
from dzgui.const.constants import EXPAND, FILL
from dzgui.managers.threading import call_on_thread, StoredFunc, ThreadingManager
from dzgui.strings import preboot
from dzgui.strings import preboot, dialogs
from dzgui.util.format import format_exception
from dzgui.util.strings import dialog_header
from dzgui.util.symlink import rebuild_symlinks
@ -59,6 +58,9 @@ class BootDialog(Gtk.Dialog):
self.view = Gtk.TreeView(enable_search=False, headers_visible=False)
self.view.set_model(self.store)
# TODO: capture sigint in early dialogs
self.connect("key-press-event", self._on_keypress)
for i, column_title in enumerate(["Task", "State"]):
renderer = Gtk.CellRendererText()
column = Gtk.TreeViewColumn(column_title, renderer, text=i)
@ -138,6 +140,10 @@ class BootDialog(Gtk.Dialog):
GLib.timeout_add(100, self.pulse_spinner)
def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None:
if event.keyval == Gdk.KEY_Escape:
self.exit_button.emit("clicked")
def pulse_spinner(self) -> Literal[True]:
for row in self.store:
if row[2]:
@ -184,10 +190,10 @@ class BootDialog(Gtk.Dialog):
self.thread_man.set_cleanup_func(callback)
def update_task(self, task: str) -> None:
self.store.append((task, "Running", True, 0))
self.store.append((task, dialogs.running, True, 0))
def update_status(self, state: Success) -> None:
d = {Success.OK: "OK", Success.FAIL: "FAILED"}
d = {Success.OK: dialogs.ok, Success.FAIL: dialogs.failed}
msg = d[state]
self.store[len(self.store) - 1][1] = msg
self.iter_step()
@ -200,7 +206,7 @@ class BootDialog(Gtk.Dialog):
it: Gtk.TreeIter,
data: Any,
) -> None:
if model[it][1] == "Running":
if model[it][1] == dialogs.running:
cell.set_property("visible", True)
else:
cell.set_property("visible", False)
@ -217,9 +223,9 @@ class BootDialog(Gtk.Dialog):
state = model[it][1]
if column.get_sort_column_id() != 1:
return
if state == "OK":
if state == dialogs.ok:
cell.set_property(prop, HEX_GREEN)
elif state == "FAILED":
elif state == dialogs.failed:
cell.set_property(prop, HEX_RED)
else:
cell.set_property(prop, None)

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from pathlib import Path
from typing import Self, Sequence, TYPE_CHECKING
from typing import Self, Sequence, TYPE_CHECKING, Union
from dzgui.util import css
import dzgui.api.pefile as PeFile
@ -62,27 +62,36 @@ class FolderHBox(HBox):
super().__init__(spacing=10)
self.set_margin_start(10)
self.set_margin_bottom(10)
self.set_margin_end(10)
self.set_margin_bottom(5)
# TODO: alternate class for left-aligned icons
self.button = IconTextButton(FOLDER, btn_label)
self.button.set_halign(Gtk.Align.START)
self.button.set_image_position(Gtk.PositionType.LEFT)
self.scrolled_label = Gtk.ScrolledWindow(propagate_natural_width=True, halign=Gtk.Align.START)
self.label = Gtk.Label()
self.extend([self.button, self.label])
self.scrolled_label.add(self.label)
self.extend([self.button, self.scrolled_label])
def get_button(self) -> Gtk.Button:
return self.button
def set_label(self, label: str) -> None:
self.label.set_label(label)
# TODO: strings
prefix = "Current folder: "
self.label.set_label(prefix + label)
def hide_label(self) -> None:
self.label.hide()
class ModFrame(HeadingFrame):
def __init__(self, controller: "Controller", label: str) -> None:
def __init__(self, parent: OfflineLoader, controller: "Controller", label: str) -> None:
super().__init__(heading=label)
self.parent = parent
self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.tree = OfflineModTreeView(controller)
@ -103,26 +112,38 @@ class ModFrame(HeadingFrame):
self.vbox.pack_end(self.tree_vbox, expand=True, fill=True, padding=3)
self.frame.add(self.vbox)
# self.vbox.add(self.tree_vbox)
# self.none_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START)
# self.vbox.pack_end(self.none_label, expand=True, fill=True, padding=3)
# self.none_label.hide()
sel = self.tree.get_selection()
sel.connect("changed", self._on_selection_changed)
# TODO: inherit icons from warnings area in preconnect dialog
# TODO: strings
self.error_label = Gtk.Label(label="No mods found", halign=Gtk.Align.START, margin_start=10, margin_bottom=5)
self.vbox.pack_end(self.error_label, expand=True, fill=True, padding=3)
def set_error(self, msg: str) -> None:
self.error_label.set_label(msg)
self.error_label.show()
def hide_errors(self) -> None:
self.error_label.hide()
def start_empty(self) -> None:
self.error_label.show()
self.collapse_tree()
def hide_all(self) -> None:
self.error_label.hide()
self.collapse_tree()
# TODO: warnings area
# valid warnings (one at a time):
# - name collision within custom mods
# - name collision b/w custom and local (permissible)
# - no mods
# - not a valid mission
# name collision within custom mods
def get_mods(self) -> list[str]:
model = self.tree.get_model()
model, treeiters = self.tree.get_selection().get_selected_rows()
if model is None:
return []
return [row[1] for row in model]
return [model[_iter][1] for _iter in treeiters]
def collapse_tree(self) -> None:
self.tree_vbox.hide()
@ -133,7 +154,7 @@ class ModFrame(HeadingFrame):
def pack(self, widget: Gtk.Widget) -> None:
self.vbox.pack_start(widget, expand=False, fill=False, padding=5)
def set_model(self, model: "FastInsertListStore") -> None:
def set_model(self, model: Union["FastInsertListStore", None]) -> None:
self.tree.set_model(model)
self.tree.mod_man.store = model
self.set_cursor()
@ -145,6 +166,7 @@ class ModFrame(HeadingFrame):
else:
status = f"Mods selected: {len(rows)}"
self.status.set_label(status)
self.parent.check_button()
def set_cursor(self) -> None:
path = Gtk.TreePath.new_from_indices([0])
@ -156,7 +178,7 @@ class CustomModFrame(ModFrame):
def __init__(
self, parent: OfflineLoader, controller: "Controller", heading: str
) -> None:
super().__init__(controller, heading)
super().__init__(parent, controller, heading)
self.parent = parent
@ -171,34 +193,62 @@ class CustomModFrame(ModFrame):
self.emitter.connect("custom_mods_loaded", self._on_custom_mods_loaded)
def hide_tree(self) -> None:
# TODO: no mods message is different from collision message
self.set_error(offline.no_mods)
self.custom_hbox.hide_label()
self.tree_vbox.hide()
def present_tree(self, folder: str, store: "FastInsertListStore") -> None:
self.custom_hbox.set_label(folder)
self.tree.set_model(store)
self.tree_vbox.show()
self.hide_errors()
def _on_custom_mods_loaded(
self,
emitter: "Emitter",
store: "FastInsertListStore",
folder: str,
has_duplicates: bool,
) -> None:
if len(store) == 0:
# TODO: pop error dialog area
# block button access
pass
self.hide_tree()
else:
self.custom_hbox.set_label(folder)
self.tree.set_model(store)
self.tree_vbox.show()
if has_duplicates:
# TODO: pop relevant error
# duplicates should block button access
pass
self.present_tree()
def _on_custom_button_clicked(self, button: Gtk.Button) -> None:
self.parent.parse_mods()
local_mods = self.get_mods()
self.parent.offline_man.get_custom_mods(local_mods)
def get_mods(self) -> list[str]:
rows = self.tree.get_selection().get_selected_rows()
dirs = [str(col[1]) for col in rows]
return dirs
class MissionFrame(HeadingFrame):
def __init__(self, parent: OfflineLoader, controller: "Controller") -> None:
super().__init__(heading=offline.mission_frame)
self.parent = parent
self.controller = controller
self.emitter = controller.get_emitter()
self.mission_hbox = FolderHBox(offline.mission_button)
self.mission_button = self.mission_hbox.get_button()
self.mission_button.connect("clicked", self._on_mission_button_clicked)
self.warning = Gtk.Label(halign=Gtk.Align.START, margin_start=10, margin_bottom=5)
self.vbox = VBox()
self.vbox.extend([self.mission_hbox, self.warning])
self.frame.add(self.vbox)
self.emitter.connect("custom_mission_loaded", self._on_mission_loaded)
def _on_mission_loaded(self, emitter: "Emitter", folder: str, is_valid: bool) -> None:
self.mission_hbox.set_label(folder)
if not is_valid:
self.warning.show()
self.warning.set_label(offline.no_mission)
else:
self.warning.hide()
def _on_mission_button_clicked(self, button: Gtk.Button) -> None:
self.parent.offline_man.get_mission()
class RadioFrame(HeadingFrame):
@ -255,20 +305,11 @@ class OfflineLoader(Gtk.Box):
self.add(PageHeading(offline.heading))
self.local_frame = ModFrame(controller, offline.local_frame)
self.local_frame = ModFrame(self, controller, offline.local_frame)
self.custom_frame = CustomModFrame(self, controller, offline.custom_frame)
# TODO: suppress symlink column
self.custom_tree = OfflineModTreeView(controller)
# TODO: custom class
self.mission_hbox = FolderHBox(offline.mission_button)
self.mission_frame = HeadingFrame.new_with_widget_and_label(
self.mission_hbox, offline.mission_frame
)
self.mission_button = self.mission_hbox.get_button()
self.mission_button.connect("clicked", self._on_mission_clicked)
self.mission_frame = MissionFrame(self, controller)
self.radio_frame = RadioFrame(controller)
self.scrollable = Gtk.ScrolledWindow(
@ -290,7 +331,7 @@ class OfflineLoader(Gtk.Box):
self.button_box.set_halign(Gtk.Align.END)
self.button_box.set_margin_top(5)
self.back = Gtk.Button(label="Back")
self.ok = Gtk.Button(label="Launch")
self.ok = Gtk.Button(label="Launch", sensitive=False)
self.back.connect("clicked", self._on_back_clicked)
self.ok.connect("clicked", self._on_ok_clicked)
self.connect("key-press-event", self._on_keypress)
@ -301,46 +342,37 @@ class OfflineLoader(Gtk.Box):
self.add(self.scrollable)
self.add(self.button_box)
def _on_mission_clicked(self, button: Gtk.Button) -> None:
# process path, cf. set_custom_folder()
# file = path / "init.c"
# if not file.exists():
# pop warning
pass
def parse_mods(self) -> None:
# TODO: check method on custom frame
# TODO: delegate folderpicker to offline manager
def check_button(self) -> None:
local_mods = self.local_frame.get_mods()
folder = self.controller.set_custom_folder()
if folder is not None:
# TODO:
self.offline_man.parse_custom_mods(local_mods, folder)
custom_mods = self.custom_frame.get_mods()
if len(local_mods) == 0 and len(custom_mods) == 0:
self.ok.set_sensitive(False)
else:
self.ok.set_sensitive(True)
def _on_keypress(self, widget: Self, event: Gdk.EventKey) -> None:
if event.keyval == Gdk.KEY_Escape:
self.back.emit("clicked")
def populate(self, store: "FastInsertListStore") -> None:
def populate(self, store: Union["FastInsertListStore", None]) -> None:
self.local_frame.set_model(store)
if store is None:
self.local_frame.collapse_tree()
# TODO: toggle if empty model, show warning label
# NOTE: there may be no local mods
# TODO: suppress trees if there are no mods
self.custom_frame.collapse_tree()
self.local_frame.start_empty()
else:
self.local_frame.hide_errors()
# NOTE: suppress custom tree until explicitly loaded
self.custom_frame.hide_all()
def _on_back_clicked(self, button: Gtk.Button) -> None:
self.controller.open_page(NotebookPage.MODS)
def _on_ok_clicked(self, button: Gtk.Button) -> None:
# TODO; block button access if no mods are selected
# TODO: consider hooking up to emitter and changing button state whenever mods are selected
# TODO: grab all values in one pass
# appid = self.radio_frame.get_appid()
# mission = self.mission_frame.get_mission()
# local_mods = self.local_frame.get_mods()
# custom_mods = self.custom_frame.get_mods()
# cf. api.mods._hash(uid, use_custom=True)
# TODO: set up all symlinks prior to launch, including custom ones
# self.offline_man.setup(appid, mission, local_mods, custom_mods)
pass

5
tests/fixtures/cpp/meta1.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol = 1;
publishedid = 1234567890;
name = "ModName";
timestamp = 638278452000000000;
hash = "a1b2c3d4e5f6g7h8i9j0";

5
tests/fixtures/cpp/meta2.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol = 1
publishedid = 1234567890
name = "ModName"
timestamp = 638278452000000000
hash = "a1b2c3d4e5f6g7h8i9j0"

5
tests/fixtures/cpp/meta3.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol=1
publishedid=1234567890
name="ModName"
timestamp=638278452000000000
hash="a1b2c3d4e5f6g7h8i9j0"

5
tests/fixtures/cpp/meta4.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol=1
publishedid=1234567890
name=ModName
timestamp=638278452000000000
hash=a1b2c3d4e5f6g7h8i9j0

5
tests/fixtures/cpp/meta5.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol=1;
publishedid=1234567890;
name=ModName;
timestamp=638278452000000000;
hash=a1b2c3d4e5f6g7h8i9j0;

5
tests/fixtures/cpp/meta6.cpp vendored Normal file
View File

@ -0,0 +1,5 @@
protocol=1
publishedid=1234567890;
name="ModName";
timestamp=638278452000000000;
hash= a1b2c3d4e5f6g7h8i9j0;

3
tests/fixtures/cpp/test.py vendored Normal file
View File

@ -0,0 +1,3 @@
from dzgui.api.mods import tokenize
print(tokenize("meta4.cpp"))

17
tests/test_modmeta.py Normal file
View File

@ -0,0 +1,17 @@
import pytest
from dzgui.api.mods import tokenize
from tests.fixtures import fixture_path
@pytest.mark.mods
@pytest.mark.parametrize("fixture", [
fixture_path("cpp/meta1.cpp"),
fixture_path("cpp/meta2.cpp"),
fixture_path("cpp/meta3.cpp"),
fixture_path("cpp/meta4.cpp"),
fixture_path("cpp/meta5.cpp"),
fixture_path("cpp/meta6.cpp"),
]
)
def test_modmeta(fixture: str) -> None:
meta = tokenize(fixture)
assert meta["name"] == "ModName"