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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user