Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions scripts/build/build.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/bin/bash
cd "$(dirname "$0")/../.."
set -e
set -euo pipefail

PY_MAJOR=$(uv run python -c "import sys; print(sys.version_info.major)")
PY_MINOR=$(uv run python -c "import sys; print(sys.version_info.minor)")

UV_VERSION=$(uv run python -c "import winrandr; print(winrandr.__version__)")

echo "==> 安装构建依赖..."
uv sync --dev
Expand All @@ -16,9 +16,9 @@ uv run nuitka --clean-cache=all
echo "==> 使用 Nuitka 编译为单文件 exe..."
if [ "$PY_MAJOR" -ge 3 ] && [ "$PY_MINOR" -ge 13 ]; then
echo " (Python ≥ 3.13,使用 zig 编译器)"
uv run nuitka --standalone --onefile --output-dir=dist --output-filename=winrandr.exe --assume-yes-for-downloads winrandr
uv run nuitka --standalone --onefile --output-dir=dist --output-filename=winrandr.exe --company-name=winrandr --product-name=winrandr --file-version="$UV_VERSION" --assume-yes-for-downloads winrandr
else
uv run nuitka --standalone --onefile --output-dir=dist --output-filename=winrandr.exe --mingw64 --assume-yes-for-downloads winrandr
uv run nuitka --standalone --onefile --output-dir=dist --output-filename=winrandr.exe --company-name=winrandr --product-name=winrandr --file-version="$UV_VERSION" --mingw64 --assume-yes-for-downloads winrandr
fi

echo "==> 构建完成!exe 位于 dist/winrandr.exe"
4 changes: 2 additions & 2 deletions scripts/build/clean.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#!/bin/bash
cd "$(dirname "$0")/../.."
set -e
set -euo pipefail

echo "==> 清理 Nuitka 构建缓存..."
rm -rf dist/winrandr.build dist/winrandr.dist dist/winrandr.onefile-build 2>/dev/null && echo " dist/ 构建目录已清理" || echo " 部分构建目录正在使用中(跳过)"

echo "==> 清理 Python 缓存..."
find . -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null && echo " __pycache__ 已清理" || true
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null && echo " __pycache__ 已清理" || true
find . -type f -name "*.pyc" -delete 2>/dev/null && echo " .pyc 已清理" || true

echo "==> 清理测试和工具缓存..."
Expand Down
23 changes: 23 additions & 0 deletions tests/features/test_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,29 @@ def test_enumerate_modes_empty():
assert modes == []


def test_enumerate_modes_fractional_refresh_rates():
"""QDC 报告的 59.94 应与 EnumDisplaySettings 的 60 匹配。"""
from ctypes import sizeof

from winrandr.features.resolution import enumerate_modes
from winrandr.win32.structures import DEVMODE

def fake_enum(_name, _i, dm_ptr):
dm_ptr._obj.dmSize = sizeof(DEVMODE)
if _i == 0:
dm_ptr._obj.dmPelsWidth = 1920
dm_ptr._obj.dmPelsHeight = 1080
dm_ptr._obj.dmDisplayFrequency = 60
return 1
return 0 # 无更多模式

with patch("winrandr.features.resolution._EnumDisplaySettings", side_effect=fake_enum):
# cur_refresh=59.94 模拟 QDC 报告分数刷新率
modes = enumerate_modes("DISPLAY1", 1920, 1080, 59.94)
assert len(modes) == 1
assert modes[0].is_current is True


def test_enumerate_modes_skips_invalid():
"""无效尺寸(frequency=0)的 mode 应跳过(覆盖 33->44 分支)。"""
from ctypes import sizeof
Expand Down
62 changes: 54 additions & 8 deletions tests/integration/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,50 @@ def test_rel_position_short_name():
assert "DISPLAY2" not in args[0]


def test_get_display_props_connector_type():
"""get_display_props 应包含 connector_type。"""
from winrandr.win32.structures import DISPLAYCONFIG_PATH_INFO

paths = (DISPLAYCONFIG_PATH_INFO * 1)()

def fake_enum(_name, _idx, dd_ptr, _flags):
dd_ptr._obj.DeviceID = r"MONITOR\ABC123"
dd_ptr._obj.StateFlags = 0x05
return True

with patch("winrandr.api._EnumDisplayDevices", side_effect=fake_enum):
with patch("winrandr.api.query_all_config", return_value=(paths, None, 1, 0)):
with patch("winrandr.api.get_gdi_name", return_value=r"\\.\DISPLAY1"):
with patch("winrandr.api.get_adapter_name", return_value=""):
with patch("winrandr.api.get_monitor_device_path", return_value=""):
with patch("winrandr.api.get_connector_type", return_value="HDMI"):
with patch("winrandr.api.get_edid", return_value=None):
props = get_display_props(r"\\.\DISPLAY1")
assert props.get("connector_type") == "HDMI"


def test_get_display_props_no_connector_type():
"""get_display_props 当连接类型为空时不应包含 connector_type。"""
from winrandr.win32.structures import DISPLAYCONFIG_PATH_INFO

paths = (DISPLAYCONFIG_PATH_INFO * 1)()

def fake_enum(_name, _idx, dd_ptr, _flags):
dd_ptr._obj.DeviceID = r"MONITOR\ABC123"
dd_ptr._obj.StateFlags = 0x05
return True

with patch("winrandr.api._EnumDisplayDevices", side_effect=fake_enum):
with patch("winrandr.api.query_all_config", return_value=(paths, None, 1, 0)):
with patch("winrandr.api.get_gdi_name", return_value=r"\\.\DISPLAY1"):
with patch("winrandr.api.get_adapter_name", return_value=""):
with patch("winrandr.api.get_monitor_device_path", return_value=""):
with patch("winrandr.api.get_connector_type", return_value=""):
with patch("winrandr.api.get_edid", return_value=None):
props = get_display_props(r"\\.\DISPLAY1")
assert "connector_type" not in props


def test_list_displays_refresh_from_qdc():
"""vSyncFreq.Denominator 非零时从 QDC 直接获取刷新率。"""
from winrandr.win32.constants import (
Expand Down Expand Up @@ -286,11 +330,13 @@ def fake_enum(_name, _idx, dd_ptr, _flags):
with patch("winrandr.api.get_gdi_name", return_value=r"\\.\DISPLAY1"):
with patch("winrandr.api.get_adapter_name", return_value="GPU0"):
with patch("winrandr.api.get_monitor_device_path", return_value=r"\\.\DEVICE123"):
with patch("winrandr.api.get_edid", return_value={"edid_name": "TestMonitor"}):
props = get_display_props(r"\\.\DISPLAY1")
assert props["device_id"] == r"MONITOR\ABC123\SomeExtra"
assert "attached" in props["state_flags"]
assert "primary" in props["state_flags"]
assert props["adapter"] == "GPU0"
assert props["monitor_path"] == r"\\.\DEVICE123"
assert props["edid_name"] == "TestMonitor"
with patch("winrandr.api.get_connector_type", return_value="HDMI"):
with patch("winrandr.api.get_edid", return_value={"edid_name": "TestMonitor"}):
props = get_display_props(r"\\.\DISPLAY1")
assert props["device_id"] == r"MONITOR\ABC123\SomeExtra"
assert "attached" in props["state_flags"]
assert "primary" in props["state_flags"]
assert props["adapter"] == "GPU0"
assert props["monitor_path"] == r"\\.\DEVICE123"
assert props.get("connector_type") == "HDMI"
assert props["edid_name"] == "TestMonitor"
32 changes: 32 additions & 0 deletions tests/unit/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,38 @@ def test_apply_aliases_y_only():
assert ns.reflect == "y"


def test_apply_aliases_reflect_x_wins_over_y():
"""--reflect x 与 -y 同时使用时,--reflect 优先,-y 被忽略,记录警告。"""
ns = _ns(reflect="x", x=False, y=True)
_apply_aliases(ns)
assert ns.reflect == "x"


def test_apply_aliases_reflect_xy_wins_over_x(caplog):
"""--reflect xy 与 -x 同时使用时,--reflect 优先,-x 被忽略,记录警告。"""
ns = _ns(reflect="xy", x=True, y=False)
_apply_aliases(ns)
assert ns.reflect == "xy"
assert len(caplog.records) == 1
assert "被忽略" in caplog.records[0].message


def test_apply_aliases_reflect_y_wins_over_x(caplog):
"""--reflect y 与 -x 同时使用时,--reflect 优先,-x 被忽略,记录警告。"""
ns = _ns(reflect="y", x=True, y=False)
_apply_aliases(ns)
assert ns.reflect == "y"
assert len(caplog.records) == 1
assert "被忽略" in caplog.records[0].message


def test_apply_aliases_reflect_normal_with_x():
"""--reflect normal(即清除)后 -x 仍生效,因为 normal 视为「未设置」。"""
ns = _ns(reflect="normal", x=True, y=False)
_apply_aliases(ns)
assert ns.reflect == "x"


def test_is_mod_op_true():
"""修改类操作应返回 True。"""
for attr in _MOD_OP_ATTRS:
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/win32/test_connector_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Tests for win32/utils.py get_connector_type function."""

import ctypes
from ctypes import byref, sizeof
from unittest.mock import patch

from winrandr.win32.constants import DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME
from winrandr.win32.structures import (
DISPLAYCONFIG_PATH_INFO,
DISPLAYCONFIG_TARGET_DEVICE_NAME,
)
from winrandr.win32.utils import get_connector_type


def _make_fake_api(target_flags: int):
"""创建模拟 _DisplayConfigGetDeviceInfo 的工厂函数,返回指定 targetFlags。"""

def fake(header_ptr):
tname = DISPLAYCONFIG_TARGET_DEVICE_NAME()
tname.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME
tname.header.size = sizeof(DISPLAYCONFIG_TARGET_DEVICE_NAME)
tname.targetFlags = target_flags
ctypes.memmove(header_ptr, byref(tname), sizeof(DISPLAYCONFIG_TARGET_DEVICE_NAME))
return 0

return fake


def _make_path():
return (DISPLAYCONFIG_PATH_INFO * 1)()[0]


def test_connector_type_hdmi():
path = _make_path()
with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x08)):
assert get_connector_type(path) == "HDMI"


def test_connector_type_dp():
path = _make_path()
with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x10)):
assert get_connector_type(path) == "DisplayPort"


def test_connector_type_usb_c():
path = _make_path()
with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x80)):
assert get_connector_type(path) == "USB-C"


def test_connector_type_vga():
path = _make_path()
with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x04)):
assert get_connector_type(path) == "VGA"


def test_connector_type_dvi():
path = _make_path()
with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x02)):
assert get_connector_type(path) == "DVI"


def test_connector_type_unknown():
"""未知的标志位应返回空字符串。"""
path = _make_path()
with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x40)):
assert get_connector_type(path) == ""


def test_connector_type_api_failure():
"""API 调用失败时返回空字符串。"""
path = _make_path()
with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", return_value=1):
assert get_connector_type(path) == ""
5 changes: 4 additions & 1 deletion winrandr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
__version__ = "0.7.1"

__all__ = [
"GDI_DEVICE_PREFIX",
"ROTATION_FROM_NAME",
"ROTATION_NAMES",
"DisplayInfo",
Expand All @@ -22,6 +23,7 @@
"set_auto",
"set_brightness",
"set_gamma",
"set_night_mode",
"set_noprimary",
"set_off",
"set_position",
Expand All @@ -42,6 +44,7 @@
set_auto,
set_brightness,
set_gamma,
set_night_mode,
set_noprimary,
set_off,
set_position,
Expand All @@ -62,4 +65,4 @@
preview_save,
save_profile,
)
from winrandr.win32.constants import ROTATION_FROM_NAME, ROTATION_NAMES
from winrandr.win32.constants import GDI_DEVICE_PREFIX, ROTATION_FROM_NAME, ROTATION_NAMES
34 changes: 24 additions & 10 deletions winrandr/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"get_display_props",
"list_displays",
"list_providers",
"set_night_mode",
"set_position_relative",
]

Expand All @@ -15,6 +16,7 @@
identify_display,
set_brightness,
set_gamma,
set_night_mode,
)
from winrandr.features.layout import ( # noqa: F401
set_noprimary,
Expand Down Expand Up @@ -50,6 +52,7 @@
from winrandr.win32.structures import DISPLAY_DEVICE
from winrandr.win32.utils import (
get_adapter_name,
get_connector_type,
get_friendly_name_via_enum,
get_gdi_name,
get_monitor_device_path,
Expand Down Expand Up @@ -200,16 +203,7 @@ def get_display_props(device_name: str) -> dict[str, str]:
config = query_all_config()
if config:
paths, _modes, path_count, _mode_count = config
for i in range(path_count):
gdi_name = get_gdi_name(paths[i])
if gdi_name == device_name:
adapter = get_adapter_name(paths[i])
if adapter:
props["adapter"] = adapter
mon_path = get_monitor_device_path(paths[i])
if mon_path:
props["monitor_path"] = mon_path
break
props.update(_get_config_props(device_name, paths, path_count))

edid = get_edid(device_name)
if edid:
Expand All @@ -218,6 +212,26 @@ def get_display_props(device_name: str) -> dict[str, str]:
return props


def _get_config_props(device_name: str, paths: tuple, path_count: int) -> dict[str, str]:
"""从 QDC 路径列表中查找并收集指定显示器的配置属性。"""
props = {}
for i in range(path_count):
gdi_name = get_gdi_name(paths[i])
if gdi_name != device_name:
continue
adapter = get_adapter_name(paths[i])
if adapter:
props["adapter"] = adapter
mon_path = get_monitor_device_path(paths[i])
if mon_path:
props["monitor_path"] = mon_path
conn_type = get_connector_type(paths[i])
if conn_type:
props["connector_type"] = conn_type
break
return props


def list_providers() -> list[dict[str, str | int]]:
"""列举 GPU 适配器。"""
providers = []
Expand Down
Loading
Loading