Coverage for graphica/core/plugin_testing.py: 97.9%
150 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""
2プラグイン作者向けのテスト用の偽物。本体(Qt・QApplication)を起動せずにテストできる。
4 api = FakeGraphicaPluginAPI()
5 my_plugin.register(api)
6 ctx = FakePluginContext(datasets=[ds])
7 api.menu_actions[0].callback(ctx)
8 assert ctx.messages == [("info", "My Plugin", "3 点")]
10FakeGraphicaPluginAPI は本物の GraphicaPluginAPI と同じ公開メソッドを持ち、本物が拒む
11重複登録は同じく ValueError にする(偽物だけ通るとテストが通っても本体で失敗する)。
12tests/test_plugin_api_contract.py が両者のシグネチャの一致を確かめている。
13"""
14import tempfile
15from collections.abc import Iterator
16from contextlib import contextmanager
18from graphica.core.color_palettes import BUILTIN_PALETTES, default_color_cycle, known_palette_names, normalize_palettes
19from graphica.core.named_colors import add_named_color
20from graphica.core.plugin_context import PluginContext
21from graphica.core.plugin_types import PluginMenuAction
22from typing import TYPE_CHECKING, Any, Callable
23if TYPE_CHECKING:
24 from graphica.core.dataset import Dataset
27class FakeGraphicaPluginAPI:
28 """
29 登録内容を記録するだけの GraphicaPluginAPI。
31 Attributes:
32 menu_actions (list[PluginMenuAction]): 本物と同じ形。callback に FakePluginContext を渡して試せる。
33 panels (dict): 名前 -> {"widget_factory", "area"}。
34 そのほか fit_functions / importers / exporters / processors / analyzers / plot_types /
35 render_backends は、名前(または拡張子)をキーにした登録内容の辞書。
36 """
38 def __init__(self, plugin_name: str = "test_plugin") -> None:
39 self.plugin_name = plugin_name
40 self.fit_functions: dict[str, dict[str, Any]] = {}
41 self.menu_actions: list[PluginMenuAction] = []
42 self.importers: dict[str, dict[str, Any]] = {}
43 self.exporters: dict[str, dict[str, Any]] = {}
44 self.processors: dict[str, dict[str, Any]] = {}
45 self.analyzers: dict[str, dict[str, Any]] = {}
46 self.panels: dict[str, dict[str, Any]] = {}
47 self.plot_types: dict[str, dict[str, Any]] = {}
48 self.render_backends: dict[str, Any] = {}
50 def register_fit_function(self, name: str, func: Callable[..., Any], param_names: list[str],
51 p0: list[float] | Callable[..., Any] | None = None) -> None:
52 if name in self.fit_functions:
53 raise ValueError(f"フィット関数 '{name}' は既に登録されています。")
54 self.fit_functions[name] = {"func": func, "param_names": param_names, "p0": p0}
56 def register_menu_action(self, text: str, callback: Callable[..., Any], shortcut: str | None = None) -> None:
57 self.menu_actions.append(PluginMenuAction(
58 text=text, callback=callback, shortcut=shortcut, plugin_name=self.plugin_name,
59 ))
61 def register_importer(self, extensions: list[str], loader: Callable[[str], Any], *, name: str | None = None,
62 priority: int = 0) -> None:
63 # 本物は拡張子ごとに複数を優先度順で持つが、使える拡張子を確かめる用途にはこれで足りる。
64 for ext in extensions:
65 ext = ext.lower()
66 if not ext.startswith('.'):
67 ext = '.' + ext
68 self.importers[ext] = {"loader": loader, "name": name, "priority": priority}
70 def register_exporter(self, format_name: str, extension: str, writer: Callable[[Any, str], Any], *,
71 name: str | None = None) -> None:
72 self.exporters[format_name.lower()] = {"extension": extension, "writer": writer, "name": name}
74 def register_processor(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, category: str = "general",
75 param_schema: list[dict[str, Any]] | None = None) -> None:
76 if name in self.processors:
77 raise ValueError(f"データ処理 '{name}' は既に登録されています。")
78 self.processors[name] = {"fn": fn, "category": category, "param_schema": list(param_schema or [])}
80 def register_analyzer(self, name: str, fn: Callable[[Any, dict[str, Any]], Any], *, output_kind: str = "table",
81 param_schema: list[dict[str, Any]] | None = None) -> None:
82 if name in self.analyzers:
83 raise ValueError(f"解析 '{name}' は既に登録されています。")
84 self.analyzers[name] = {"fn": fn, "output_kind": output_kind, "param_schema": list(param_schema or [])}
86 def register_panel(self, name: str, widget_factory: Callable[..., Any], *, area: str = "right") -> None:
87 if name in self.panels:
88 raise ValueError(f"パネル '{name}' は既に登録されています。")
89 self.panels[name] = {"widget_factory": widget_factory, "area": area}
91 def register_plot_type(self, type_name: str, drawer: Callable[..., Any], *, requires_2d: bool = False) -> None:
92 if type_name in self.plot_types:
93 raise ValueError(f"プロット種別 '{type_name}' は既に登録されています。")
94 self.plot_types[type_name] = {"drawer": drawer, "requires_2d": requires_2d}
96 def register_render_backend(self, name: str, backend: Any) -> None:
97 if name in self.render_backends:
98 raise ValueError(f"描画バックエンド '{name}' は既に登録されています。")
99 self.render_backends[name] = {"backend": backend}
102class FakePluginContext(PluginContext):
103 """
104 メモリ上だけで動く PluginContext。本物と同じ検証をし、行った操作を記録する。
106 Args:
107 datasets (list[Dataset] | None): タブにあるデータセット。
108 current (Dataset | None): 選ばれているデータセット。
109 selected (list[Dataset] | None): 選択中のデータセット。省略時は current だけ。
110 plugin_name (str): show_message の既定のタイトル。
111 data_dir (str | None): 省略時は一時フォルダを作る。
112 named_colors (list[dict] | None) / color_palettes (dict | None) / color_cycle (list[str] | None):
113 色の初期値。
115 Attributes:
116 messages (list[tuple]): 表示したメッセージ ("info" または "error", タイトル, 本文)。
117 undo_descriptions (list[str]): Undo 履歴に積まれた操作の説明。undo_group の中の変更は、
118 そのグループの説明1つにまとまる(本体と同じく、何も変えなかったグループは積まない)。
119 redraw_count (int): redraw() が呼ばれた回数。
120 active_palette_name (str | None): set_active_color_palette() で選んだパレット名。
121 """
123 def __init__(self, datasets: "list[Dataset] | None" = None, current: "Dataset | None" = None,
124 selected: "list[Dataset] | None" = None, plugin_name: str = "test_plugin",
125 data_dir: str | None = None, named_colors: list[dict[str, str]] | None = None,
126 color_palettes: dict[str, list[str]] | None = None, color_cycle: list[str] | None = None) -> None:
127 self._datasets = list(datasets or [])
128 self._current = current
129 self._selected = list(selected) if selected is not None else ([current] if current else [])
130 self._plugin_name = plugin_name
131 self._data_dir = data_dir
132 self._named_colors = [dict(e) for e in (named_colors or [])]
133 self._palettes = normalize_palettes(color_palettes or {})
134 self._color_cycle = list(color_cycle or ["#1f77b4", "#ff7f0e", "#2ca02c"])
135 self._datasets_changed_callbacks: list[Callable[[], Any]] = []
136 self._selection_changed_callbacks: list[Callable[[Dataset | None], Any]] = []
137 self.messages: list[tuple[str, str, str]] = [] # ("info" / "error", タイトル, 本文)
138 self.undo_descriptions: list[str | None] = []
139 self.redraw_count = 0
140 self.active_palette_name: str | None = None
141 self._undo_group: dict[str, Any] | None = None
143 def datasets(self) -> "list[Dataset]":
144 return list(self._datasets)
146 def current_dataset(self) -> "Dataset | None":
147 return self._current
149 def selected_datasets(self) -> "list[Dataset]":
150 return list(self._selected)
152 def add_dataset(self, dataset: "Dataset", description: str | None = None) -> None:
153 self._datasets.append(dataset)
154 self._record_undo(description or f"[{self._plugin_name}] データセットの追加")
155 self.fire_datasets_changed()
157 def set_dataset_properties(self, dataset: "Dataset", values: dict[str, Any], description: str | None = None) -> None:
158 unknown = sorted(k for k in values if not hasattr(dataset, k))
159 if unknown:
160 raise AttributeError(f"Dataset に無い属性です: {', '.join(unknown)}")
161 for key, value in values.items():
162 setattr(dataset, key, value)
163 self._record_undo(description or f"[{self._plugin_name}] プロパティの変更")
164 self.fire_datasets_changed()
166 def _record_undo(self, description: str) -> None:
167 group = self._undo_group
168 if group is None:
169 self.undo_descriptions.append(description)
170 elif not group["started"]:
171 self.undo_descriptions.append(group["description"])
172 group["started"] = True
174 @contextmanager
175 def undo_group(self, description: str) -> Iterator[None]:
176 outermost = self._undo_group is None
177 if outermost:
178 self._undo_group = {"description": description, "started": False}
179 try:
180 yield
181 finally:
182 if outermost:
183 self._undo_group = None
185 def redraw(self) -> None:
186 self.redraw_count += 1
188 def on_datasets_changed(self, callback: Callable[[], Any]) -> None:
189 self._datasets_changed_callbacks.append(callback)
191 def on_selection_changed(self, callback: Callable[["Dataset | None"], Any]) -> None:
192 self._selection_changed_callbacks.append(callback)
194 def fire_datasets_changed(self) -> None:
195 """テスト用: データセットが変わったときの通知を送る。"""
196 for callback in list(self._datasets_changed_callbacks):
197 callback()
199 def select(self, dataset: "Dataset | None") -> None:
200 """テスト用: データセット一覧で dataset を選んだことにし、通知を送る。"""
201 self._current = dataset
202 self._selected = [dataset] if dataset is not None else []
203 for callback in list(self._selection_changed_callbacks):
204 callback(dataset)
206 @property
207 def parent_widget(self) -> Any:
208 return None
210 def show_message(self, text: str, title: str | None = None) -> None:
211 self.messages.append(("info", title or self._plugin_name, text))
213 def show_error(self, text: str, title: str | None = None) -> None:
214 self.messages.append(("error", title or self._plugin_name, text))
216 @property
217 def data_dir(self) -> str:
218 if self._data_dir is None:
219 self._data_dir = tempfile.mkdtemp(prefix=f"graphica_{self._plugin_name}_")
220 return self._data_dir
222 def named_colors(self) -> list[dict[str, str]]:
223 return [dict(e) for e in self._named_colors]
225 def set_named_colors(self, entries: list[dict[str, str]]) -> None:
226 validated: list[dict[str, str]] = []
227 for entry in entries:
228 validated = add_named_color(validated, entry.get("name"), entry.get("color"))
229 self._named_colors = validated
231 def color_palettes(self) -> dict[str, list[str]]:
232 return {name: list(colors) for name, colors in self._palettes.items()}
234 def set_color_palettes(self, palettes: dict[str, list[str]]) -> None:
235 self._palettes = normalize_palettes(palettes)
237 def active_color_cycle(self) -> list[str]:
238 return list(self._color_cycle)
240 def set_active_color_palette(self, name: str) -> None:
241 if name not in known_palette_names(self._palettes):
242 raise ValueError(f"配色パレット「{name}」はありません。")
243 self.active_palette_name = name
244 colors = BUILTIN_PALETTES.get(name) or self._palettes.get(name)
245 self._color_cycle = list(colors) if colors else default_color_cycle()