Coverage for manyworlds/scenario_forest.py: 99%

120 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-08-06 10:28 -0400

1"""Defines the ScenarioForest Class""" 

2import re 

3from igraph import Graph 

4import pdb 

5 

6from .scenario import Scenario 

7from .step import Step, Prerequisite, Action, Assertion 

8 

9class ScenarioForest: 

10 """A collection of one or more directed trees the vertices of which represent BDD scenarios 

11 

12 :param graph: A graph 

13 :type graph: class:`igraph.Graph` 

14 """ 

15 

16 TAB_SIZE = 4 

17 indentation_pattern = rf'(?P<indentation>( {{{TAB_SIZE}}})*)' 

18 scenario_pattern = r'Scenario: (?P<scenario_name>.*)' 

19 

20 SCENARIO_LINE_PATTERN = re.compile("^{}{}$".format(indentation_pattern, scenario_pattern)) 

21 STEP_LINE_PATTERN = re.compile("^{}{}$".format(indentation_pattern, Step.step_pattern)) 

22 TABLE_LINE_PATTERN = re.compile("^{}{}$".format(indentation_pattern, Step.table_pattern)) 

23 

24 def __init__(self, graph): 

25 """Constructor method 

26 """ 

27 self.graph = graph 

28 

29 @classmethod 

30 def data_table_list_to_dict(cls, data_table): 

31 header_row = data_table[0] 

32 return [dict(zip(header_row, row)) for row in data_table[1:]] 

33 

34 @classmethod 

35 def data_table_dict_to_list(cls, data_table): 

36 return [list(data_table[0].keys())] + [list(row.values()) for row in data_table] 

37 

38 @classmethod 

39 def from_file(cls, file_path): 

40 """Create a scenario tree instance from an indented feature file 

41 

42 Scan the indented file line by line and: 

43 1. Keep track of the last scenario encountered at each indentation level 

44 2. Any scenario encountered is added as a child to the last scenario encounterd 

45 at the parent level 

46 3. Any action or assertion encountered is added to the last scenarion encountered 

47 at that level 

48 

49 :param file_path: Fath to indented feature file 

50 :type file_path: str 

51 :return: A new instance of manyworlds.ScenarioForest 

52 :rtype: class:'manyworlds.ScenarioForest' 

53 """ 

54 graph = Graph(directed=True) 

55 with open(file_path) as indented_file: 

56 raw_lines = [l.rstrip('\n') for l in indented_file.readlines() if not l.strip() == ""] 

57 current_scenarios = {} # used to keep track of last scenario encountered at each level 

58 current_table = None 

59 current_step = None 

60 

61 # Scan the file line by line 

62 for line in raw_lines: 

63 

64 # Determine whether line is scenario, step or table row 

65 scenario_match = cls.SCENARIO_LINE_PATTERN.match(line) 

66 step_match = cls.STEP_LINE_PATTERN.match(line) 

67 table_match = cls.TABLE_LINE_PATTERN.match(line) 

68 if not (scenario_match or step_match or table_match): 

69 raise ValueError('Unable to parse line: ' + line.strip()) 

70 

71 # close and record any open data table 

72 if (scenario_match or step_match) and current_table: 

73 current_step.data = ScenarioForest.data_table_list_to_dict(current_table) 

74 current_table = None 

75 

76 if scenario_match: # Line is scenario 

77 current_level = int(len((scenario_match)['indentation']) / cls.TAB_SIZE) 

78 

79 current_scenario_vertex = graph.add_vertex() 

80 current_scenario = Scenario(scenario_match['scenario_name'], current_scenario_vertex) 

81 current_scenario_vertex['scenario'] = current_scenario 

82 current_scenarios[current_level] = current_scenario 

83 if current_level > 0: 

84 # Connect to parent scenario 

85 current_scenario_parent = current_scenarios[current_level-1] 

86 graph.add_edge(current_scenario_parent.vertex, current_scenario.vertex) 

87 

88 elif step_match: # Line is action or assertion 

89 current_scenario = current_scenarios[current_level] 

90 new_step = Step.parse(step_match[0].strip(), previous_step=current_step) 

91 current_scenario.steps.append(new_step) 

92 current_step = new_step 

93 

94 elif table_match: # Line is table row 

95 if current_table == None: 

96 current_table = [] 

97 row = [s.strip() for s in line.split('|')[1:-1]] 

98 current_table.append(row) 

99 

100 # In case the file ends with a data table: 

101 if current_table: 

102 current_step.data = ScenarioForest.data_table_list_to_dict(current_table) 

103 

104 return ScenarioForest(graph) 

105 

106 @classmethod 

107 def write_scenario_steps(cls, file_handle, steps, comments=False): 

108 """Write formatted scenario steps to file 

109 

110 :param file_handle: The file to which to write the steps 

111 :type file_handle: class:'io.TextIOWrapper' 

112 :param steps: The steps to write 

113 :type steps: list of Step 

114 """ 

115 last_step = None 

116 for step_num, step in enumerate(steps): 

117 first_of_type = (last_step == None or last_step.conjunction != step.conjunction) 

118 file_handle.write(step.format(first_of_type=first_of_type) + "\n") 

119 if comments and step.comment: 

120 file_handle.write("# " + step.comment + "\n") 

121 if step.data: 

122 ScenarioForest.write_data_table(file_handle, step.data) 

123 last_step = step 

124 

125 @classmethod 

126 def write_data_table(cls, file_handle, data_table): 

127 data = ScenarioForest.data_table_dict_to_list(data_table) 

128 col_widths = [max([len(cell) for cell in col]) for col in list(zip(*data))] 

129 for row in data: 

130 padded_row = [row[col_num].ljust(col_width) for col_num, col_width in enumerate(col_widths)] 

131 file_handle.write(" | {} |\n".format(" | ".join(padded_row))) 

132 

133 def flatten(self, file, mode='strict', comments=False): 

134 """Write a flat (no indentation) feature file representing the scenario forest 

135 

136 :param file: Path to flat feature file to be written 

137 :type file: str 

138 :param mode: Flattening mode. Either 'strict' or 'relaxed' 

139 :type mode: str 

140 """ 

141 if mode == 'strict': 

142 self.flatten_strict(file, comments=comments) 

143 elif mode == 'relaxed': 

144 self.flatten_relaxed(file, comments=comments) 

145 

146 def flatten_strict(self, file_path, comments=False): 

147 """Write a flat (no indentation) feature file representing the forest using the 'strict' flattening mode 

148 

149 The 'strict' flattening mode writes one scenario per vertex in the tree, resulting in 

150 a feature file with one set of 'When' steps followed by one set of 'Then' steps (generally recommended) 

151 

152 :param file_path: Path to flat feature file 

153 :type file_path: str 

154 """ 

155 with open(file_path, 'w') as flat_file: 

156 for scenario in [sc for sc in self.scenarios() if not sc.is_breadcrumb()]: 

157 flat_file.write(scenario.format() + "\n") 

158 

159 ancestor_scenarios = scenario.ancestors() 

160 steps=[] 

161 # collect prerequisites from all scenarios along the path 

162 steps += [st 

163 for sc in ancestor_scenarios 

164 for st in sc.prerequisites()] 

165 # collect actions from all scenarios along the path 

166 steps += [st 

167 for sc in ancestor_scenarios 

168 for st in sc.actions()] 

169 # add all steps from the destination scenario only 

170 steps += scenario.steps 

171 ScenarioForest.write_scenario_steps(flat_file, steps, comments=comments) 

172 flat_file.write("\n") 

173 

174 def flatten_relaxed(self, file_path, comments=False): 

175 """Write a flat (no indentation) feature file representing the tree using the 'relaxed' flattening mode 

176 

177 The 'relaxed' flattening mode writes one scenario per leaf vertex in the tree, resulting in 

178 a feature file with multiple alternating sets of "When" and "Then" steps per (generally considered an anti-pattern) 

179 

180 :param file_path: Path to flat feature file 

181 :type file_path: str 

182 """ 

183 with open(file_path, 'w') as flat_file: 

184 tested_scenarios = [] 

185 for scenario in self.leaf_scenarios(): 

186 flat_file.write(scenario.format() + "\n") 

187 

188 path_scenarios = scenario.ancestors() + [scenario] 

189 steps=[] 

190 for path_scenario in path_scenarios: 

191 steps += path_scenario.prerequisites() 

192 steps += path_scenario.actions() 

193 if path_scenario not in tested_scenarios: 

194 steps += path_scenario.assertions() 

195 tested_scenarios.append(path_scenario) 

196 

197 ScenarioForest.write_scenario_steps(flat_file, steps, comments=comments) 

198 flat_file.write("\n") 

199 

200 def find(self, *scenario_names): 

201 

202 scenario = next(sc for sc in self.root_scenarios() if sc.name == scenario_names[0]) 

203 for scenario_name in scenario_names[1:]: 

204 scenario = next(vt['scenario'] for vt in scenario.vertex.successors() if vt['scenario'].name == scenario_name) 

205 

206 return scenario 

207 

208 def scenarios(self): 

209 """Return the scenarios of the scenario forest 

210 

211 :return: A list of manyworlds.Scenario 

212 :rtype: list 

213 """ 

214 return [vx['scenario'] for vx in self.graph.vs] 

215 

216 def root_scenarios(self): 

217 """Return the root scenarios of the scenario forest (the vertices with no incoming edges) 

218 

219 :return: A list of manyworlds.Scenario 

220 :rtype: list 

221 """ 

222 return [vx['scenario'] for vx in self.graph.vs if vx.indegree() == 0] 

223 

224 def leaf_scenarios(self): 

225 """Return the leaf scenarios of the scenario forest (the vertices with no outgoing edges) 

226 

227 :return: A list of manyworlds.Scenario 

228 :rtype: list 

229 """ 

230 return [vx['scenario'] for vx in self.graph.vs if vx.outdegree() == 0]