1 | # -*- coding: utf-8 -*-
|
---|
2 | # $Id: testset.py 98103 2023-01-17 14:15:46Z vboxsync $
|
---|
3 |
|
---|
4 | """
|
---|
5 | Test Manager - TestSet.
|
---|
6 | """
|
---|
7 |
|
---|
8 | __copyright__ = \
|
---|
9 | """
|
---|
10 | Copyright (C) 2012-2023 Oracle and/or its affiliates.
|
---|
11 |
|
---|
12 | This file is part of VirtualBox base platform packages, as
|
---|
13 | available from https://www.virtualbox.org.
|
---|
14 |
|
---|
15 | This program is free software; you can redistribute it and/or
|
---|
16 | modify it under the terms of the GNU General Public License
|
---|
17 | as published by the Free Software Foundation, in version 3 of the
|
---|
18 | License.
|
---|
19 |
|
---|
20 | This program is distributed in the hope that it will be useful, but
|
---|
21 | WITHOUT ANY WARRANTY; without even the implied warranty of
|
---|
22 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
---|
23 | General Public License for more details.
|
---|
24 |
|
---|
25 | You should have received a copy of the GNU General Public License
|
---|
26 | along with this program; if not, see <https://www.gnu.org/licenses>.
|
---|
27 |
|
---|
28 | The contents of this file may alternatively be used under the terms
|
---|
29 | of the Common Development and Distribution License Version 1.0
|
---|
30 | (CDDL), a copy of it is provided in the "COPYING.CDDL" file included
|
---|
31 | in the VirtualBox distribution, in which case the provisions of the
|
---|
32 | CDDL are applicable instead of those of the GPL.
|
---|
33 |
|
---|
34 | You may elect to license modified versions of this file under the
|
---|
35 | terms and conditions of either the GPL or the CDDL or both.
|
---|
36 |
|
---|
37 | SPDX-License-Identifier: GPL-3.0-only OR CDDL-1.0
|
---|
38 | """
|
---|
39 | __version__ = "$Revision: 98103 $"
|
---|
40 |
|
---|
41 |
|
---|
42 | # Standard python imports.
|
---|
43 | import os;
|
---|
44 | import zipfile;
|
---|
45 | import unittest;
|
---|
46 |
|
---|
47 | # Validation Kit imports.
|
---|
48 | from common import utils;
|
---|
49 | from testmanager import config;
|
---|
50 | from testmanager.core import db;
|
---|
51 | from testmanager.core.base import ModelDataBase, ModelDataBaseTestCase, ModelLogicBase, \
|
---|
52 | TMExceptionBase, TMTooManyRows, TMRowNotFound;
|
---|
53 | from testmanager.core.testbox import TestBoxData;
|
---|
54 | from testmanager.core.testresults import TestResultFileDataEx;
|
---|
55 |
|
---|
56 |
|
---|
57 | class TestSetData(ModelDataBase):
|
---|
58 | """
|
---|
59 | TestSet Data.
|
---|
60 | """
|
---|
61 |
|
---|
62 | ## @name TestStatus_T
|
---|
63 | # @{
|
---|
64 | ksTestStatus_Running = 'running';
|
---|
65 | ksTestStatus_Success = 'success';
|
---|
66 | ksTestStatus_Skipped = 'skipped';
|
---|
67 | ksTestStatus_BadTestBox = 'bad-testbox';
|
---|
68 | ksTestStatus_Aborted = 'aborted';
|
---|
69 | ksTestStatus_Failure = 'failure';
|
---|
70 | ksTestStatus_TimedOut = 'timed-out';
|
---|
71 | ksTestStatus_Rebooted = 'rebooted';
|
---|
72 | ## @}
|
---|
73 |
|
---|
74 | ## List of relatively harmless (to testgroup/case) statuses.
|
---|
75 | kasHarmlessTestStatuses = [ ksTestStatus_Skipped, ksTestStatus_BadTestBox, ksTestStatus_Aborted, ];
|
---|
76 | ## List of bad statuses.
|
---|
77 | kasBadTestStatuses = [ ksTestStatus_Failure, ksTestStatus_TimedOut, ksTestStatus_Rebooted, ];
|
---|
78 |
|
---|
79 | ksIdAttr = 'idTestSet';
|
---|
80 |
|
---|
81 | ksParam_idTestSet = 'TestSet_idTestSet';
|
---|
82 | ksParam_tsConfig = 'TestSet_tsConfig';
|
---|
83 | ksParam_tsCreated = 'TestSet_tsCreated';
|
---|
84 | ksParam_tsDone = 'TestSet_tsDone';
|
---|
85 | ksParam_enmStatus = 'TestSet_enmStatus';
|
---|
86 | ksParam_idBuild = 'TestSet_idBuild';
|
---|
87 | ksParam_idBuildCategory = 'TestSet_idBuildCategory';
|
---|
88 | ksParam_idBuildTestSuite = 'TestSet_idBuildTestSuite';
|
---|
89 | ksParam_idGenTestBox = 'TestSet_idGenTestBox';
|
---|
90 | ksParam_idTestBox = 'TestSet_idTestBox';
|
---|
91 | ksParam_idSchedGroup = 'TestSet_idSchedGroup';
|
---|
92 | ksParam_idTestGroup = 'TestSet_idTestGroup';
|
---|
93 | ksParam_idGenTestCase = 'TestSet_idGenTestCase';
|
---|
94 | ksParam_idTestCase = 'TestSet_idTestCase';
|
---|
95 | ksParam_idGenTestCaseArgs = 'TestSet_idGenTestCaseArgs';
|
---|
96 | ksParam_idTestCaseArgs = 'TestSet_idTestCaseArgs';
|
---|
97 | ksParam_idTestResult = 'TestSet_idTestResult';
|
---|
98 | ksParam_sBaseFilename = 'TestSet_sBaseFilename';
|
---|
99 | ksParam_iGangMemberNo = 'TestSet_iGangMemberNo';
|
---|
100 | ksParam_idTestSetGangLeader = 'TestSet_idTestSetGangLeader';
|
---|
101 |
|
---|
102 | kasAllowNullAttributes = [ 'tsDone', 'idBuildTestSuite', 'idTestSetGangLeader' ];
|
---|
103 | kasValidValues_enmStatus = [
|
---|
104 | ksTestStatus_Running,
|
---|
105 | ksTestStatus_Success,
|
---|
106 | ksTestStatus_Skipped,
|
---|
107 | ksTestStatus_BadTestBox,
|
---|
108 | ksTestStatus_Aborted,
|
---|
109 | ksTestStatus_Failure,
|
---|
110 | ksTestStatus_TimedOut,
|
---|
111 | ksTestStatus_Rebooted,
|
---|
112 | ];
|
---|
113 | kiMin_iGangMemberNo = 0;
|
---|
114 | kiMax_iGangMemberNo = 1023;
|
---|
115 |
|
---|
116 |
|
---|
117 | kcDbColumns = 20;
|
---|
118 |
|
---|
119 | def __init__(self):
|
---|
120 | ModelDataBase.__init__(self);
|
---|
121 |
|
---|
122 | #
|
---|
123 | # Initialize with defaults.
|
---|
124 | # See the database for explanations of each of these fields.
|
---|
125 | #
|
---|
126 | self.idTestSet = None;
|
---|
127 | self.tsConfig = None;
|
---|
128 | self.tsCreated = None;
|
---|
129 | self.tsDone = None;
|
---|
130 | self.enmStatus = 'running';
|
---|
131 | self.idBuild = None;
|
---|
132 | self.idBuildCategory = None;
|
---|
133 | self.idBuildTestSuite = None;
|
---|
134 | self.idGenTestBox = None;
|
---|
135 | self.idTestBox = None;
|
---|
136 | self.idSchedGroup = None;
|
---|
137 | self.idTestGroup = None;
|
---|
138 | self.idGenTestCase = None;
|
---|
139 | self.idTestCase = None;
|
---|
140 | self.idGenTestCaseArgs = None;
|
---|
141 | self.idTestCaseArgs = None;
|
---|
142 | self.idTestResult = None;
|
---|
143 | self.sBaseFilename = None;
|
---|
144 | self.iGangMemberNo = 0;
|
---|
145 | self.idTestSetGangLeader = None;
|
---|
146 |
|
---|
147 | def initFromDbRow(self, aoRow):
|
---|
148 | """
|
---|
149 | Internal worker for initFromDbWithId and initFromDbWithGenId as well as
|
---|
150 | TestBoxSetLogic.
|
---|
151 | """
|
---|
152 |
|
---|
153 | if aoRow is None:
|
---|
154 | raise TMRowNotFound('TestSet not found.');
|
---|
155 |
|
---|
156 | self.idTestSet = aoRow[0];
|
---|
157 | self.tsConfig = aoRow[1];
|
---|
158 | self.tsCreated = aoRow[2];
|
---|
159 | self.tsDone = aoRow[3];
|
---|
160 | self.enmStatus = aoRow[4];
|
---|
161 | self.idBuild = aoRow[5];
|
---|
162 | self.idBuildCategory = aoRow[6];
|
---|
163 | self.idBuildTestSuite = aoRow[7];
|
---|
164 | self.idGenTestBox = aoRow[8];
|
---|
165 | self.idTestBox = aoRow[9];
|
---|
166 | self.idSchedGroup = aoRow[10];
|
---|
167 | self.idTestGroup = aoRow[11];
|
---|
168 | self.idGenTestCase = aoRow[12];
|
---|
169 | self.idTestCase = aoRow[13];
|
---|
170 | self.idGenTestCaseArgs = aoRow[14];
|
---|
171 | self.idTestCaseArgs = aoRow[15];
|
---|
172 | self.idTestResult = aoRow[16];
|
---|
173 | self.sBaseFilename = aoRow[17];
|
---|
174 | self.iGangMemberNo = aoRow[18];
|
---|
175 | self.idTestSetGangLeader = aoRow[19];
|
---|
176 | return self;
|
---|
177 |
|
---|
178 |
|
---|
179 | def initFromDbWithId(self, oDb, idTestSet):
|
---|
180 | """
|
---|
181 | Initialize the object from the database.
|
---|
182 | """
|
---|
183 | oDb.execute('SELECT *\n'
|
---|
184 | 'FROM TestSets\n'
|
---|
185 | 'WHERE idTestSet = %s\n'
|
---|
186 | , (idTestSet, ) );
|
---|
187 | aoRow = oDb.fetchOne()
|
---|
188 | if aoRow is None:
|
---|
189 | raise TMRowNotFound('idTestSet=%s not found' % (idTestSet,));
|
---|
190 | return self.initFromDbRow(aoRow);
|
---|
191 |
|
---|
192 |
|
---|
193 | def openFile(self, sFilename, sMode = 'rb'):
|
---|
194 | """
|
---|
195 | Opens a file.
|
---|
196 |
|
---|
197 | Returns (oFile, cbFile, fIsStream) on success.
|
---|
198 | Returns (None, sErrorMsg, None) on failure.
|
---|
199 | Will not raise exceptions, unless the class instance is invalid.
|
---|
200 | """
|
---|
201 | assert sMode in [ 'rb', 'r', 'rU' ];
|
---|
202 |
|
---|
203 | # Try raw file first.
|
---|
204 | sFile1 = os.path.join(config.g_ksFileAreaRootDir, self.sBaseFilename + '-' + sFilename);
|
---|
205 | try:
|
---|
206 | oFile = open(sFile1, sMode); # pylint: disable=consider-using-with,unspecified-encoding
|
---|
207 | return (oFile, os.fstat(oFile.fileno()).st_size, False);
|
---|
208 | except Exception as oXcpt1:
|
---|
209 | # Try the zip archive next.
|
---|
210 | sFile2 = os.path.join(config.g_ksZipFileAreaRootDir, self.sBaseFilename + '.zip');
|
---|
211 | try:
|
---|
212 | oZipFile = zipfile.ZipFile(sFile2, 'r'); # pylint: disable=consider-using-with
|
---|
213 | oFile = oZipFile.open(sFilename, sMode if sMode != 'rb' else 'r'); # pylint: disable=consider-using-with
|
---|
214 | cbFile = oZipFile.getinfo(sFilename).file_size;
|
---|
215 | return (oFile, cbFile, True);
|
---|
216 | except Exception as oXcpt2:
|
---|
217 | # Construct a meaningful error message.
|
---|
218 | try:
|
---|
219 | if os.path.exists(sFile1):
|
---|
220 | return (None, 'Error opening "%s": %s' % (sFile1, oXcpt1), None);
|
---|
221 | if not os.path.exists(sFile2):
|
---|
222 | return (None, 'File "%s" not found. [%s, %s]' % (sFilename, sFile1, sFile2,), None);
|
---|
223 | return (None, 'Error opening "%s" inside "%s": %s' % (sFilename, sFile2, oXcpt2), None);
|
---|
224 | except Exception as oXcpt3:
|
---|
225 | return (None, 'OMG! %s; %s; %s' % (oXcpt1, oXcpt2, oXcpt3,), None);
|
---|
226 | return (None, 'Code not reachable!', None);
|
---|
227 |
|
---|
228 | def createFile(self, sFilename, sMode = 'wb'):
|
---|
229 | """
|
---|
230 | Creates a new file.
|
---|
231 |
|
---|
232 | Returns oFile on success.
|
---|
233 | Returns sErrorMsg on failure.
|
---|
234 | """
|
---|
235 | assert sMode in [ 'wb', 'w', 'wU' ];
|
---|
236 |
|
---|
237 | # Try raw file first.
|
---|
238 | sFile1 = os.path.join(config.g_ksFileAreaRootDir, self.sBaseFilename + '-' + sFilename);
|
---|
239 | try:
|
---|
240 | if not os.path.exists(os.path.dirname(sFile1)):
|
---|
241 | os.makedirs(os.path.dirname(sFile1), 0o755);
|
---|
242 | oFile = open(sFile1, sMode); # pylint: disable=consider-using-with,unspecified-encoding
|
---|
243 | except Exception as oXcpt1:
|
---|
244 | return str(oXcpt1);
|
---|
245 | return oFile;
|
---|
246 |
|
---|
247 | @staticmethod
|
---|
248 | def findLogOffsetForTimestamp(sLogContent, tsTimestamp, offStart = 0, fAfter = False):
|
---|
249 | """
|
---|
250 | Log parsing utility function for finding the offset for the given timestamp.
|
---|
251 |
|
---|
252 | We ASSUME the log lines are prefixed with UTC timestamps on the format
|
---|
253 | '09:43:55.789353'.
|
---|
254 |
|
---|
255 | Return index into the sLogContent string, 0 if not found.
|
---|
256 | """
|
---|
257 | # Turn tsTimestamp into a string compatible with what we expect to find in the log.
|
---|
258 | oTsZulu = db.dbTimestampToZuluDatetime(tsTimestamp);
|
---|
259 | sWantedTs = oTsZulu.strftime('%H:%M:%S.%f');
|
---|
260 | assert len(sWantedTs) == 15;
|
---|
261 |
|
---|
262 | # Now loop thru the string, line by line.
|
---|
263 | offRet = offStart;
|
---|
264 | off = offStart;
|
---|
265 | while True:
|
---|
266 | sThisTs = sLogContent[off : off + 15];
|
---|
267 | if len(sThisTs) >= 15 \
|
---|
268 | and sThisTs[2] == ':' \
|
---|
269 | and sThisTs[5] == ':' \
|
---|
270 | and sThisTs[8] == '.' \
|
---|
271 | and sThisTs[14] in '0123456789':
|
---|
272 | if sThisTs < sWantedTs:
|
---|
273 | offRet = off;
|
---|
274 | elif sThisTs == sWantedTs:
|
---|
275 | if not fAfter:
|
---|
276 | return off;
|
---|
277 | offRet = off;
|
---|
278 | else:
|
---|
279 | if fAfter:
|
---|
280 | offRet = off;
|
---|
281 | break;
|
---|
282 |
|
---|
283 | # next line.
|
---|
284 | off = sLogContent.find('\n', off);
|
---|
285 | if off < 0:
|
---|
286 | if fAfter:
|
---|
287 | offRet = len(sLogContent);
|
---|
288 | break;
|
---|
289 | off += 1;
|
---|
290 |
|
---|
291 | return offRet;
|
---|
292 |
|
---|
293 | @staticmethod
|
---|
294 | def extractLogSection(sLogContent, tsStart, tsLast):
|
---|
295 | """
|
---|
296 | Returns log section from tsStart to tsLast (or all if we cannot make sense of it).
|
---|
297 | """
|
---|
298 | offStart = TestSetData.findLogOffsetForTimestamp(sLogContent, tsStart);
|
---|
299 | offEnd = TestSetData.findLogOffsetForTimestamp(sLogContent, tsLast, offStart, fAfter = True);
|
---|
300 | return sLogContent[offStart : offEnd];
|
---|
301 |
|
---|
302 | @staticmethod
|
---|
303 | def extractLogSectionElapsed(sLogContent, tsStart, tsElapsed):
|
---|
304 | """
|
---|
305 | Returns log section from tsStart and tsElapsed forward (or all if we cannot make sense of it).
|
---|
306 | """
|
---|
307 | tsStart = db.dbTimestampToZuluDatetime(tsStart);
|
---|
308 | tsLast = tsStart + tsElapsed;
|
---|
309 | return TestSetData.extractLogSection(sLogContent, tsStart, tsLast);
|
---|
310 |
|
---|
311 |
|
---|
312 |
|
---|
313 | class TestSetLogic(ModelLogicBase):
|
---|
314 | """
|
---|
315 | TestSet logic.
|
---|
316 | """
|
---|
317 |
|
---|
318 |
|
---|
319 | def __init__(self, oDb):
|
---|
320 | ModelLogicBase.__init__(self, oDb);
|
---|
321 |
|
---|
322 |
|
---|
323 | def tryFetch(self, idTestSet):
|
---|
324 | """
|
---|
325 | Attempts to fetch a test set.
|
---|
326 |
|
---|
327 | Returns a TestSetData object on success.
|
---|
328 | Returns None if no status was found.
|
---|
329 | Raises exception on other errors.
|
---|
330 | """
|
---|
331 | self._oDb.execute('SELECT *\n'
|
---|
332 | 'FROM TestSets\n'
|
---|
333 | 'WHERE idTestSet = %s\n',
|
---|
334 | (idTestSet,));
|
---|
335 | if self._oDb.getRowCount() == 0:
|
---|
336 | return None;
|
---|
337 | oData = TestSetData();
|
---|
338 | return oData.initFromDbRow(self._oDb.fetchOne());
|
---|
339 |
|
---|
340 | def strTabString(self, sString, fCommit = False):
|
---|
341 | """
|
---|
342 | Gets the string table id for the given string, adding it if new.
|
---|
343 | """
|
---|
344 | ## @todo move this and make a stored procedure for it.
|
---|
345 | self._oDb.execute('SELECT idStr\n'
|
---|
346 | 'FROM TestResultStrTab\n'
|
---|
347 | 'WHERE sValue = %s'
|
---|
348 | , (sString,));
|
---|
349 | if self._oDb.getRowCount() == 0:
|
---|
350 | self._oDb.execute('INSERT INTO TestResultStrTab (sValue)\n'
|
---|
351 | 'VALUES (%s)\n'
|
---|
352 | 'RETURNING idStr\n'
|
---|
353 | , (sString,));
|
---|
354 | if fCommit:
|
---|
355 | self._oDb.commit();
|
---|
356 | return self._oDb.fetchOne()[0];
|
---|
357 |
|
---|
358 | def complete(self, idTestSet, sStatus, fCommit = False):
|
---|
359 | """
|
---|
360 | Completes the testset.
|
---|
361 | Returns the test set ID of the gang leader, None if no gang involvement.
|
---|
362 | Raises exceptions on database errors and invalid input.
|
---|
363 | """
|
---|
364 |
|
---|
365 | assert sStatus != TestSetData.ksTestStatus_Running;
|
---|
366 |
|
---|
367 | #
|
---|
368 | # Get the basic test set data and check if there is anything to do here.
|
---|
369 | #
|
---|
370 | oData = TestSetData().initFromDbWithId(self._oDb, idTestSet);
|
---|
371 | if oData.enmStatus != TestSetData.ksTestStatus_Running:
|
---|
372 | raise TMExceptionBase('TestSet %s is already completed as %s.' % (idTestSet, oData.enmStatus));
|
---|
373 | if oData.idTestResult is None:
|
---|
374 | raise self._oDb.integrityException('idTestResult is NULL for TestSet %u' % (idTestSet,));
|
---|
375 |
|
---|
376 | #
|
---|
377 | # Close open sub test results, count these as errors.
|
---|
378 | # Note! No need to propagate error counts here. Only one tree line will
|
---|
379 | # have open sets, and it will go all the way to the root.
|
---|
380 | #
|
---|
381 | self._oDb.execute('SELECT idTestResult\n'
|
---|
382 | 'FROM TestResults\n'
|
---|
383 | 'WHERE idTestSet = %s\n'
|
---|
384 | ' AND enmStatus = %s\n'
|
---|
385 | ' AND idTestResult <> %s\n'
|
---|
386 | 'ORDER BY idTestResult DESC\n'
|
---|
387 | , (idTestSet, TestSetData.ksTestStatus_Running, oData.idTestResult));
|
---|
388 | aaoRows = self._oDb.fetchAll();
|
---|
389 | if aaoRows:
|
---|
390 | idStr = self.strTabString('Unclosed test result', fCommit = fCommit);
|
---|
391 | for aoRow in aaoRows:
|
---|
392 | self._oDb.execute('UPDATE TestResults\n'
|
---|
393 | 'SET enmStatus = \'failure\',\n'
|
---|
394 | ' tsElapsed = CURRENT_TIMESTAMP - tsCreated,\n'
|
---|
395 | ' cErrors = cErrors + 1\n'
|
---|
396 | 'WHERE idTestResult = %s\n'
|
---|
397 | , (aoRow[0],));
|
---|
398 | self._oDb.execute('INSERT INTO TestResultMsgs (idTestResult, idTestSet, idStrMsg, enmLevel)\n'
|
---|
399 | 'VALUES ( %s, %s, %s, \'failure\'::TestResultMsgLevel_T)\n'
|
---|
400 | , (aoRow[0], idTestSet, idStr,));
|
---|
401 |
|
---|
402 | #
|
---|
403 | # If it's a success result, check it against error counters.
|
---|
404 | #
|
---|
405 | if sStatus not in TestSetData.kasBadTestStatuses:
|
---|
406 | self._oDb.execute('SELECT COUNT(*)\n'
|
---|
407 | 'FROM TestResults\n'
|
---|
408 | 'WHERE idTestSet = %s\n'
|
---|
409 | ' AND cErrors > 0\n'
|
---|
410 | , (idTestSet,));
|
---|
411 | cErrors = self._oDb.fetchOne()[0];
|
---|
412 | if cErrors > 0:
|
---|
413 | sStatus = TestSetData.ksTestStatus_Failure;
|
---|
414 |
|
---|
415 | #
|
---|
416 | # If it's an pure 'failure', check for timeouts and propagate it.
|
---|
417 | #
|
---|
418 | if sStatus == TestSetData.ksTestStatus_Failure:
|
---|
419 | self._oDb.execute('SELECT COUNT(*)\n'
|
---|
420 | 'FROM TestResults\n'
|
---|
421 | 'WHERE idTestSet = %s\n'
|
---|
422 | ' AND enmStatus = %s\n'
|
---|
423 | , ( idTestSet, TestSetData.ksTestStatus_TimedOut, ));
|
---|
424 | if self._oDb.fetchOne()[0] > 0:
|
---|
425 | sStatus = TestSetData.ksTestStatus_TimedOut;
|
---|
426 |
|
---|
427 | #
|
---|
428 | # Complete the top level test result and then the test set.
|
---|
429 | #
|
---|
430 | self._oDb.execute('UPDATE TestResults\n'
|
---|
431 | 'SET cErrors = (SELECT COALESCE(SUM(cErrors), 0)\n'
|
---|
432 | ' FROM TestResults\n'
|
---|
433 | ' WHERE idTestResultParent = %s)\n'
|
---|
434 | 'WHERE idTestResult = %s\n'
|
---|
435 | 'RETURNING cErrors\n'
|
---|
436 | , (oData.idTestResult, oData.idTestResult));
|
---|
437 | cErrors = self._oDb.fetchOne()[0];
|
---|
438 | if cErrors == 0 and sStatus in TestSetData.kasBadTestStatuses:
|
---|
439 | self._oDb.execute('UPDATE TestResults\n'
|
---|
440 | 'SET cErrors = 1\n'
|
---|
441 | 'WHERE idTestResult = %s\n'
|
---|
442 | , (oData.idTestResult,));
|
---|
443 | elif cErrors > 0 and sStatus not in TestSetData.kasBadTestStatuses:
|
---|
444 | sStatus = TestSetData.ksTestStatus_Failure; # Impossible.
|
---|
445 | self._oDb.execute('UPDATE TestResults\n'
|
---|
446 | 'SET enmStatus = %s,\n'
|
---|
447 | ' tsElapsed = CURRENT_TIMESTAMP - tsCreated\n'
|
---|
448 | 'WHERE idTestResult = %s\n'
|
---|
449 | , (sStatus, oData.idTestResult,));
|
---|
450 |
|
---|
451 | self._oDb.execute('UPDATE TestSets\n'
|
---|
452 | 'SET enmStatus = %s,\n'
|
---|
453 | ' tsDone = CURRENT_TIMESTAMP\n'
|
---|
454 | 'WHERE idTestSet = %s\n'
|
---|
455 | , (sStatus, idTestSet,));
|
---|
456 |
|
---|
457 | self._oDb.maybeCommit(fCommit);
|
---|
458 | return oData.idTestSetGangLeader;
|
---|
459 |
|
---|
460 | def completeAsAbandoned(self, idTestSet, fCommit = False):
|
---|
461 | """
|
---|
462 | Completes the testset as abandoned if necessary.
|
---|
463 |
|
---|
464 | See scenario #9:
|
---|
465 | file://../../docs/AutomaticTestingRevamp.html#cleaning-up-abandond-testcase
|
---|
466 |
|
---|
467 | Returns True if successfully completed as abandond, False if it's already
|
---|
468 | completed, and raises exceptions under exceptional circumstances.
|
---|
469 | """
|
---|
470 |
|
---|
471 | #
|
---|
472 | # Get the basic test set data and check if there is anything to do here.
|
---|
473 | #
|
---|
474 | oData = self.tryFetch(idTestSet);
|
---|
475 | if oData is None:
|
---|
476 | return False;
|
---|
477 | if oData.enmStatus != TestSetData.ksTestStatus_Running:
|
---|
478 | return False;
|
---|
479 |
|
---|
480 | if oData.idTestResult is not None:
|
---|
481 | #
|
---|
482 | # Clean up test results, adding a message why they failed.
|
---|
483 | #
|
---|
484 | self._oDb.execute('UPDATE TestResults\n'
|
---|
485 | 'SET enmStatus = \'failure\',\n'
|
---|
486 | ' tsElapsed = CURRENT_TIMESTAMP - tsCreated,\n'
|
---|
487 | ' cErrors = cErrors + 1\n'
|
---|
488 | 'WHERE idTestSet = %s\n'
|
---|
489 | ' AND enmStatus = \'running\'::TestStatus_T\n'
|
---|
490 | , (idTestSet,));
|
---|
491 |
|
---|
492 | idStr = self.strTabString('The test was abandond by the testbox', fCommit = fCommit);
|
---|
493 | self._oDb.execute('INSERT INTO TestResultMsgs (idTestResult, idTestSet, idStrMsg, enmLevel)\n'
|
---|
494 | 'VALUES ( %s, %s, %s, \'failure\'::TestResultMsgLevel_T)\n'
|
---|
495 | , (oData.idTestResult, idTestSet, idStr,));
|
---|
496 |
|
---|
497 | #
|
---|
498 | # Complete the testset.
|
---|
499 | #
|
---|
500 | self._oDb.execute('UPDATE TestSets\n'
|
---|
501 | 'SET enmStatus = \'failure\',\n'
|
---|
502 | ' tsDone = CURRENT_TIMESTAMP\n'
|
---|
503 | 'WHERE idTestSet = %s\n'
|
---|
504 | ' AND enmStatus = \'running\'::TestStatus_T\n'
|
---|
505 | , (idTestSet,));
|
---|
506 |
|
---|
507 | self._oDb.maybeCommit(fCommit);
|
---|
508 | return True;
|
---|
509 |
|
---|
510 | def completeAsGangGatheringTimeout(self, idTestSet, fCommit = False):
|
---|
511 | """
|
---|
512 | Completes the testset with a gang-gathering timeout.
|
---|
513 | Raises exceptions on database errors and invalid input.
|
---|
514 | """
|
---|
515 | #
|
---|
516 | # Get the basic test set data and check if there is anything to do here.
|
---|
517 | #
|
---|
518 | oData = TestSetData().initFromDbWithId(self._oDb, idTestSet);
|
---|
519 | if oData.enmStatus != TestSetData.ksTestStatus_Running:
|
---|
520 | raise TMExceptionBase('TestSet %s is already completed as %s.' % (idTestSet, oData.enmStatus));
|
---|
521 | if oData.idTestResult is None:
|
---|
522 | raise self._oDb.integrityException('idTestResult is NULL for TestSet %u' % (idTestSet,));
|
---|
523 |
|
---|
524 | #
|
---|
525 | # Complete the top level test result and then the test set.
|
---|
526 | #
|
---|
527 | self._oDb.execute('UPDATE TestResults\n'
|
---|
528 | 'SET enmStatus = \'failure\',\n'
|
---|
529 | ' tsElapsed = CURRENT_TIMESTAMP - tsCreated,\n'
|
---|
530 | ' cErrors = cErrors + 1\n'
|
---|
531 | 'WHERE idTestSet = %s\n'
|
---|
532 | ' AND enmStatus = \'running\'::TestStatus_T\n'
|
---|
533 | , (idTestSet,));
|
---|
534 |
|
---|
535 | idStr = self.strTabString('Gang gathering timed out', fCommit = fCommit);
|
---|
536 | self._oDb.execute('INSERT INTO TestResultMsgs (idTestResult, idTestSet, idStrMsg, enmLevel)\n'
|
---|
537 | 'VALUES ( %s, %s, %s, \'failure\'::TestResultMsgLevel_T)\n'
|
---|
538 | , (oData.idTestResult, idTestSet, idStr,));
|
---|
539 |
|
---|
540 | self._oDb.execute('UPDATE TestSets\n'
|
---|
541 | 'SET enmStatus = \'failure\',\n'
|
---|
542 | ' tsDone = CURRENT_TIMESTAMP\n'
|
---|
543 | 'WHERE idTestSet = %s\n'
|
---|
544 | , (idTestSet,));
|
---|
545 |
|
---|
546 | self._oDb.maybeCommit(fCommit);
|
---|
547 | return True;
|
---|
548 |
|
---|
549 | def createFile(self, oTestSet, sName, sMime, sKind, sDesc, cbFile, fCommit = False): # pylint: disable=too-many-locals
|
---|
550 | """
|
---|
551 | Creates a file and associating with the current test result record in
|
---|
552 | the test set.
|
---|
553 |
|
---|
554 | Returns file object that the file content can be written to.
|
---|
555 | Raises exception on database error, I/O errors, if there are too many
|
---|
556 | files in the test set or if they take up too much disk space.
|
---|
557 |
|
---|
558 | The caller (testboxdisp.py) is expected to do basic input validation,
|
---|
559 | so we skip that and get on with the bits only we can do.
|
---|
560 | """
|
---|
561 |
|
---|
562 | #
|
---|
563 | # Furhter input and limit checks.
|
---|
564 | #
|
---|
565 | if oTestSet.enmStatus != TestSetData.ksTestStatus_Running:
|
---|
566 | raise TMExceptionBase('Cannot create files on a test set with status "%s".' % (oTestSet.enmStatus,));
|
---|
567 |
|
---|
568 | self._oDb.execute('SELECT TestResultStrTab.sValue\n'
|
---|
569 | 'FROM TestResultFiles,\n'
|
---|
570 | ' TestResults,\n'
|
---|
571 | ' TestResultStrTab\n'
|
---|
572 | 'WHERE TestResults.idTestSet = %s\n'
|
---|
573 | ' AND TestResultFiles.idTestResult = TestResults.idTestResult\n'
|
---|
574 | ' AND TestResultStrTab.idStr = TestResultFiles.idStrFile\n'
|
---|
575 | , ( oTestSet.idTestSet,));
|
---|
576 | if self._oDb.getRowCount() + 1 > config.g_kcMaxUploads:
|
---|
577 | raise TMExceptionBase('Uploaded too many files already (%d).' % (self._oDb.getRowCount(),));
|
---|
578 |
|
---|
579 | dFiles = {}
|
---|
580 | cbTotalFiles = 0;
|
---|
581 | for aoRow in self._oDb.fetchAll():
|
---|
582 | dFiles[aoRow[0].lower()] = 1; # For determining a unique filename further down.
|
---|
583 | sFile = os.path.join(config.g_ksFileAreaRootDir, oTestSet.sBaseFilename + '-' + aoRow[0]);
|
---|
584 | try:
|
---|
585 | cbTotalFiles += os.path.getsize(sFile);
|
---|
586 | except:
|
---|
587 | cbTotalFiles += config.g_kcMbMaxUploadSingle * 1048576;
|
---|
588 | if (cbTotalFiles + cbFile + 1048575) / 1048576 > config.g_kcMbMaxUploadTotal:
|
---|
589 | raise TMExceptionBase('Will exceed total upload limit: %u bytes + %u bytes > %s MiB.' \
|
---|
590 | % (cbTotalFiles, cbFile, config.g_kcMbMaxUploadTotal));
|
---|
591 |
|
---|
592 | #
|
---|
593 | # Create a new file.
|
---|
594 | #
|
---|
595 | self._oDb.execute('SELECT idTestResult\n'
|
---|
596 | 'FROM TestResults\n'
|
---|
597 | 'WHERE idTestSet = %s\n'
|
---|
598 | ' AND enmStatus = \'running\'::TestStatus_T\n'
|
---|
599 | 'ORDER BY idTestResult DESC\n'
|
---|
600 | 'LIMIT 1\n'
|
---|
601 | % ( oTestSet.idTestSet, ));
|
---|
602 | if self._oDb.getRowCount() < 1:
|
---|
603 | raise TMExceptionBase('No open test results - someone committed a capital offence or we ran into a race.');
|
---|
604 | idTestResult = self._oDb.fetchOne()[0];
|
---|
605 |
|
---|
606 | if sName.lower() in dFiles:
|
---|
607 | # Note! There is in theory a race here, but that's something the
|
---|
608 | # test driver doing parallel upload with non-unique names
|
---|
609 | # should worry about. The TD should always avoid this path.
|
---|
610 | sOrgName = sName;
|
---|
611 | for i in range(2, config.g_kcMaxUploads + 6):
|
---|
612 | sName = '%s-%s' % (i, sName,);
|
---|
613 | if sName not in dFiles:
|
---|
614 | break;
|
---|
615 | sName = None;
|
---|
616 | if sName is None:
|
---|
617 | raise TMExceptionBase('Failed to find unique name for %s.' % (sOrgName,));
|
---|
618 |
|
---|
619 | self._oDb.execute('INSERT INTO TestResultFiles(idTestResult, idTestSet, idStrFile, idStrDescription,\n'
|
---|
620 | ' idStrKind, idStrMime)\n'
|
---|
621 | 'VALUES (%s, %s, %s, %s, %s, %s)\n'
|
---|
622 | , ( idTestResult,
|
---|
623 | oTestSet.idTestSet,
|
---|
624 | self.strTabString(sName),
|
---|
625 | self.strTabString(sDesc),
|
---|
626 | self.strTabString(sKind),
|
---|
627 | self.strTabString(sMime),
|
---|
628 | ));
|
---|
629 |
|
---|
630 | oFile = oTestSet.createFile(sName, 'wb');
|
---|
631 | if utils.isString(oFile):
|
---|
632 | raise TMExceptionBase('Error creating "%s": %s' % (sName, oFile));
|
---|
633 | self._oDb.maybeCommit(fCommit);
|
---|
634 | return oFile;
|
---|
635 |
|
---|
636 | def getGang(self, idTestSetGangLeader):
|
---|
637 | """
|
---|
638 | Returns an array of TestBoxData object representing the gang for the given testset.
|
---|
639 | """
|
---|
640 | self._oDb.execute('SELECT TestBoxesWithStrings.*\n'
|
---|
641 | 'FROM TestBoxesWithStrings,\n'
|
---|
642 | ' TestSets'
|
---|
643 | 'WHERE TestSets.idTestSetGangLeader = %s\n'
|
---|
644 | ' AND TestSets.idGenTestBox = TestBoxesWithStrings.idGenTestBox\n'
|
---|
645 | 'ORDER BY iGangMemberNo ASC\n'
|
---|
646 | , ( idTestSetGangLeader,));
|
---|
647 | aaoRows = self._oDb.fetchAll();
|
---|
648 | aoTestBoxes = [];
|
---|
649 | for aoRow in aaoRows:
|
---|
650 | aoTestBoxes.append(TestBoxData().initFromDbRow(aoRow));
|
---|
651 | return aoTestBoxes;
|
---|
652 |
|
---|
653 | def getFile(self, idTestSet, idTestResultFile):
|
---|
654 | """
|
---|
655 | Gets the TestResultFileEx corresponding to idTestResultFile.
|
---|
656 |
|
---|
657 | Raises an exception if the file wasn't found, doesn't belong to
|
---|
658 | idTestSet, and on DB error.
|
---|
659 | """
|
---|
660 | self._oDb.execute('SELECT TestResultFiles.*,\n'
|
---|
661 | ' StrTabFile.sValue AS sFile,\n'
|
---|
662 | ' StrTabDesc.sValue AS sDescription,\n'
|
---|
663 | ' StrTabKind.sValue AS sKind,\n'
|
---|
664 | ' StrTabMime.sValue AS sMime\n'
|
---|
665 | 'FROM TestResultFiles,\n'
|
---|
666 | ' TestResultStrTab AS StrTabFile,\n'
|
---|
667 | ' TestResultStrTab AS StrTabDesc,\n'
|
---|
668 | ' TestResultStrTab AS StrTabKind,\n'
|
---|
669 | ' TestResultStrTab AS StrTabMime,\n'
|
---|
670 | ' TestResults\n'
|
---|
671 | 'WHERE TestResultFiles.idTestResultFile = %s\n'
|
---|
672 | ' AND TestResultFiles.idStrFile = StrTabFile.idStr\n'
|
---|
673 | ' AND TestResultFiles.idStrDescription = StrTabDesc.idStr\n'
|
---|
674 | ' AND TestResultFiles.idStrKind = StrTabKind.idStr\n'
|
---|
675 | ' AND TestResultFiles.idStrMime = StrTabMime.idStr\n'
|
---|
676 | ' AND TestResults.idTestResult = TestResultFiles.idTestResult\n'
|
---|
677 | ' AND TestResults.idTestSet = %s\n'
|
---|
678 | , ( idTestResultFile, idTestSet, ));
|
---|
679 | return TestResultFileDataEx().initFromDbRow(self._oDb.fetchOne());
|
---|
680 |
|
---|
681 |
|
---|
682 | def getById(self, idTestSet):
|
---|
683 | """
|
---|
684 | Get TestSet table record by its id
|
---|
685 | """
|
---|
686 | self._oDb.execute('SELECT *\n'
|
---|
687 | 'FROM TestSets\n'
|
---|
688 | 'WHERE idTestSet=%s\n',
|
---|
689 | (idTestSet,))
|
---|
690 |
|
---|
691 | aRows = self._oDb.fetchAll()
|
---|
692 | if len(aRows) not in (0, 1):
|
---|
693 | raise TMTooManyRows('Found more than one test sets with the same credentials. Database structure is corrupted.')
|
---|
694 | try:
|
---|
695 | return TestSetData().initFromDbRow(aRows[0])
|
---|
696 | except IndexError:
|
---|
697 | return None
|
---|
698 |
|
---|
699 |
|
---|
700 | def fetchOrphaned(self):
|
---|
701 | """
|
---|
702 | Returns a list of TestSetData objects of orphaned test sets.
|
---|
703 |
|
---|
704 | A test set is orphaned if tsDone is NULL and the testbox has created
|
---|
705 | one or more newer testsets.
|
---|
706 | """
|
---|
707 |
|
---|
708 | self._oDb.execute('SELECT TestSets.*\n'
|
---|
709 | 'FROM TestSets,\n'
|
---|
710 | ' (SELECT idTestSet, idTestBox FROM TestSets WHERE tsDone is NULL) AS t\n'
|
---|
711 | 'WHERE TestSets.idTestSet = t.idTestSet\n'
|
---|
712 | ' AND EXISTS(SELECT 1 FROM TestSets st\n'
|
---|
713 | ' WHERE st.idTestBox = t.idTestBox AND st.idTestSet > t.idTestSet)\n'
|
---|
714 | ' AND NOT EXISTS(SELECT 1 FROM TestBoxStatuses tbs\n'
|
---|
715 | ' WHERE tbs.idTestBox = t.idTestBox AND tbs.idTestSet = t.idTestSet)\n'
|
---|
716 | 'ORDER by TestSets.idTestBox, TestSets.idTestSet'
|
---|
717 | );
|
---|
718 | aoRet = [];
|
---|
719 | for aoRow in self._oDb.fetchAll():
|
---|
720 | aoRet.append(TestSetData().initFromDbRow(aoRow));
|
---|
721 | return aoRet;
|
---|
722 |
|
---|
723 | def fetchByAge(self, tsNow = None, cHoursBack = 24):
|
---|
724 | """
|
---|
725 | Returns a list of TestSetData objects of a given time period (default is 24 hours).
|
---|
726 |
|
---|
727 | Returns None if no testsets stored,
|
---|
728 | Returns an empty list if no testsets found with given criteria.
|
---|
729 | """
|
---|
730 | if tsNow is None:
|
---|
731 | tsNow = self._oDb.getCurrentTimestamp();
|
---|
732 |
|
---|
733 | if self._oDb.getRowCount() == 0:
|
---|
734 | return None;
|
---|
735 |
|
---|
736 | self._oDb.execute('(SELECT *\n'
|
---|
737 | ' FROM TestSets\n'
|
---|
738 | ' WHERE tsDone <= %s\n'
|
---|
739 | ' AND tsDone > (%s - interval \'%s hours\')\n'
|
---|
740 | ')\n'
|
---|
741 | , ( tsNow, tsNow, cHoursBack, ));
|
---|
742 |
|
---|
743 | aoRet = [];
|
---|
744 | for aoRow in self._oDb.fetchAll():
|
---|
745 | aoRet.append(TestSetData().initFromDbRow(aoRow));
|
---|
746 | return aoRet;
|
---|
747 |
|
---|
748 | def isTestBoxExecutingTooRapidly(self, idTestBox): ## s/To/Too/
|
---|
749 | """
|
---|
750 | Checks whether the specified test box is executing tests too rapidly.
|
---|
751 |
|
---|
752 | The parameters defining too rapid execution are defined in config.py.
|
---|
753 |
|
---|
754 | Returns True if it does, False if it doesn't.
|
---|
755 | May raise database problems.
|
---|
756 | """
|
---|
757 |
|
---|
758 | self._oDb.execute('(\n'
|
---|
759 | 'SELECT tsCreated\n'
|
---|
760 | 'FROM TestSets\n'
|
---|
761 | 'WHERE idTestBox = %s\n'
|
---|
762 | ' AND tsCreated >= (CURRENT_TIMESTAMP - interval \'%s seconds\')\n'
|
---|
763 | ') UNION (\n'
|
---|
764 | 'SELECT tsCreated\n'
|
---|
765 | 'FROM TestSets\n'
|
---|
766 | 'WHERE idTestBox = %s\n'
|
---|
767 | ' AND tsCreated >= (CURRENT_TIMESTAMP - interval \'%s seconds\')\n'
|
---|
768 | ' AND enmStatus >= \'failure\'\n'
|
---|
769 | ')'
|
---|
770 | , ( idTestBox, config.g_kcSecMinSinceLastTask,
|
---|
771 | idTestBox, config.g_kcSecMinSinceLastFailedTask, ));
|
---|
772 | return self._oDb.getRowCount() > 0;
|
---|
773 |
|
---|
774 |
|
---|
775 | #
|
---|
776 | # The virtual test sheriff interface.
|
---|
777 | #
|
---|
778 |
|
---|
779 | def fetchBadTestBoxIds(self, cHoursBack = 2, tsNow = None, aidFailureReasons = None):
|
---|
780 | """
|
---|
781 | Fetches a list of test box IDs which returned bad-testbox statuses in the
|
---|
782 | given period (tsDone).
|
---|
783 | """
|
---|
784 | if tsNow is None:
|
---|
785 | tsNow = self._oDb.getCurrentTimestamp();
|
---|
786 | if aidFailureReasons is None:
|
---|
787 | aidFailureReasons = [ -1, ];
|
---|
788 | self._oDb.execute('(SELECT idTestBox\n'
|
---|
789 | ' FROM TestSets\n'
|
---|
790 | ' WHERE TestSets.enmStatus = \'bad-testbox\'\n'
|
---|
791 | ' AND tsDone <= %s\n'
|
---|
792 | ' AND tsDone > (%s - interval \'%s hours\')\n'
|
---|
793 | ') UNION (\n'
|
---|
794 | ' SELECT TestSets.idTestBox\n'
|
---|
795 | ' FROM TestSets,\n'
|
---|
796 | ' TestResultFailures\n'
|
---|
797 | ' WHERE TestSets.tsDone <= %s\n'
|
---|
798 | ' AND TestSets.tsDone > (%s - interval \'%s hours\')\n'
|
---|
799 | ' AND TestSets.enmStatus >= \'failure\'::TestStatus_T\n'
|
---|
800 | ' AND TestSets.idTestSet = TestResultFailures.idTestSet\n'
|
---|
801 | ' AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP\n'
|
---|
802 | ' AND TestResultFailures.idFailureReason IN ('
|
---|
803 | + ', '.join([str(i) for i in aidFailureReasons]) + ')\n'
|
---|
804 | ')\n'
|
---|
805 | , ( tsNow, tsNow, cHoursBack,
|
---|
806 | tsNow, tsNow, cHoursBack, ));
|
---|
807 | return [aoRow[0] for aoRow in self._oDb.fetchAll()];
|
---|
808 |
|
---|
809 | def fetchSetsForTestBox(self, idTestBox, cHoursBack = 2, tsNow = None):
|
---|
810 | """
|
---|
811 | Fetches the TestSet rows for idTestBox for the given period (tsDone), w/o running ones.
|
---|
812 |
|
---|
813 | Returns list of TestSetData sorted by tsDone in descending order.
|
---|
814 | """
|
---|
815 | if tsNow is None:
|
---|
816 | tsNow = self._oDb.getCurrentTimestamp();
|
---|
817 | self._oDb.execute('SELECT *\n'
|
---|
818 | 'FROM TestSets\n'
|
---|
819 | 'WHERE TestSets.idTestBox = %s\n'
|
---|
820 | ' AND tsDone IS NOT NULL\n'
|
---|
821 | ' AND tsDone <= %s\n'
|
---|
822 | ' AND tsDone > (%s - interval \'%s hours\')\n'
|
---|
823 | 'ORDER by tsDone DESC\n'
|
---|
824 | , ( idTestBox, tsNow, tsNow, cHoursBack,));
|
---|
825 | return self._dbRowsToModelDataList(TestSetData);
|
---|
826 |
|
---|
827 | def fetchFailedSetsWithoutReason(self, cHoursBack = 2, tsNow = None):
|
---|
828 | """
|
---|
829 | Fetches the TestSet failure rows without any currently (CURRENT_TIMESTAMP
|
---|
830 | not tsNow) assigned failure reason.
|
---|
831 |
|
---|
832 | Returns list of TestSetData sorted by tsDone in descending order.
|
---|
833 |
|
---|
834 | Note! Includes bad-testbox sets too as it can be useful to analyze these
|
---|
835 | too even if we normally count them in the 'skipped' category.
|
---|
836 | """
|
---|
837 | if tsNow is None:
|
---|
838 | tsNow = self._oDb.getCurrentTimestamp();
|
---|
839 | self._oDb.execute('SELECT TestSets.*\n'
|
---|
840 | 'FROM TestSets\n'
|
---|
841 | ' LEFT OUTER JOIN TestResultFailures\n'
|
---|
842 | ' ON TestResultFailures.idTestSet = TestSets.idTestSet\n'
|
---|
843 | ' AND TestResultFailures.tsExpire = \'infinity\'::TIMESTAMP\n'
|
---|
844 | 'WHERE TestSets.tsDone IS NOT NULL\n'
|
---|
845 | ' AND TestSets.enmStatus IN ( %s, %s, %s, %s )\n'
|
---|
846 | ' AND TestSets.tsDone <= %s\n'
|
---|
847 | ' AND TestSets.tsDone > (%s - interval \'%s hours\')\n'
|
---|
848 | ' AND TestResultFailures.idTestSet IS NULL\n'
|
---|
849 | 'ORDER by tsDone DESC\n'
|
---|
850 | , ( TestSetData.ksTestStatus_Failure, TestSetData.ksTestStatus_TimedOut,
|
---|
851 | TestSetData.ksTestStatus_Rebooted, TestSetData.ksTestStatus_BadTestBox,
|
---|
852 | tsNow,
|
---|
853 | tsNow, cHoursBack,));
|
---|
854 | return self._dbRowsToModelDataList(TestSetData);
|
---|
855 |
|
---|
856 |
|
---|
857 |
|
---|
858 | #
|
---|
859 | # Unit testing.
|
---|
860 | #
|
---|
861 |
|
---|
862 | # pylint: disable=missing-docstring
|
---|
863 | class TestSetDataTestCase(ModelDataBaseTestCase):
|
---|
864 | def setUp(self):
|
---|
865 | self.aoSamples = [TestSetData(),];
|
---|
866 |
|
---|
867 | if __name__ == '__main__':
|
---|
868 | unittest.main();
|
---|
869 | # not reached.
|
---|