diff --git a/src/App/ComplexGeoDataPy.xml b/src/App/ComplexGeoDataPy.xml index c60f905826..7b094cb3dd 100644 --- a/src/App/ComplexGeoDataPy.xml +++ b/src/App/ComplexGeoDataPy.xml @@ -64,6 +64,37 @@ Apply a transformation to the underlying geometry + + + + setElementName(element,name=None,postfix=None,overwrite=False,sid=None), Set an element name + + element : the original element name, e.g. Edge1, Vertex2 + name : the new name for the element, None to remove the mapping + postfix : postfix of the name that will not be hashed + overwrite: if true, it will overwrite exiting name + sid : to hash the name any way you want, provide your own string id(s) in this parameter + + An element can have multiple mapped names. However, a name can only be mapped + to one element + + + + + + getElementName(name,direction=0) - Return a mapped element name or reverse + + + + + getElementIndexedName(name) - Return the indexed element name + + + + + getElementMappedName(name) - Return the mapped element name + + Get the bounding box (BoundBox) of the complex geometric data. @@ -88,5 +119,35 @@ + + + Get/Set the string hasher of this object + + + + + + Get the current element map size + + + + + + Get/Set a dict of element mapping + + + + + + Get a dict of element reverse mapping + + + + + + Element map version + + + diff --git a/src/App/ComplexGeoDataPyImp.cpp b/src/App/ComplexGeoDataPyImp.cpp index e6c9fe69a5..9cc26fe0c5 100644 --- a/src/App/ComplexGeoDataPyImp.cpp +++ b/src/App/ComplexGeoDataPyImp.cpp @@ -27,13 +27,17 @@ #endif #include "ComplexGeoData.h" +#include "StringHasher.h" // inclusion of the generated files (generated out of ComplexGeoDataPy.xml) #include #include +#include +#include #include #include #include +#include "Base/PyWrapParseTupleAndKeywords.h" #include #include @@ -292,6 +296,231 @@ PyObject* ComplexGeoDataPy::transformGeometry(PyObject *args) } } +PyObject* ComplexGeoDataPy::getElementName(PyObject* args) +{ + char* input; + int direction = 0; + if (!PyArg_ParseTuple(args, "s|i", &input, &direction)) { + return NULL; + } + + Data::MappedElement res = getComplexGeoDataPtr()->getElementName(input); + std::string s; + if (direction == 1) { + return Py::new_reference_to(Py::String(res.name.appendToBuffer(s))); + } + else if (direction == 0) { + return Py::new_reference_to(Py::String(res.index.appendToStringBuffer(s))); + } + else if (Data::IndexedName(input)) { + return Py::new_reference_to(Py::String(res.name.appendToBuffer(s))); + } + else { + return Py::new_reference_to(Py::String(res.index.appendToStringBuffer(s))); + } +} + +PyObject* ComplexGeoDataPy::getElementIndexedName(PyObject* args) +{ + char* input; + PyObject* returnID = Py_False; + if (!PyArg_ParseTuple(args, "s|O", &input, &returnID)) { + return NULL; + } + + ElementIDRefs ids; + Data::MappedElement res = + getComplexGeoDataPtr()->getElementName(input, PyObject_IsTrue(returnID) ? &ids : nullptr); + std::string s; + Py::String name(res.index.appendToStringBuffer(s)); + if (!PyObject_IsTrue(returnID)) { + return Py::new_reference_to(name); + } + + Py::List list; + for (auto& id : ids) { + list.append(Py::Long(id.value())); + } + return Py::new_reference_to(Py::TupleN(name, list)); +} + +PyObject* ComplexGeoDataPy::getElementMappedName(PyObject* args) +{ + char* input; + PyObject* returnID = Py_False; + if (!PyArg_ParseTuple(args, "s|O", &input, &returnID)) { + return NULL; + } + + ElementIDRefs ids; + Data::MappedElement res = + getComplexGeoDataPtr()->getElementName(input, PyObject_IsTrue(returnID) ? &ids : nullptr); + std::string s; + Py::String name(res.name.appendToBuffer(s)); + if (!PyObject_IsTrue(returnID)) { + return Py::new_reference_to(name); + } + + Py::List list; + for (auto& id : ids) { + list.append(Py::Long(id.value())); + } + return Py::new_reference_to(Py::TupleN(name, list)); +} + +PyObject* ComplexGeoDataPy::setElementName(PyObject* args, PyObject* kwds) +{ + const char* element; + const char* name = 0; + const char* postfix = 0; + int tag = 0; + PyObject* pySid = Py_None; + PyObject* overwrite = Py_False; + + const std::array kwlist = {"element", "name", "postfix", "overwrite", "sid", "tag", nullptr}; + if (!Wrapped_ParseTupleAndKeywords(args, + kwds, + "s|sssOOi", + kwlist, + &element, + &name, + &postfix, + &overwrite, + &pySid, + &tag)) { + return NULL; + } + ElementIDRefs sids; + if (pySid != Py_None) { + if (PyObject_TypeCheck(pySid, &App::StringIDPy::Type)) { + sids.push_back(static_cast(pySid)->getStringIDPtr()); + } + else if (PySequence_Check(pySid)) { + Py::Sequence seq(pySid); + for (auto it = seq.begin(); it != seq.end(); ++it) { + auto ptr = (*it).ptr(); + if (PyObject_TypeCheck(ptr, &App::StringIDPy::Type)) { + sids.push_back(static_cast(ptr)->getStringIDPtr()); + } + else { + throw Py::TypeError("expect StringID in sid sequence"); + } + } + } + else { + throw Py::TypeError("expect sid to contain either StringID or sequence of StringID"); + } + } + PY_TRY + { + Data::IndexedName index(element, getComplexGeoDataPtr()->getElementTypes()); + Data::MappedName mapped = Data::MappedName::fromRawData(name); + std::ostringstream ss; + ElementMapPtr map = getComplexGeoDataPtr()->resetElementMap(); + map->encodeElementName(getComplexGeoDataPtr()->elementType(index), + mapped, + ss, + &sids, + tag, + postfix, + tag); + Data::MappedName res = + map->setElementName(index, mapped, tag, &sids, PyObject_IsTrue(overwrite)); + return Py::new_reference_to(Py::String(res.toString(0))); + } + PY_CATCH +} + +Py::Object ComplexGeoDataPy::getHasher() const +{ + auto self = getComplexGeoDataPtr(); + if (!self->Hasher) { + return Py::None(); + } + return Py::Object(self->Hasher->getPyObject(), true); +} + +Py::Dict ComplexGeoDataPy::getElementMap() const +{ + Py::Dict ret; + std::string s; + for (auto& v : getComplexGeoDataPtr()->getElementMap()) { + s.clear(); + ret.setItem(v.name.toString(0), Py::String(v.index.appendToStringBuffer(s))); + } + return ret; +} + +void ComplexGeoDataPy::setElementMap(Py::Dict dict) +{ + std::vector map; + const auto& types = getComplexGeoDataPtr()->getElementTypes(); + for (auto it = dict.begin(); it != dict.end(); ++it) { + const auto& value = *it; + if (!value.first.isString() || !value.second.isString()) { + throw Py::TypeError("expect only strings in the dict"); + } + map.emplace_back(Data::MappedName(value.first.as_string().c_str()), + Data::IndexedName(Py::Object(value.second).as_string().c_str(), types)); + } + getComplexGeoDataPtr()->setElementMap(map); +} + +Py::Dict ComplexGeoDataPy::getElementReverseMap() const +{ + Py::Dict ret; + std::string s; + for (auto& v : getComplexGeoDataPtr()->getElementMap()) { + s.clear(); + auto value = ret[Py::String(v.index.appendToStringBuffer(s))]; + Py::Object item(value); + if (item.isNone()) { + s.clear(); + value = Py::String(v.name.appendToBuffer(s)); + } + else if (item.isList()) { + Py::List list(item); + s.clear(); + list.append(Py::String(v.name.appendToBuffer(s))); + } + else { + Py::List list; + list.append(item); + s.clear(); + list.append(Py::String(v.name.appendToBuffer(s))); + value = list; + } + } + return ret; +} + +Py::Int ComplexGeoDataPy::getElementMapSize() const +{ + return Py::Int((long)getComplexGeoDataPtr()->getElementMapSize()); +} + +void ComplexGeoDataPy::setHasher(Py::Object obj) +{ + auto self = getComplexGeoDataPtr(); + if (obj.isNone()) { + if (self->Hasher) { + self->Hasher = App::StringHasherRef(); + self->resetElementMap(); + } + } + else if (PyObject_TypeCheck(obj.ptr(), &App::StringHasherPy::Type)) { + App::StringHasherRef ref( + static_cast(obj.ptr())->getStringHasherPtr()); + if (self->Hasher != ref) { + self->Hasher = ref; + self->resetElementMap(); + } + } + else { + throw Py::TypeError("invalid type"); + } +} + Py::Object ComplexGeoDataPy::getBoundBox() const { return Py::BoundingBox(getComplexGeoDataPtr()->getBoundBox()); @@ -324,6 +553,19 @@ void ComplexGeoDataPy::setPlacement(Py::Object arg) } } +Py::String ComplexGeoDataPy::getElementMapVersion() const +{ +#ifdef FC_USE_TNP_FIX + return Py::String(getComplexGeoDataPtr()->getElementMapVersion()); +#else + // This is to allow python level tests visibility into whether element maps are in use, so that + // expectations can be adjusted. Eventually this ifdef and clause should be removed, and at the + // same time all python tests checking for ElementMapVersion != '' should also be removed. + return Py::String(); +#endif +} + + Py::Int ComplexGeoDataPy::getTag() const { return Py::Int(getComplexGeoDataPtr()->Tag); diff --git a/src/Mod/Part/CMakeLists.txt b/src/Mod/Part/CMakeLists.txt index 767b1a43d9..6fa26eda2b 100644 --- a/src/Mod/Part/CMakeLists.txt +++ b/src/Mod/Part/CMakeLists.txt @@ -70,6 +70,7 @@ set(Part_tests parttests/TopoShapeListTest.py parttests/ColorPerFaceTest.py parttests/ColorTransparencyTest.py + parttests/TopoShapeTest.py ) add_custom_target(PartScripts ALL SOURCES diff --git a/src/Mod/Part/TestPartApp.py b/src/Mod/Part/TestPartApp.py index 1031c71b80..b1f4e92fed 100644 --- a/src/Mod/Part/TestPartApp.py +++ b/src/Mod/Part/TestPartApp.py @@ -30,6 +30,7 @@ App = FreeCAD from parttests.Geom2d_tests import Geom2dTests from parttests.regression_tests import RegressionTests from parttests.TopoShapeListTest import TopoShapeListTest +from parttests.TopoShapeTest import TopoShapeTest #--------------------------------------------------------------------------- # define the test cases to test the FreeCAD Part module diff --git a/src/Mod/Part/parttests/TopoShapeTest.py b/src/Mod/Part/parttests/TopoShapeTest.py new file mode 100644 index 0000000000..57861cf132 --- /dev/null +++ b/src/Mod/Part/parttests/TopoShapeTest.py @@ -0,0 +1,402 @@ +import FreeCAD as App +import Part + +import unittest + +class TopoShapeAssertions: + def assertAttrEqual(self, toposhape, attr_value_list, msg=None): + for attr, value in attr_value_list: + result = toposhape.__getattribute__( + attr + ) # Look up each attribute by string name + if result.__str__() != value.__str__(): + if msg == None: + msg = f"TopoShape {attr} is incorrect: {result} should be {value}", + raise AssertionError(msg) + + def assertAttrAlmostEqual(self, toposhape, attr_value_list, places=5, msg=None): + range = 1 / 10 ** places + for attr, value in attr_value_list: + result = toposhape.__getattribute__( + attr + ) # Look up each attribute by string name + if abs(result - value) > range: + if msg == None: + msg = f"TopoShape {attr} is incorrect: {result} should be {value}" + raise AssertionError(msg) + + def assertAttrCount(self, toposhape, attr_value_list, msg=None): + for attr, value in attr_value_list: + result = toposhape.__getattribute__( + attr + ) # Look up each attribute by string name + if len(result) != value: + if msg == None: + msg = f"TopoShape {attr} is incorrect: {result} should have {value} elements" + raise AssertionError(msg) + + def assertKeysInMap(self, map, keys, msg=None): + for key in keys: + if not key in map: + if msg == None: + msg = f"Key {key} not found in map: {map}" + raise AssertionError(msg) +class TopoShapeTest(unittest.TestCase, TopoShapeAssertions): + def setUp(self): + """Create a document and some TopoShapes of various types""" + self.doc = App.newDocument("TopoShape") + self.box = Part.makeBox(1, 2, 2) + Part.show(self.box, "Box1") + self.box2 = Part.makeBox(2, 1, 2) + Part.show(self.box2, "Box2") + + def tearDown(self): + App.closeDocument("TopoShape") + + def testTopoShapeBox(self): + # Arrange our test TopoShape + box2_toposhape = self.doc.Box2.Shape + # Arrange list of attributes and values to string match + attr_value_list = [ + ["BoundBox", App.BoundBox(0, 0, 0, 2, 1, 2)], + ["CenterOfGravity", App.Vector(1, 0.5, 1)], + ["CenterOfMass", App.Vector(1, 0.5, 1)], + ["CompSolids", []], + ["Compounds", []], + ["Content", ""], + ["ElementMap", {}], + ["ElementReverseMap", {}], + # ['Hasher', {}], # Todo: Should this exist? Different implementation? + [ + "MatrixOfInertia", + App.Matrix( + 1.66667, 0, 0, 0, 0, 2.66667, 0, 0, 0, 0, 1.66667, 0, 0, 0, 0, 1 + ), + ], + ["Module", "Part"], + ["Orientation", "Forward"], + # ['OuterShell', {}], # Todo: Could verify that a Shell Object is returned + ["Placement", App.Placement()], + [ + "PrincipalProperties", + { + "SymmetryAxis": True, + "SymmetryPoint": False, + "Moments": ( + 2.666666666666666, + 1.666666666666667, + 1.666666666666667, + ), + "FirstAxisOfInertia": App.Vector(0.0, 1.0, 0.0), + "SecondAxisOfInertia": App.Vector(0.0, 0.0, 1.0), + "ThirdAxisOfInertia": App.Vector(1.0, 0.0, 0.0), + "RadiusOfGyration": ( + 0.816496580927726, + 0.6454972243679029, + 0.6454972243679029, + ), + }, + ], + ["ShapeType", "Solid"], + [ + "StaticMoments", + (3.999999999999999, 1.9999999999999996, 3.999999999999999), + ], + # ['Tag', 0], # Gonna vary, so can't really assert, except maybe != 0? + ["TypeId", "Part::TopoShape"], + ] + # Assert all the expected values match when converted to strings. + self.assertAttrEqual(box2_toposhape, attr_value_list) + + # Arrange list of attributes and values to match within 5 decimal places + attr_value_list = [ + ["Area", 16.0], + ["ElementMapSize", 0], + # ['ElementMapVersion', 4 ], # Todo: Not until TNP on. + ["Length", 40.0], # Sum of all edges of each face, so some redundancy. + ["Mass", 4.0], + # ['MemSize', 13824], # Platform variations in this size. + ["Volume", 4.0], + ] + # Assert all the expected values match + self.assertAttrAlmostEqual(box2_toposhape, attr_value_list, 5) + + # Arrange list of attributes to check list lengths + attr_value_list = [ + ["Edges", 12], + ["Faces", 6], + ["Shells", 1], + ["Solids", 1], + ["SubShapes", 1], + ["Vertexes", 8], + ["Wires", 6], + ] + # Assert all the expected values match + self.assertAttrCount(box2_toposhape, attr_value_list) + + def testTopoShapeElementMap(self): + """Tests TopoShape elementMap""" + # Arrange + # Act No elementMaps exist in base shapes until we perform an operation. + compound1 = Part.Compound( + [self.doc.Objects[-1].Shape, self.doc.Objects[-2].Shape] + ) + self.doc.addObject("Part::Compound", "Compound") + self.doc.Compound.Links = [ + App.activeDocument().Box1, + App.activeDocument().Box2, + ] + self.doc.recompute() + compound2 = self.doc.Compound.Shape + # Assert + # This is a flag value to indicate that ElementMaps are supported under the current C++ build: + if compound1.ElementMapVersion != "": # Should be '4' as of Mar 2023. + # 52 is 2 cubes of 26 each: 6 Faces, 12 Edges, 8 Vertexes + # Todo: This should contain something as soon as the Python interface for Part.Compound TNP exists + # self.assertEqual(len(compound1.ElementMap), 52, "ElementMap is Incorrect: {0}".format(compound1.ElementMap)) + self.assertEqual( + len(compound2.ElementReverseMap), + 52, + "ElementMap is Incorrect: {0}".format(compound2.ElementMap), + ) + + # def testTopoShapeOperations(self): + # compound1 = Part.Compound([self.doc.Box1.Shape, self.doc.Box2.Shape]) + # box1ts = self.doc.Box1.Shape + # box2ts = self.doc.Box2.Shape + # face1 = Part.Face(Part.Wire([Part.makeCircle(10)])) + # cut1 = box1ts.cut(box2ts) + # common1 = box1ts.common(box2ts) + # fuse1 = box1ts.fuse(box2ts) + # fuse2 = box1ts.generalFuse([box2ts]) + # fuse3 = box1ts.multiFuse([box2ts]) + # mirror1 = box1ts.mirror(App.Vector(0, 0, 0), App.Vector(1, 0, 0)) + # clean1 = box1ts.cleaned() + # # complement1 = box1ts.complement() + # # fix1 = box1ts.fix() + # rotate1 = box1ts.rotated(App.Vector(0, 0, 0), App.Vector(1, 0, 0), 45) + # scale1 = box1ts.scaled(2) + # translate1 = box1ts.translated((2, 0, 0)) + # section1 = box1ts.section(face1) + # slice1 = box1ts.slice(App.Vector(1, 0, 0), 2) + # slice2 = box1ts.slices(App.Vector(1, 0, 0), [2, 3]) + # # clean, complement, fix, reverse, scale, + + def testPartCommon(self): + self.doc.addObject("Part::MultiCommon", "Common") + self.doc.Common.Shapes = [self.doc.Box1, self.doc.Box2] + self.doc.recompute() + names = list(self.doc.Common.Shape.ElementReverseMap.keys()) + names.sort() + if self.doc.Common.Shape.ElementMapVersion != "": # Should be '4' as of Mar 2023. + self.assertKeysInMap(self.doc.Common.Shape.ElementReverseMap, + [ + "Edge1", + "Edge2", + "Edge3", + "Edge4", + "Edge5", + "Edge6", + "Edge7", + "Edge8", + "Edge9", + "Edge10", + "Edge11", + "Edge12", + "Face1", + "Face2", + "Face3", + "Face4", + "Face5", + "Face6", + "Vertex1", + "Vertex2", + "Vertex3", + "Vertex4", + "Vertex5", + "Vertex6", + "Vertex7", + "Vertex8", + ], + ) + + def testPartCut(self): + self.doc.addObject("Part::Cut", "Cut") + self.doc.Cut.Base = self.doc.Box1 + self.doc.Cut.Tool = self.doc.Box2 + self.doc.recompute() + if self.doc.Cut.Shape.ElementMapVersion != "": # Should be '4' as of Mar 2023. + self.assertKeysInMap(self.doc.Cut.Shape.ElementReverseMap, + [ + "Edge1", + "Edge2", + "Edge3", + "Edge4", + "Edge5", + "Edge6", + "Edge7", + "Edge8", + "Edge9", + "Edge10", + "Edge11", + "Edge12", + "Face1", + "Face2", + "Face3", + "Face4", + "Face5", + "Face6", + "Vertex1", + "Vertex2", + "Vertex3", + "Vertex4", + "Vertex5", + "Vertex6", + "Vertex7", + "Vertex8", + ], + ) + + def testPartFuse(self): + self.doc.addObject("Part::Fuse", "Fuse") + self.doc.Fuse.Base = self.doc.Box1 + self.doc.Fuse.Tool = self.doc.Box2 + self.doc.recompute() + if self.doc.Fuse.Shape.ElementMapVersion != "": # Should be '4' as of Mar 2023. + self.assertEqual(len(self.doc.Fuse.Shape.ElementReverseMap), 58) + self.doc.Fuse.Refine = True + self.doc.recompute() + self.assertEqual(len(self.doc.Fuse.Shape.ElementReverseMap), 38) + # Shape is an extruded L, with 8 Faces, 12 Vertexes, 18 Edges + + +# TODO: Consider the following possible test objects: +# Part::AttachExtension ::init(); +# Part::AttachExtensionPython ::init(); +# Part::PrismExtension ::init(); +# Part::Feature ::init(); +# Part::FeatureExt ::init(); +# Part::BodyBase ::init(); +# Part::FeaturePython ::init(); +# Part::FeatureGeometrySet ::init(); +# Part::CustomFeature ::init(); +# Part::CustomFeaturePython ::init(); +# Part::Boolean ::init(); +# Part::Common ::init(); +# Part::MultiCommon ::init(); +# Part::Cut ::init(); +# Part::Fuse ::init(); +# Part::MultiFuse ::init(); +# Part::Section ::init(); +# Part::FilletBase ::init(); +# Part::Fillet ::init(); +# Part::Chamfer ::init(); +# Part::Compound ::init(); +# Part::Compound2 ::init(); +# Part::Extrusion ::init(); +# Part::Scale ::init(); +# Part::Revolution ::init(); +# Part::Mirroring ::init(); +# TopoShape calls to be consider testing +# 'add', +# 'ancestorsOfType', +# 'applyRotation', +# 'applyTranslation', +# 'check', +# 'childShapes', +# 'cleaned', +# 'common', +# 'complement', +# 'connectEdgesToWires', +# 'copy', +# 'countElement', +# 'countSubElements', +# 'cut', +# 'defeaturing', +# 'distToShape', +# 'dumpContent', +# 'dumpToString', +# 'dumps', +# 'exportBinary', +# 'exportBrep', +# 'exportBrepToString', +# 'exportIges', +# 'exportStep', +# 'exportStl', +# 'extrude', +# 'findPlane', +# 'fix', +# 'fixTolerance', +# 'fuse', +# 'generalFuse', +# 'getAllDerivedFrom', +# 'getElement', +# 'getElementTypes', +# 'getFaces', +# 'getFacesFromSubElement', +# 'getLines', +# 'getLinesFromSubElement', +# 'getPoints', +# 'getTolerance', +# 'globalTolerance', +# 'hashCode', +# 'importBinary', +# 'importBrep', +# 'importBrepFromString', +# 'inTolerance', +# 'isClosed', +# 'isCoplanar', +# 'isDerivedFrom', +# 'isEqual', +# 'isInfinite', +# 'isInside', +# 'isNull', +# 'isPartner', +# 'isSame', +# 'isValid', +# 'limitTolerance', +# 'loads', +# 'makeChamfer', +# 'makeFillet', +# 'makeOffset2D', +# 'makeOffsetShape', +# 'makeParallelProjection', +# 'makePerspectiveProjection', +# 'makeShapeFromMesh', +# 'makeThickness', +# 'makeWires', +# 'mirror', +# 'multiFuse', +# 'nullify', +# 'oldFuse', +# 'optimalBoundingBox', +# 'overTolerance', +# 'project', +# 'proximity', +# 'read', +# 'reflectLines', +# 'removeInternalWires', +# 'removeShape', +# 'removeSplitter', +# 'replaceShape', +# 'restoreContent', +# 'reverse', +# 'reversed', +# 'revolve', +# 'rotate', +# 'rotated', +# 'scale', +# 'scaled', +# 'section', +# 'setFaces', +# 'sewShape', +# 'slice', +# 'slices', +# 'tessellate', +# 'toNurbs', +# 'transformGeometry', +# 'transformShape', +# 'transformed', +# 'translate', +# 'translated', +# 'writeInventor' diff --git a/tests/src/Mod/Part/App/FeaturePartCut.cpp b/tests/src/Mod/Part/App/FeaturePartCut.cpp index b5148d68ce..1bf2b69b0b 100644 --- a/tests/src/Mod/Part/App/FeaturePartCut.cpp +++ b/tests/src/Mod/Part/App/FeaturePartCut.cpp @@ -173,4 +173,21 @@ TEST_F(FeaturePartCutTest, testGetProviderName) EXPECT_STREQ(name, "PartGui::ViewProviderBoolean"); } +TEST_F(FeaturePartCutTest, testMapping) +{ + + // Arrange + _cut->Base.setValue(_boxes[0]); + _cut->Tool.setValue(_boxes[1]); + // Act + _cut->execute(); + const Part::TopoShape& ts1 = _cut->Shape.getShape(); + // Assert +#ifndef FC_USE_TNP_FIX + EXPECT_EQ(ts1.getElementMap().size(), 0); +#else + EXPECT_EQ(ts1.getElementMap().size(), 26); +#endif +} + // See FeaturePartCommon.cpp for a history test. It would be exactly the same and redundant here.