1
1
mirror of https://github.com/KenanZhu/AutoLibrary.git synced 2026-06-17 23:13:03 +08:00

refactor(pages): 抽取时间选择策略为 TimeSelectMaker,将 Overlay 基类更名为 Dialog

将 findBestTimeOption 中的预约/续约双分支逻辑抽象为策略模式:
- TimeOptionReader 负责从 WebElement 提取时间数据(ReserveTimeReader / RenewTimeReader)
- TimeDecisionMaker 执行纯决策算法,零 Selenium 依赖
- TimeSelectMaker 作为工厂统一创建配置好的决策器
- 共享常量 LIBRARY_CLOSE_MINS 统一收敛至 TimeSelectMaker

同时将 Overlay 基类重命名为 Dialog,SeatMapOverlay 同步更名为 SeatMapDialog,保持命名一致性。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 13:13:43 +08:00
parent caa563e770
commit 345cb95b98
14 changed files with 244 additions and 114 deletions
+3 -3
View File
@@ -18,7 +18,7 @@ from selenium.common.exceptions import (
TimeoutException,
)
from pages.components.SeatMapOverlay import SeatMapOverlay
from pages.components.SeatMapDialog import SeatMapDialog
from pages.components.ReserveResultDialog import ReserveResultDialog
@@ -124,9 +124,9 @@ class ReserveView:
def openSeatMap(
self,
) -> SeatMapOverlay:
) -> SeatMapDialog:
return SeatMapOverlay(self._driver)
return SeatMapDialog(self._driver)
def submitReserve(
self,
+2 -2
View File
@@ -12,7 +12,7 @@ from pages.LoginPage import LoginPage
from pages.MainShell import MainShell
from pages.ReserveView import ReserveView
from pages.RecordsView import RecordsView
from pages.components.SeatMapOverlay import SeatMapOverlay
from pages.components.SeatMapDialog import SeatMapDialog
from pages.components.TimeSelectDialog import TimeSelectDialog
from pages.components.ReserveResultDialog import ReserveResultDialog
from pages.components.CheckinResultDialog import CheckinResultDialog
@@ -24,7 +24,7 @@ __all__ = [
"MainShell",
"ReserveView",
"RecordsView",
"SeatMapOverlay",
"SeatMapDialog",
"TimeSelectDialog",
"ReserveResultDialog",
"CheckinResultDialog",
+2 -2
View File
@@ -16,10 +16,10 @@ from selenium.common.exceptions import (
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from pages.components.Overlay import Overlay
from pages.components.Dialog import Dialog
class CheckinResultDialog(Overlay):
class CheckinResultDialog(Dialog):
"""
Check-in result dialog.
"""
@@ -13,7 +13,7 @@ from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class Overlay:
class Dialog:
"""
Context-managed overlay / modal / dialog on a page.
@@ -36,7 +36,7 @@ class Overlay:
def __enter__(
self,
) -> "Overlay":
) -> "Dialog":
WebDriverWait(self._driver, self._timeout).until(
EC.visibility_of_element_located(self._root_locator)
+2 -2
View File
@@ -16,10 +16,10 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from pages.components.Overlay import Overlay
from pages.components.Dialog import Dialog
class RenewDialog(Overlay):
class RenewDialog(Dialog):
"""
Renewal time selection dialog.
"""
+2 -2
View File
@@ -14,10 +14,10 @@ from selenium.common.exceptions import (
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from pages.components.Overlay import Overlay
from pages.components.Dialog import Dialog
class ReserveResultDialog(Overlay):
class ReserveResultDialog(Dialog):
"""
Reservation result dialog shown after submitting a reserve request.
"""
@@ -18,10 +18,10 @@ from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from pages.components.Overlay import Overlay
from pages.components.Dialog import Dialog
class SeatMapOverlay(Overlay):
class SeatMapDialog(Dialog):
"""
Seat selection overlay that opens after choosing a floor and room.
"""
+2 -2
View File
@@ -15,10 +15,10 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from pages.components.Overlay import Overlay
from pages.components.Dialog import Dialog
class TimeSelectDialog(Overlay):
class TimeSelectDialog(Dialog):
"""
Time selection panel that appears after selecting a seat.
+2 -2
View File
@@ -7,14 +7,14 @@ This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from pages.components.SeatMapOverlay import SeatMapOverlay
from pages.components.SeatMapDialog import SeatMapDialog
from pages.components.TimeSelectDialog import TimeSelectDialog
from pages.components.ReserveResultDialog import ReserveResultDialog
from pages.components.CheckinResultDialog import CheckinResultDialog
from pages.components.RenewDialog import RenewDialog
__all__ = [
"SeatMapOverlay",
"SeatMapDialog",
"TimeSelectDialog",
"ReserveResultDialog",
"CheckinResultDialog",
+16 -17
View File
@@ -19,16 +19,13 @@ from selenium.webdriver.remote.webdriver import WebDriver
from base.MsgBase import MsgBase
from pages.MainShell import MainShell
from pages.components.RenewDialog import RenewDialog
from pages.flows._helpers import (
timeStrToMins,
minsToTimeStr,
findBestTimeOption,
)
from pages.flows._helpers import timeStrToMins, minsToTimeStr
from pages.strategies.timeSelectMaker import TimeSelectMaker
class RenewFlow(MsgBase):
LIBRARY_CLOSE_MINS = 1410
LIBRARY_CLOSE_MINS = TimeSelectMaker.LIBRARY_CLOSE_MINS
def __init__(
self,
@@ -83,24 +80,26 @@ class RenewFlow(MsgBase):
self._showTrace("当前未查询到可用续约时间 !", self.TraceLevel.WARNING)
self._shell.refresh()
return False
best_opt, best_text, actual_diff, free_times = findBestTimeOption(
renew_time_opts, target_renew_mins, max_diff, prefer_earlier,
is_reserve=False,
result = TimeSelectMaker.forRenew().decide(
renew_time_opts,
target_renew_mins,
max_diff,
prefer_earlier
)
if best_opt is not None:
best_opt.click()
abs_diff = abs(actual_diff)
if actual_diff < 0:
if result.selected_index >= 0:
renew_time_opts[result.selected_index].click()
abs_diff = abs(result.actual_diff)
if result.actual_diff < 0:
relation = f"早了 {abs_diff} 分钟"
elif actual_diff > 0:
elif result.actual_diff > 0:
relation = f"晚了 {abs_diff} 分钟"
else:
relation = "正好等于 续约时间"
self._showTrace(
f"选择距离期望续约时间最近的 {best_text}, "
f"选择距离期望续约时间最近的 {result.display_text}, "
f"与期望续约时间相比 {relation}"
)
record["time"]["end"] = best_text.strip()
record["time"]["end"] = result.display_text.strip()
renew_ok_btn.click()
self._shell.refresh()
return True
@@ -109,7 +108,7 @@ class RenewFlow(MsgBase):
f"所有可选时间与目标时间相差都超过了 {max_diff} 分钟 !",
self.TraceLevel.WARNING,
)
self._showTrace(f"当前可供续约的时间有: {free_times}")
self._showTrace(f"当前可供续约的时间有: {result.free_times}")
self._shell.refresh()
return False
except (NoSuchElementException, TimeoutException) as e:
+17 -17
View File
@@ -20,11 +20,8 @@ from selenium.webdriver.remote.webdriver import WebDriver
from base.MsgBase import MsgBase
from pages.MainShell import MainShell
from pages.flows._helpers import (
timeStrToMins,
minsToTimeStr,
findBestTimeOption,
)
from pages.flows._helpers import timeStrToMins, minsToTimeStr
from pages.strategies.timeSelectMaker import TimeSelectMaker
from pages.ReserveView import ReserveView
from pages.components.ReserveResultDialog import ReserveResultDialog
from pages.components.TimeSelectDialog import TimeSelectDialog
@@ -50,7 +47,7 @@ class ReserveContext:
class ReserveFlow(MsgBase):
LIBRARY_CLOSE_MINS = timeStrToMins("23:30")
LIBRARY_CLOSE_MINS = TimeSelectMaker.LIBRARY_CLOSE_MINS
def __init__(
self,
@@ -219,30 +216,33 @@ class ReserveFlow(MsgBase):
f"{time_type} 选择失败 ! : 当前未查询到可用时间", self.TraceLevel.ERROR
)
return -1
best_opt, best_text, actual_diff, free_times = findBestTimeOption(
all_time_opts, target_time, max_time_diff, prefer_earlier, is_reserve=True
result = TimeSelectMaker.forReserve().decide(
all_time_opts,
target_time,
max_time_diff,
prefer_earlier
)
if best_opt is not None:
best_opt.click()
abs_diff = abs(actual_diff)
if actual_diff < 0:
if result.selected_index >= 0:
all_time_opts[result.selected_index].click()
abs_diff = abs(result.actual_diff)
if result.actual_diff < 0:
relation = f"早了 {abs_diff} 分钟"
elif actual_diff > 0:
elif result.actual_diff > 0:
relation = f"晚了 {abs_diff} 分钟"
else:
relation = f"正好等于 {time_type}"
self._showTrace(
f"选择距离期望 {time_type} 最近的 {best_text}, "
f"选择距离期望 {time_type} 最近的 {result.display_text}, "
f"与期望 {time_type} 相比 {relation}"
)
return target_time + actual_diff
return target_time + result.actual_diff
target_time_str = minsToTimeStr(target_time)
self._showTrace(
f"无法选择最近的 {time_type} {target_time_str}, "
f"所有可选时间与目标时间相差都超过 {max_time_diff} 分钟",
self.TraceLevel.WARNING,
)
self._showTrace(f"当前可供预约的 {time_type} 有: {free_times}")
self._showTrace(f"当前可供预约的 {time_type} 有: {result.free_times}")
return -1
def _calcEndTime(
@@ -251,7 +251,7 @@ class ReserveFlow(MsgBase):
duration: int,
) -> int:
expect_end_mins = int(begin_mins + duration * 60)
expect_end_mins = int(begin_mins + duration*60)
if expect_end_mins > self.LIBRARY_CLOSE_MINS:
expect_end_mins = self.LIBRARY_CLOSE_MINS
self._showTrace(
-61
View File
@@ -7,9 +7,6 @@ This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from datetime import datetime
def timeStrToMins(
time_str: str,
) -> int:
@@ -17,67 +14,9 @@ def timeStrToMins(
hour, minute = map(int, time_str.split(":"))
return hour * 60 + minute
def minsToTimeStr(
mins: int,
) -> str:
hour, minute = divmod(int(mins), 60)
return f"{hour:02d}:{minute:02d}"
def findBestTimeOption(
time_options: list,
target_time: int,
max_time_diff: int,
prefer_earlier: bool,
is_reserve: bool = True,
) -> tuple:
"""
Find the best time option from available WebElement options.
Returns:
(bestElement, bestText, actual_diff, freeTimesList)
or (None, None, None, freeTimesList) if no suitable option.
"""
free_times = []
best_time_diff = max_time_diff
best_actual_diff = None
best_time_opt = None
for time_opt in time_options:
if is_reserve:
time_attr = time_opt.get_attribute("time")
if time_attr == "now":
now = datetime.now()
time_val = now.hour * 60 + now.minute
elif time_attr and time_attr.isdigit():
time_val = int(time_attr)
else:
continue
else:
time_attr = time_opt.get_attribute("id")
if not (time_attr and time_attr.isdigit()):
continue
time_val = int(time_attr)
free_times.append(
time_opt.text.strip()
if not is_reserve
else minsToTimeStr(time_val)
)
actual_diff = time_val - target_time
abs_diff = abs(actual_diff)
if abs_diff < best_time_diff or (
abs_diff == best_time_diff
and (
(prefer_earlier and actual_diff <= 0)
or (not prefer_earlier and actual_diff >= 0)
)
):
best_time_diff = abs_diff
best_actual_diff = actual_diff
best_time_opt = time_opt
if best_time_opt is not None:
return (best_time_opt, best_time_opt.text.strip(), best_actual_diff, free_times)
return (None, None, None, free_times)
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from pages.strategies.timeSelectMaker import (
TimeSelectMaker,
TimeDecisionMaker,
TimeOptionReader,
ReserveTimeReader,
RenewTimeReader,
TimeOption,
TimeSelectionResult,
)
__all__ = [
"TimeSelectMaker",
"TimeDecisionMaker",
"TimeOptionReader",
"ReserveTimeReader",
"RenewTimeReader",
"TimeOption",
"TimeSelectionResult",
]
+164
View File
@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""
Copyright (c) 2026 KenanZhu.
All rights reserved.
This software is provided "as is", without any warranty of any kind.
You may use, modify, and distribute this file under the terms of the MIT License.
See the LICENSE file for details.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from pages.flows._helpers import minsToTimeStr
@dataclass
class TimeOption:
value: int
element_text: str
@dataclass
class TimeSelectionResult:
selected_index: int = -1
selected_value: int = 0
display_text: str = ""
actual_diff: int = 0
free_times: list[str] = field(default_factory=list)
class TimeOptionReader(ABC):
@abstractmethod
def readOptions(
self,
elements: list,
) -> list[TimeOption]:
...
def formatFreeTime(
self,
opt: TimeOption,
) -> str:
return opt.element_text
class ReserveTimeReader(TimeOptionReader):
"""
Reads the ``time`` HTML attribute for the reserve flow.
Special value ``"now"`` is resolved to the current wall-clock minute.
"""
def readOptions(
self,
elements: list,
) -> list[TimeOption]:
options: list[TimeOption] = []
for el in elements:
time_attr = el.get_attribute("time")
if time_attr == "now":
now = datetime.now()
value = now.hour * 60 + now.minute
elif time_attr and time_attr.isdigit():
value = int(time_attr)
else:
continue
options.append(TimeOption(value=value, element_text=el.text.strip()))
return options
def formatFreeTime(
self,
opt: TimeOption,
) -> str:
return minsToTimeStr(opt.value)
class RenewTimeReader(TimeOptionReader):
"""
Reads the ``id`` HTML attribute for the renewal flow.
"""
def readOptions(
self,
elements: list,
) -> list[TimeOption]:
options: list[TimeOption] = []
for el in elements:
time_attr = el.get_attribute("id")
if not (time_attr and time_attr.isdigit()):
continue
options.append(TimeOption(value=int(time_attr), element_text=el.text.strip()))
return options
class TimeDecisionMaker:
def __init__(
self,
reader: TimeOptionReader,
) -> None:
self._reader = reader
def decide(
self,
elements: list,
target_time: int,
max_time_diff: int,
prefer_earlier: bool,
) -> TimeSelectionResult:
options = self._reader.readOptions(elements)
free_times = [self._reader.formatFreeTime(o) for o in options]
best_diff = max_time_diff
best_actual_diff = None
best_index = -1
for i, opt in enumerate(options):
actual_diff = opt.value - target_time
abs_diff = abs(actual_diff)
if abs_diff < best_diff or (
abs_diff == best_diff
and (
(prefer_earlier and actual_diff <= 0)
or (not prefer_earlier and actual_diff >= 0)
)
):
best_diff = abs_diff
best_actual_diff = actual_diff
best_index = i
if best_index == -1:
return TimeSelectionResult(free_times=free_times)
chosen = options[best_index]
return TimeSelectionResult(
selected_index=best_index,
selected_value=chosen.value,
display_text=chosen.element_text,
actual_diff=best_actual_diff or 0,
free_times=free_times,
)
class TimeSelectMaker:
LIBRARY_CLOSE_MINS = 1410
MAX_DURATION_HOURS = 8
@staticmethod
def forReserve(
) -> TimeDecisionMaker:
return TimeDecisionMaker(ReserveTimeReader())
@staticmethod
def forRenew(
) -> TimeDecisionMaker:
return TimeDecisionMaker(RenewTimeReader())