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

1""" 

2プラグイン作者向けのテスト用の偽物。本体(Qt・QApplication)を起動せずにテストできる。 

3 

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 点")] 

9 

10FakeGraphicaPluginAPI は本物の GraphicaPluginAPI と同じ公開メソッドを持ち、本物が拒む 

11重複登録は同じく ValueError にする(偽物だけ通るとテストが通っても本体で失敗する)。 

12tests/test_plugin_api_contract.py が両者のシグネチャの一致を確かめている。 

13""" 

14import tempfile 

15from collections.abc import Iterator 

16from contextlib import contextmanager 

17 

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 

25 

26 

27class FakeGraphicaPluginAPI: 

28 """ 

29 登録内容を記録するだけの GraphicaPluginAPI。 

30 

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 """ 

37 

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] = {} 

49 

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} 

55 

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 )) 

60 

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} 

69 

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} 

73 

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 [])} 

79 

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 [])} 

85 

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} 

90 

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} 

95 

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} 

100 

101 

102class FakePluginContext(PluginContext): 

103 """ 

104 メモリ上だけで動く PluginContext。本物と同じ検証をし、行った操作を記録する。 

105 

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 色の初期値。 

114 

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 """ 

122 

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 

142 

143 def datasets(self) -> "list[Dataset]": 

144 return list(self._datasets) 

145 

146 def current_dataset(self) -> "Dataset | None": 

147 return self._current 

148 

149 def selected_datasets(self) -> "list[Dataset]": 

150 return list(self._selected) 

151 

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() 

156 

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() 

165 

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 

173 

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 

184 

185 def redraw(self) -> None: 

186 self.redraw_count += 1 

187 

188 def on_datasets_changed(self, callback: Callable[[], Any]) -> None: 

189 self._datasets_changed_callbacks.append(callback) 

190 

191 def on_selection_changed(self, callback: Callable[["Dataset | None"], Any]) -> None: 

192 self._selection_changed_callbacks.append(callback) 

193 

194 def fire_datasets_changed(self) -> None: 

195 """テスト用: データセットが変わったときの通知を送る。""" 

196 for callback in list(self._datasets_changed_callbacks): 

197 callback() 

198 

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) 

205 

206 @property 

207 def parent_widget(self) -> Any: 

208 return None 

209 

210 def show_message(self, text: str, title: str | None = None) -> None: 

211 self.messages.append(("info", title or self._plugin_name, text)) 

212 

213 def show_error(self, text: str, title: str | None = None) -> None: 

214 self.messages.append(("error", title or self._plugin_name, text)) 

215 

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 

221 

222 def named_colors(self) -> list[dict[str, str]]: 

223 return [dict(e) for e in self._named_colors] 

224 

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 

230 

231 def color_palettes(self) -> dict[str, list[str]]: 

232 return {name: list(colors) for name, colors in self._palettes.items()} 

233 

234 def set_color_palettes(self, palettes: dict[str, list[str]]) -> None: 

235 self._palettes = normalize_palettes(palettes) 

236 

237 def active_color_cycle(self) -> list[str]: 

238 return list(self._color_cycle) 

239 

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()