Coverage for tests / unit / test_graphable.py: 100%

251 statements  

« prev     ^ index     » next       coverage.py v7.13.3, created at 2026-02-16 21:32 +0000

1from unittest.mock import MagicMock 

2 

3from pytest import raises 

4 

5from graphable.errors import GraphCycleError 

6from graphable.graphable import Graphable 

7 

8 

9class TestGraphable: 

10 def test_initialization(self): 

11 ref = "my_ref" 

12 node = Graphable(ref) 

13 assert node.reference == ref 

14 assert len(node.dependents) == 0 

15 assert len(node.depends_on) == 0 

16 # Ensure reference is accessible and consistent 

17 assert node.reference == ref 

18 

19 def test_dependencies_new(self): 

20 node_a = Graphable("A") 

21 node_b = Graphable("B") 

22 

23 # Test adding a new dependency using the public API 

24 node_a.add_dependency(node_b) 

25 assert node_b in node_a.depends_on 

26 assert node_a in node_b.dependents 

27 # This call should cover lines 47-51 in _add_depends_on and lines in add_dependency that call _add_dependent indirectly 

28 

29 def test_dependencies_existing(self): 

30 node_a = Graphable("A") 

31 node_b = Graphable("B") 

32 

33 # Add dependency once 

34 node_a.add_dependency(node_b) 

35 assert node_b in node_a.depends_on 

36 assert node_a in node_b.dependents 

37 initial_dependents_count_b = len(node_b.dependents) 

38 initial_depends_on_count_a = len(node_a.depends_on) 

39 

40 # Add the same dependency again - should not change state or execute internal logic again 

41 node_a.add_dependency(node_b) 

42 assert node_b in node_a.depends_on 

43 assert node_a in node_b.dependents 

44 # Assert that adding an existing dependency does not increase counts 

45 assert len(node_a.depends_on) == initial_depends_on_count_a 

46 assert len(node_b.dependents) == initial_dependents_count_b 

47 # This tests the 'else' path of _add_depends_on and _add_dependent 

48 

49 def test_add_dependency_and_dependent_new_items(self): 

50 node_a = Graphable("A") 

51 node_b = Graphable("B") 

52 node_c = Graphable("C") 

53 

54 # Test _add_dependent for a new item, ensuring the 'if' block is covered 

55 assert len(node_a.dependents) == 0 

56 node_a._add_dependent(node_b) # This call should execute lines 36-38 

57 assert node_b in node_a.dependents # Verifies line 37 

58 assert len(node_a.dependents) == 1 # Verifies line 37 

59 

60 # Test _add_depends_on for a new item, ensuring the 'if' block is covered 

61 assert len(node_b.depends_on) == 0 

62 node_b._add_depends_on(node_a) # This call should execute lines 47-51 

63 assert node_a in node_b.depends_on # Verifies line 48 

64 assert len(node_b.depends_on) == 1 # Verifies line 48 

65 

66 # Test adding existing items to ensure they are not re-added (tests else path implicitly) 

67 node_a._add_dependent(node_b) # Should hit the 'else' path for _add_dependent 

68 assert len(node_a.dependents) == 1 

69 node_b._add_depends_on(node_a) # Should hit the 'else' path for _add_depends_on 

70 assert len(node_b.depends_on) == 1 

71 

72 # Test public API method add_dependency which calls _add_depends_on and dependency._add_dependent 

73 node_c.add_dependency(node_a) # node_a is now a dependency of node_c 

74 assert node_a in node_c.depends_on 

75 assert node_c in node_a.dependents # tests _add_dependent called via public API 

76 

77 # Test public API method add_dependent which calls _add_dependent and dependent._add_depends_on 

78 node_c.add_dependent(node_b) # node_b is now a dependent of node_c 

79 assert node_b in node_c.dependents 

80 assert ( 

81 node_c in node_b.depends_on 

82 ) # tests _add_depends_on called via public API 

83 

84 def test_add_dependents_multiple(self): 

85 node_a = Graphable("A") 

86 node_b = Graphable("B") 

87 node_c = Graphable("C") 

88 dependents_set = {node_b, node_c} 

89 

90 # Test adding new dependents 

91 node_a.add_dependents(dependents_set) # Covers lines 73-75 (logger and loop) 

92 assert node_b in node_a.dependents 

93 assert node_c in node_a.dependents 

94 assert len(node_a.dependents) == 2 

95 # Verify reciprocal dependencies (lines 73-75 should be covered by this loop) 

96 assert node_a in node_b.depends_on 

97 assert node_a in node_c.depends_on 

98 assert len(node_b.depends_on) == 1 

99 assert len(node_c.depends_on) == 1 

100 

101 # Test adding dependents again with some existing 

102 node_d = Graphable("D") 

103 node_a.add_dependents({node_b, node_d}) # node_b already exists, node_d is new 

104 assert node_b in node_a.dependents 

105 assert node_d in node_a.dependents 

106 assert len(node_a.dependents) == 3 # node_b, node_c, node_d 

107 assert node_a in node_d.depends_on 

108 

109 def test_add_dependencies_multiple(self): 

110 node_a = Graphable("A") 

111 node_b = Graphable("B") 

112 node_c = Graphable("C") 

113 dependencies_set = {node_b, node_c} 

114 

115 # Test adding multiple dependencies 

116 node_a.add_dependencies(dependencies_set) 

117 assert node_b in node_a.depends_on 

118 assert node_c in node_a.depends_on 

119 assert len(node_a.depends_on) == 2 

120 # Verify reciprocal dependents 

121 assert node_a in node_b.dependents 

122 assert node_a in node_c.dependents 

123 

124 def test_tags(self): 

125 node = Graphable("A") 

126 node.add_tag("t1") 

127 node.add_tag("t2") 

128 assert node.tags == {"t1", "t2"} 

129 # Ensure it's a copy 

130 node.tags.add("t3") 

131 assert "t3" not in node.tags 

132 assert node.tags == {"t1", "t2"} 

133 

134 def test_remove_tag_existing(self): 

135 node = Graphable("A") 

136 node.add_tag("t1") 

137 assert node.is_tagged("t1") 

138 node.remove_tag("t1") # Covers line 175 (discard) and 176 (logger) 

139 assert not node.is_tagged("t1") 

140 assert len(node.tags) == 0 

141 

142 def test_remove_tag_non_existent(self): 

143 node = Graphable("A") 

144 assert not node.is_tagged("t1") 

145 node.remove_tag( 

146 "t1" 

147 ) # Should not raise an error, covers line 175 (discard) and 176 (logger) 

148 assert not node.is_tagged("t1") 

149 assert len(node.tags) == 0 

150 

151 def test_reference_property_access(self): 

152 ref_val = "test_ref" 

153 node = Graphable(ref_val) 

154 # Accessing reference property directly 

155 assert node.reference == ref_val # Covers lines 153-156 

156 # Accessing it again after some operations 

157 node.add_tag("test") 

158 assert node.reference == ref_val 

159 node_b = Graphable("B") 

160 node.add_dependency(node_b) 

161 assert node.reference == ref_val 

162 

163 def test_dependents_property_is_copy(self): 

164 node = Graphable("A") 

165 deps = node.dependents 

166 assert isinstance(deps, set) 

167 # Modifying the returned set shouldn't modify the internal set 

168 deps.add(Graphable("junk")) 

169 assert len(node.dependents) == 0 

170 

171 def test_depends_on_property_is_copy(self): 

172 node = Graphable("A") 

173 deps = node.depends_on 

174 assert isinstance(deps, set) 

175 deps.add(Graphable("junk")) 

176 assert len(node.depends_on) == 0 

177 

178 def test_provides_to_alias(self): 

179 node_a = Graphable("A") 

180 node_b = Graphable("B") 

181 node_a.provides_to(node_b) # Test the alias 

182 assert node_b in node_a.dependents 

183 assert node_a in node_b.depends_on 

184 

185 def test_requires_alias(self): 

186 node_a = Graphable("A") 

187 node_b = Graphable("B") 

188 node_a.requires(node_b) # Test the alias 

189 assert node_b in node_a.depends_on 

190 assert node_a in node_b.dependents 

191 

192 def test_cycle_detection(self): 

193 a = Graphable("A") 

194 b = Graphable("B") 

195 c = Graphable("C") 

196 

197 a.add_dependency(b, check_cycles=True) 

198 b.add_dependency(c, check_cycles=True) 

199 

200 # C -> B -> A. Adding A -> C creates a cycle. 

201 with raises(GraphCycleError) as excinfo: 

202 c.add_dependency(a, check_cycles=True) 

203 assert "would create a cycle" in str(excinfo.value) 

204 assert excinfo.value.cycle is not None 

205 assert a in excinfo.value.cycle 

206 assert b in excinfo.value.cycle 

207 assert c in excinfo.value.cycle 

208 

209 def test_cycle_detection_dependent(self): 

210 a = Graphable("A") 

211 b = Graphable("B") 

212 c = Graphable("C") 

213 

214 a.add_dependent(b, check_cycles=True) 

215 b.add_dependent(c, check_cycles=True) 

216 

217 # A -> B -> C. Adding C -> A creates a cycle. 

218 with raises(GraphCycleError) as excinfo: 

219 c.add_dependent(a, check_cycles=True) 

220 assert "would create a cycle" in str(excinfo.value) 

221 

222 def test_find_path(self): 

223 a = Graphable("A") 

224 b = Graphable("B") 

225 c = Graphable("C") 

226 d = Graphable("D") 

227 

228 a.add_dependent(b) 

229 b.add_dependent(c) 

230 c.add_dependent(d) 

231 

232 path = a.find_path(d) 

233 assert path == [a, b, c, d] 

234 

235 assert ( 

236 a.find_path(a) is None 

237 ) # Simple BFS doesn't find self unless there's a loop 

238 

239 a.add_dependent(a) 

240 assert a.find_path(a) == [a, a] 

241 

242 def test_ordering(self): 

243 a = Graphable("A") 

244 b = Graphable("B") 

245 c = Graphable("C") 

246 

247 a.add_dependent(b) 

248 b.add_dependent(c) 

249 

250 # A is ancestor of B and C 

251 # B is ancestor of C 

252 assert a < b 

253 assert b < c 

254 assert a < c 

255 

256 assert c > b 

257 assert b > a 

258 assert c > a 

259 

260 assert a <= b 

261 assert a <= a 

262 

263 assert c >= b 

264 assert c >= c 

265 

266 # Incomparable nodes 

267 d = Graphable("D") 

268 assert not (a < d) 

269 assert not (d < a) 

270 assert not (a > d) 

271 assert not (d > a) 

272 

273 # Equality 

274 assert a == a 

275 assert a != b 

276 assert not (a == b) 

277 

278 def test_total_ordering_decorator(self): 

279 # Verify that functools.total_ordering filled in the rest 

280 a = Graphable("A") 

281 b = Graphable("B") 

282 a.add_dependent(b) 

283 

284 assert a <= b # Should be True 

285 assert b >= a # Should be True 

286 assert b > a # Should be True 

287 

288 def test_edge_attributes(self): 

289 a = Graphable("A") 

290 b = Graphable("B") 

291 a.add_dependent(b, weight=10, label="primary") 

292 

293 assert a.edge_attributes(b)["weight"] == 10 

294 assert a.edge_attributes(b)["label"] == "primary" 

295 assert b.edge_attributes(a)["weight"] == 10 

296 

297 # Test updating attribute 

298 a.set_edge_attribute(b, "weight", 20) 

299 assert b.edge_attributes(a)["weight"] == 20 

300 

301 def test_node_duration_and_status(self): 

302 a = Graphable("A") 

303 assert a.duration == 0.0 

304 assert a.status == "pending" 

305 

306 a.duration = 5.0 

307 a.status = "running" 

308 assert a.duration == 5.0 

309 assert a.status == "running" 

310 

311 def test_comparison_not_implemented(self): 

312 a = Graphable("A") 

313 with raises(TypeError): 

314 _ = a < 5 

315 with raises(TypeError): 

316 _ = a <= 5 

317 with raises(TypeError): 

318 _ = a > 5 

319 with raises(TypeError): 

320 _ = a >= 5 

321 

322 def test_edge_attributes_key_error(self): 

323 a = Graphable("A") 

324 b = Graphable("B") 

325 with raises(KeyError, match="No edge between"): 

326 a.edge_attributes(b) 

327 

328 def test_set_edge_attribute_upstream(self): 

329 a = Graphable("A") 

330 b = Graphable("B") 

331 b.add_dependency(a) 

332 

333 # Set attribute from descendant to ancestor (upstream) 

334 b.set_edge_attribute(a, "label", "upstream") 

335 assert b.edge_attributes(a)["label"] == "upstream" 

336 assert a.edge_attributes(b)["label"] == "upstream" 

337 

338 def test_set_edge_attribute_key_error(self): 

339 a = Graphable("A") 

340 b = Graphable("B") 

341 with raises(KeyError, match="No edge between"): 

342 a.set_edge_attribute(b, "key", "val") 

343 

344 def test_observer_discard_not_present(self): 

345 a = Graphable("A") 

346 mock_observer = MagicMock() 

347 # Should not raise 

348 a._unregister_observer(mock_observer)