diff --git a/Makefile b/Makefile index 44fd152ce..71d3a15c6 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,9 @@ clean: cleandocs: $(MAKE) -C docs clean +coverage: + py.test --cov-report term-missing --cov=src --cov=tests -p no:warnings + docs: $(MAKE) -C docs html diff --git a/README.md b/README.md index 4e6f4e78d..77065ca56 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,9 @@ The detailed description can be found ## Changes +#### 1.8.0 +- Major refactor of `Dimension` class + #### 1.7.3 - Implement pruning for index tables diff --git a/setup.cfg b/setup.cfg index f7b7e1bb4..fb8a7b424 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,7 @@ [tool:pytest] +python_classes = Test Describe python_files = test_*.py +python_functions = test_ it_ they_ but_ and_it_ testpaths = tests diff --git a/setup.py b/setup.py index 077a12642..8983dc797 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup, find_packages -version = '1.7.3' +version = '1.8.0' def get_long_desc(): diff --git a/src/cr/cube/crunch_cube.py b/src/cr/cube/crunch_cube.py index c8533cf77..8e2e470e3 100644 --- a/src/cr/cube/crunch_cube.py +++ b/src/cr/cube/crunch_cube.py @@ -73,8 +73,6 @@ def __init__(self, response): 'A `cube` must be JSON or `dict`.' ).format(type(response))) - self.slices = self.get_slices() - def __repr__(self): text = '\n' + str(type(self)) text += '\nName: {}'.format(self.name) @@ -158,7 +156,7 @@ def as_array(self, include_missing=False, weighted=True, adjusted=False, @lazyproperty def ca_dim_ind(self): for (i, dim) in enumerate(self.dimensions): - if dim.type == 'categorical_array': + if dim.dimension_type == 'categorical_array': return i else: return None @@ -201,7 +199,7 @@ def description(self): @lazyproperty def dim_types(self): - return [dim.type for dim in self.dimensions] + return [d.dimension_type for d in self.dimensions] @lazyproperty def dimensions(self): @@ -253,7 +251,7 @@ def get_slices(self, ca_as_0th=False): @lazyproperty def has_means(self): - """Check if cube has means.""" + """True if cube contains means data.""" measures = self._cube.get('result', {}).get('measures') if not measures: return False @@ -309,7 +307,7 @@ def is_double_mr(self): @lazyproperty def is_univariate_ca(self): """Check if cube is a just the CA ("ca x cat" or "cat x ca" dims)""" - types = {d.type for d in self.dimensions} + types = {d.dimension_type for d in self.dimensions} ca_types = {'categorical_array', 'categorical'} return self.ndim == 2 and types == ca_types @@ -429,8 +427,8 @@ def hs_dims_for_den(hs_dims, axis): include_missing=include_missing, ) arr = self._fix_shape(arr, fix_valids=include_missing) - if isinstance(arr, np.ma.core.MaskedArray): + if isinstance(arr, np.ma.core.MaskedArray): inflate_ind = tuple( ( None @@ -470,7 +468,7 @@ def missing(self): def mr_dim_ind(self): indices = [ i for i, dim in enumerate(self.dimensions) - if dim.type == 'multiple_response' + if dim.dimension_type == 'multiple_response' ] if indices: return indices[0] if len(indices) == 1 else tuple(indices) @@ -488,7 +486,7 @@ def mr_selections_indices(self): mr_dimensions_indices = [ i for (i, dim) in enumerate(self.all_dimensions) if (i + 1 < len(self.all_dimensions) and - dim.type == 'multiple_response') + dim.dimension_type == 'multiple_response') ] # For each MR and CA dimension, the 'selections' dimension @@ -801,16 +799,20 @@ def scale_means(self, hs_dims=None, prune=False): scale_means[1] = scale_means[1][~col_mask] return slices_means + @lazyproperty + def slices(self): + return self.get_slices() + @lazyproperty def univariate_ca_main_axis(self): """For univariate CA, the main axis is the categorical axis""" - dim_types = [dim.type for dim in self.dimensions] + dim_types = [d.dimension_type for d in self.dimensions] return dim_types.index('categorical') def valid_indices_with_selections(self, include_missing=False): """Get all valid indices (including MR selections).""" return [ - dim.valid_indices(include_missing) + dim.element_indices(include_missing) for dim in self.all_dimensions ] @@ -889,7 +891,7 @@ def _adjust_axis(self, axis): # axis (that were provided by the user). But we don't need to update # the axis that are "behind" the current MR. for i, dim in enumerate(self.dimensions): - if dim.type == 'multiple_response': + if dim.dimension_type == 'multiple_response': # This formula updates only the axis that come "after" the # current MR (items) dimension. new_axis[axis >= i] += 1 @@ -1042,7 +1044,7 @@ def _fix_shape(self, array, fix_valids=False): 0 if dim.is_mr_selections(self.all_dimensions) else slice(None) for dim, n in zip(self.all_dimensions, array.shape) ) if not fix_valids else np.ix_(*[ - dim.valid_indices(False) if n > 1 else [0] + dim.element_indices(include_missing=False) if n > 1 else [0] for dim, n in zip(self.all_dimensions, array.shape) ]) array = array[display_ind] @@ -1074,32 +1076,29 @@ def _inserted_dim_inds(self, transform_dims, axis): return np.array(inserted_inds[dim_ind] if len(inserted_inds) else []) def _insertions(self, result, dimension, dimension_index): - insertions = [] - - for indices in dimension.hs_indices: - ind_subtotal_elements = np.array(indices['inds']) - - if indices['anchor_ind'] == 'top': - ind_insertion = -1 - elif indices['anchor_ind'] == 'bottom': - ind_insertion = result.shape[dimension_index] - 1 - else: - ind_insertion = indices['anchor_ind'] + """Return list of (idx, sum) pairs representing subtotals. - ind = tuple( - [slice(None) for _ in range(dimension_index)] + - [ind_subtotal_elements] - ) - axis = dimension_index + *idx* is the int offset at which to insert the ndarray subtotal + in *sum*. + """ - # no indices are provided (should never get here) - if len(indices['inds']) == 0: - value = 0 - else: - value = np.sum(result[ind], axis=axis) - insertions.append((ind_insertion, value)) + def iter_insertions(): + for anchor_idx, addend_idxs in dimension.hs_indices: + insertion_idx = ( + -1 if anchor_idx == 'top' else + result.shape[dimension_index] - 1 if anchor_idx == 'bottom' + else anchor_idx + ) + addend_fancy_idx = tuple( + [slice(None) for _ in range(dimension_index)] + + [np.array(addend_idxs)] + ) + yield ( + insertion_idx, + np.sum(result[addend_fancy_idx], axis=dimension_index) + ) - return insertions + return [insertion for insertion in iter_insertions()] def _intersperse_hs_in_std_res(self, hs_dims, res): for dim, inds in enumerate(self.inserted_hs_indices()): @@ -1334,10 +1333,11 @@ def _transform(self, res, include_transforms_for_dims, # Check if transformations can/need to be performed transform = (dim.has_transforms and i - dim_offset in include_transforms_for_dims) - if dim.type == 'multiple_response': + if dim.dimension_type == 'multiple_response': dim_offset += 1 if (not transform or - dim.type in ITEM_DIMENSION_TYPES or dim.is_selections): + dim.dimension_type in ITEM_DIMENSION_TYPES or + dim.is_selections): continue # Perform transformations insertions = self._insertions(res, dim, i) diff --git a/src/cr/cube/dimension.py b/src/cr/cube/dimension.py index c12a3b3f4..8c588f6ac 100644 --- a/src/cr/cube/dimension.py +++ b/src/cr/cube/dimension.py @@ -2,6 +2,8 @@ """Provides the Dimension class.""" +from collections import Sequence + import numpy as np from cr.cube import ITEM_DIMENSION_TYPES @@ -19,124 +21,125 @@ class Dimension(object): :attr:`.CrunchCube.dimensions`. """ - def __init__(self, dim, selections=None): - self._dim = dim - self._type = self._get_type(dim, selections) + def __init__(self, dimension_dict, next_dimension_dict=None): + self._dimension_dict = dimension_dict + self._next_dimension_dict = next_dimension_dict @lazyproperty def alias(self): - """Alias of a cube's dimension.""" - refs = self._dim['references'] + """str system (as opposed to human) name for this dimension.""" + refs = self._dimension_dict['references'] return refs.get('alias') @lazyproperty def description(self): - """Description of a cube's dimension.""" - refs = self._dim['references'] - return refs.get('description') + """str description of this dimension.""" + description = self._dimension_dict['references'].get('description') + return description if description else '' + + @lazyproperty + def dimension_type(self): + """str representing type of this cube dimension.""" + # ---all this logic really belongs in the Dimensions collection + # ---object, which is where it will move to once that's implemented + + def next_dim_is_mr_cat(): + """True if subsequent dimension is an MR_CAT dimension.""" + if not self._next_dimension_dict: + return False + + categories = self._next_dimension_dict['type'].get('categories') + if not categories: + return False + + return ( + [category.get('id') for category in categories] == [1, 0, -1] + ) + + type_dict = self._dimension_dict['type'] + type_class = type_dict.get('class') + + if not type_class: + # ---numeric and text are like this--- + return type_dict['subtype']['class'] + + if type_class == 'enum': + if 'subreferences' in self._dimension_dict['references']: + return ( + 'multiple_response' if next_dim_is_mr_cat() + else 'categorical_array' + ) + if 'subtype' in type_dict: + # ---datetime is like this (enum without subreferences)--- + return type_dict['subtype']['class'] + + return type_class @memoize - def elements(self, include_missing=False): - """Get elements of the crunch Dimension. + def element_indices(self, include_missing): + """Return tuple of int element idxs for this dimension. - For categorical variables, the elements are represented by categories - internally. For other variable types, actual 'elements' of the - Crunch Cube JSON response are returned. + *include_missing* determines whether missing elements are included or + only valid element index values are returned. """ - if include_missing: - return self._elements - - return [ - el for (i, el) in enumerate(self._elements) - if i not in self.invalid_indices - ] + return ( + self._all_elements.element_idxs if include_missing else + self._valid_elements.element_idxs + ) - @lazyproperty - def elements_by_id(self): - r = {} - for i, el in enumerate(self._elements): - el['index'] = i - r[el['id']] = el - return r + @memoize + def elements(self, include_missing=False): + """_Elements object providing access to elements of this dimension.""" + return ( + self._all_elements if include_missing else self._valid_elements + ) @lazyproperty def has_transforms(self): - view = self._dim['references'].get('view') - if not view: - return False - insertions = view.get('transform', {}).get('insertions') - return insertions is not None + """True if there are subtotals on this dimension, False otherwise.""" + return len(self._subtotals) > 0 @lazyproperty def hs_indices(self): - """Headers and Subtotals indices.""" - if self.is_selections: - return [] - - eid = self.elements_by_id + """tuple of (anchor_idx, addend_idxs) pair for each subtotal. - indices = [] - for subtotal in self.subtotals: + Example:: - inds = [] - for arg in subtotal.args: - inds.append(eid[arg]['index']) - - indices.append({'anchor_ind': self._transform_anchor(subtotal), - 'inds': inds}) + ( + (2, (0, 1, 2)), + (3, (3,)), + ('bottom', (4, 5)) + ) - # filter where indices aren't available to sum - indices = [ind for ind in indices if len(ind['inds']) > 0] + Note that the `anchor_idx` item in the first position of each pair + can be 'top' or 'bottom' as well as an int. The `addend_idxs` tuple + will always contains at least one index (a subtotal with no addends + is ignored). + """ + if self.is_selections: + return () - return indices + return tuple( + (subtotal.anchor_idx, subtotal.addend_idxs) + for subtotal in self._subtotals + ) @lazyproperty def inserted_hs_indices(self): - """Returns inserted H&S indices for the dimension.""" - if (self.type in ITEM_DIMENSION_TYPES or not self.subtotals): - return [] # For CA and MR items, we don't do H&S insertions - - elements = self.elements() - element_ids = [element['id'] for element in elements] - - top_indexes = [] - middle_indexes = [] - bottom_indexes = [] - for i, st in enumerate(self.subtotals): - anchor = st.anchor - if anchor == 'top': - top_indexes.append(i) - elif anchor == 'bottom': - bottom_indexes.append(i) - else: - middle_indexes.append(anchor) - len_top_indexes = len(top_indexes) - - # push all top indexes to the top - top_indexes = list(range(len_top_indexes)) - - # adjust the middle_indexes appropriately - middle_indexes = [ - i + element_ids.index(index) + len_top_indexes + 1 - for i, index in enumerate(middle_indexes) - ] - - # what remains is the bottom - len_non_bottom_indexes = ( - len_top_indexes + len(middle_indexes) + len(elements) - ) - bottom_indexes = list(range( - len_non_bottom_indexes, len_non_bottom_indexes + len(bottom_indexes) - )) + """list of int index of each inserted subtotal for the dimension. - return top_indexes + middle_indexes + bottom_indexes + Each value represents the position of a subtotal in the interleaved + sequence of elements and subtotals items. + """ + # ---don't do H&S insertions for CA and MR subvar dimensions--- + if self.dimension_type in ITEM_DIMENSION_TYPES: + return [] - @lazyproperty - def invalid_indices(self): - return set(( - i for (i, el) in enumerate(self._elements) - if el.get('missing') - )) + return [ + idx for idx, item + in enumerate(self._iter_interleaved_items(self._valid_elements)) + if item.is_insertion + ] def is_mr_selections(self, others): """Return True if this dimension var is multiple-response selections. @@ -156,287 +159,484 @@ def is_mr_selections(self, others): @lazyproperty def is_selections(self): - categories = self._elements - if len(categories) != 3: - return False - - mr_ids = (1, 0, -1) - for i, mr_id in enumerate(mr_ids): - if categories[i]['id'] != mr_id: - return False - return True + """True for the categories dimension of an MR dimension-pair.""" + return ( + len(self._all_elements) == 3 and + self._all_elements.element_ids == (1, 0, -1) + ) def labels(self, include_missing=False, include_transforms=False, include_cat_ids=False): - """Get labels of the Crunch Dimension.""" - if (not (include_transforms and self.has_transforms) or - self.type == 'categorical_array'): - return [ - ( - self._get_name(el) - if not include_cat_ids else - (self._get_name(el), el.get('id', -1)) - ) - for (i, el) in enumerate(self._elements) - if include_missing or i not in self.invalid_indices - ] - - # Create subtotals names and insert them in labels after - # appropriate anchors - labels_with_cat_ids = [{ - 'ind': i, - 'id': el['id'], - 'name': self._get_name(el), - } for (i, el) in enumerate(self._elements)] - labels_with_cat_ids = self._update_with_subtotals(labels_with_cat_ids) - - valid_indices = self.valid_indices(include_missing) - return [ - ( - label['name'] - if not include_cat_ids else - (label['name'], label.get('id', -1)) + """Return list of str labels for the elements of this dimension. + + Returns a list of (label, element_id) pairs if *include_cat_ids* is + True. The `element_id` value in the second position of the pair is + None for subtotal items (which don't have an element-id). + """ + # TODO: Having an alternate return type triggered by a flag-parameter + # (`include_cat_ids` in this case) is poor practice. Using flags like + # that effectively squashes what should be two methods into one. + # Either get rid of the need for that alternate return value type or + # create a separate method for it. + elements = ( + self._all_elements if include_missing else self._valid_elements + ) + + include_subtotals = ( + include_transforms and + self.dimension_type != 'categorical_array' + ) + + # ---items are elements or subtotals, interleaved in display order--- + interleaved_items = tuple(self._iter_interleaved_items(elements)) + + labels = list( + item.label + for item in interleaved_items + if include_subtotals or not item.is_insertion + ) + + if include_cat_ids: + element_ids = tuple( + None if item.is_insertion else item.element_id + for item in interleaved_items + if include_subtotals or not item.is_insertion ) - for label in labels_with_cat_ids - if self._include_in_labels(label, valid_indices) - ] + return list(zip(labels, element_ids)) + + return labels @lazyproperty def name(self): """Name of a cube's dimension.""" - refs = self._dim['references'] + refs = self._dimension_dict['references'] return refs.get('name', refs.get('alias')) + @lazyproperty + def numeric_values(self): + """tuple of numeric values for valid elements of this dimension. + + Each category of a categorical variable can be assigned a *numeric + value*. For example, one might assign `like=1, dislike=-1, + neutral=0`. These numeric mappings allow quantitative operations + (such as mean) to be applied to what now forms a *scale* (in this + example, a scale of preference). + + The numeric values appear in the same order as the + categories/elements of this dimension. Each element is represented by + a value, but an element with no numeric value appears as `np.nan` in + the returned list. + """ + return tuple( + element.numeric_value for element in self._valid_elements + ) + @lazyproperty def shape(self): - return len(self._elements) + return len(self._all_elements) @lazyproperty - def subtotals(self): - view = self._dim.get('references', {}).get('view', {}) + def _all_elements(self): + """_AllElements object providing cats or subvars of this dimension.""" + return _AllElements(self._dimension_dict['type']) - if not view: - # View can be both None and {}, thus the edge case. - return [] + def _iter_interleaved_items(self, elements): + """Generate element or subtotal items in interleaved order. + + This ordering corresponds to how value "rows" (or columns) are to + appear after subtotals have been inserted at their anchor locations. + Where more than one subtotal is anchored to the same location, they + appear in their document order in the cube response. + + Only elements in the passed *elements* collection appear, which + allows control over whether missing elements are included by choosing + `._all_elements` or `._valid_elements`. + """ + subtotals = self._subtotals + + for subtotal in subtotals.iter_for_anchor('top'): + yield subtotal + + for element in elements: + yield element + for subtotal in subtotals.iter_for_anchor(element.element_id): + yield subtotal - insertions_data = view.get('transform', {}).get('insertions', []) - subtotals = [_Subtotal(data, self) for data in insertions_data] - return [subtotal for subtotal in subtotals if subtotal.is_valid] + for subtotal in subtotals.iter_for_anchor('bottom'): + yield subtotal @lazyproperty - def type(self): - """Get type of the Crunch Dimension.""" - return self._type + def _subtotals(self): + """_Subtotals sequence object for this dimension. - @memoize - def valid_indices(self, include_missing): - """Gets valid indices of Crunch Cube Dimension's elements. + The subtotals sequence provides access to any subtotal insertions + defined on this dimension. + """ + view = self._dimension_dict.get('references', {}).get('view', {}) + # ---view can be both None and {}, thus the edge case.--- + insertion_dicts = ( + [] if view is None else + view.get('transform', {}).get('insertions', []) + ) + return _Subtotals(insertion_dicts, self._valid_elements) - This function needs to be used by CrunchCube class, in order to - correctly calculate the indices of the result that needs to be - returned to the user. In most cases, the non-valid indices are - those of the missing values. + @lazyproperty + def _valid_elements(self): + """_Elements object providing access to non-missing elements. + + Any categories or subvariables representing missing data are excluded + from the collection; this sequence represents a subset of that + provided by `._all_elements`. """ - if include_missing: - return range(len(self._elements)) - else: - return [x for x in range(len(self._elements)) - if x not in self.invalid_indices] + return self._all_elements.valid_elements + + +class _BaseElements(Sequence): + """Base class for element sequence containers.""" + + def __init__(self, type_dict): + self._type_dict = type_dict + + def __getitem__(self, idx_or_slice): + """Implements indexed access.""" + return self._elements[idx_or_slice] + + def __iter__(self): + """Implements (efficient) iterability.""" + return iter(self._elements) + + def __len__(self): + """Implements len(elements).""" + return len(self._elements) @lazyproperty - def values(self): - """list of numeric values for elements of this dimension. + def element_ids(self): + """tuple of element-id for each element in collection. - Each category of a categorical variable can be assigned a *numeric - value*. For example, one might assign `like=1, dislike=-1, - neutral=0`. These numeric mappings allow quantitative operations - (such as mean) to be applied to what now forms a *scale* (in this - example, a scale of preference). + Element ids appear in the order they occur in the cube response. + """ + return tuple(element.element_id for element in self._elements) - The numeric values appear in the same order as the - categories/elements of this dimension. Each element is represented by - a value, but an element with no numeric value appears as `np.nan` in - the returned list. + @lazyproperty + def element_idxs(self): + """tuple of element-index for each element in collection. + + Element index values represent the position of this element in the + dimension-dict it came from. In the case of an _AllElements object, + it will simply be a tuple(range(len(all_elements))). """ - values = [ - el.get('numeric_value', np.nan) - for el in self._elements - if not el.get('missing') - ] - return [val if val is not None else np.nan for val in values] + return tuple(element.index for element in self._elements) + + def get_by_id(self, element_id): + """Return _Element object identified by *element_id*. + + Raises KeyError if not found. Only elements known to this collection + are accessible for lookup. For example, a _ValidElements object will + raise KeyError for the id of a missing element. + """ + return self._elements_by_id[element_id] @lazyproperty def _elements(self): - if self.type == 'categorical': - return self._dim['type']['categories'] - return self._dim['type']['elements'] - - @classmethod - def _get_name(cls, element): - name = element.get('name') - - # For categorical variables - if name: - return name - - # For numerical, datetime and text variables - value = element.get('value') - if value is None: - return None - - # The following statement is used for compatibility between - # python 2 and 3. In python 3 everything is 'str' and 'unicode' - # is not defined. So, if the value is textual, in python 3 the first - # part of the 'or' statement should short-circuit to 'True'. - type_ = type(value) - if type_ == list: - return '-'.join([str(el) for el in value]) - elif type_ in [float, int]: - return str(value) - elif type_ != dict and (type_ == str or type_ == unicode): # noqa: F821 - return value + """tuple storing actual sequence of element objects. - # For categorical array variables - name = value.get('references', {}).get('name') - if name: - return name + Must be implemented by each subclass. + """ + raise NotImplementedError('must be implemented by each subclass') + + @lazyproperty + def _element_makings(self): + """(ElementCls, element_dicts) pair for this dimension's elements. + + All the elements of a given dimension are the same type. This method + determines the type (class) and source dicts for the elements of this + dimension and provides them for the element factory. + """ + if self._type_dict['class'] == 'categorical': + return _Category, self._type_dict['categories'] + return _Element, self._type_dict['elements'] + + @lazyproperty + def _elements_by_id(self): + """dict mapping each element by its id.""" + return {element.element_id: element for element in self._elements} + + +class _AllElements(_BaseElements): + """Sequence of _BaseElement subclass objects for a dimension. - return None + Each element is either a category or a subvariable. + """ - @classmethod - def _get_type(cls, dim, selections=None): - """Gets the Dimension type. + @lazyproperty + def valid_elements(self): + """_ValidElements object containing only non-missing elements.""" + return _ValidElements(self._elements) - MR and CA variables have two subsequent dimension, which are both - necessary to determine the correct type ('categorical_array', or - 'multiple_response'). + @lazyproperty + def _elements(self): + """Composed tuple storing actual sequence of element objects.""" + ElementCls, element_dicts = self._element_makings + return tuple( + ElementCls(element_dict, idx) + for idx, element_dict in enumerate(element_dicts) + ) + + +class _ValidElements(_BaseElements): + """Sequence of non-missing element objects for a dimension. + + *all_elements* is an instance of _AllElements containing all the elements + of a dimension. This object is only intended to be constructed by + _AllElements.valid_elements and there should be no reason to construct it + directly. + """ + + def __init__(self, all_elements): + self._all_elements = all_elements + + @lazyproperty + def _elements(self): + """tuple containing actual sequence of element objects.""" + return tuple( + element for element in self._all_elements + if not element.missing + ) + + +class _BaseElement(object): + """Base class for element objects.""" + + def __init__(self, element_dict, index): + self._element_dict = element_dict + self._index = index + + @lazyproperty + def element_id(self): + """int identifier for this category or subvariable.""" + return self._element_dict['id'] + + @lazyproperty + def index(self): + """int offset at which this element appears in dimension. + + This position is based upon the document position of this element in + the cube response. No adjustment for missing elements is made. """ - type_ = dim['type'].get('class') + return self._index - if type_: - if type_ == 'enum': - if 'subreferences' in dim['references']: - return ('multiple_response' - if cls._is_multiple_response(selections) - else 'categorical_array') - if 'subtype' in dim['type']: - return dim['type']['subtype']['class'] + @property + def is_insertion(self): + """True if this item represents an insertion (e.g. subtotal). - return type_ + Unconditionally False for all element types. + """ + return False - return dim['type']['subtype']['class'] + @lazyproperty + def missing(self): + """True if this element represents missing data. - @staticmethod - def _include_in_labels(label_with_ind, valid_indices): - if label_with_ind.get('ind') is None: - # In this case, it's a transformation and not an element of the - # cube. Thus, needs to be included in resulting labels. - return True + False if this category or subvariable represents valid (collected) + data. + """ + return bool(self._element_dict.get('missing')) - return label_with_ind['ind'] in valid_indices + @lazyproperty + def numeric_value(self): + """Numeric value assigned to element by user, np.nan if absent.""" + numeric_value = self._element_dict.get('numeric_value') + return np.nan if numeric_value is None else numeric_value - @classmethod - def _is_multiple_response(cls, dim): - if not dim: - return False - categories = dim['type'].get('categories') - if not categories: - return False +class _Category(_BaseElement): + """A category on a categorical dimension.""" - if len(categories) != 3: - return False + def __init__(self, category_dict, index): + super(_Category, self).__init__(category_dict, index) + self._category_dict = category_dict - mr_ids = (1, 0, -1) - for i, mr_id in enumerate(mr_ids): - if categories[i]['id'] != mr_id: - return False - return True + @lazyproperty + def label(self): + """str display name assigned to this category by user.""" + name = self._category_dict.get('name') + return name if name else '' + + +class _Element(_BaseElement): + """A subvariable on an MR or CA enum dimension.""" + + @lazyproperty + def label(self): + """str display-name for this element, '' when absent from cube response. - def _transform_anchor(self, subtotal): + This property handles numeric, datetime and text variables, but also + subvar dimensions + """ + value = self._element_dict.get('value') + type_name = type(value).__name__ - if subtotal.anchor in ['top', 'bottom']: - return subtotal.anchor + if type_name == 'NoneType': + return '' - return self.elements_by_id[subtotal.anchor]['index'] + if type_name == 'list': + # ---like '10-15' or 'A-F'--- + return '-'.join([str(item) for item in value]) - def _update_with_subtotals(self, labels_with_cat_ids): - for subtotal in self.subtotals: - already_inserted_with_the_same_anchor = [ - index for (index, item) in enumerate(labels_with_cat_ids) - if 'anchor' in item and item['anchor'] == subtotal.anchor - ] + if type_name in ('float', 'int'): + return str(value) - if len(already_inserted_with_the_same_anchor): - ind_insert = already_inserted_with_the_same_anchor[-1] + 1 - elif subtotal.anchor == 'top': - ind_insert = 0 - elif subtotal.anchor == 'bottom': - ind_insert = len(labels_with_cat_ids) - else: - ind_insert = next( - index for (index, item) in enumerate(labels_with_cat_ids) - if item.get('id') == subtotal.anchor - ) + 1 + if type_name in ('str', 'unicode'): + return value - labels_with_cat_ids.insert(ind_insert, subtotal.data) + # ---For CA and MR subvar dimensions--- + name = value.get('references', {}).get('name') + return name if name else '' - return labels_with_cat_ids +class _Subtotals(Sequence): + """Sequence of _Subtotal objects for a dimension. -class _Subtotal(object): - """Implementation of the Insertion class for Crunch Cubes. + Each _Subtotal object represents a "subtotal" insertion transformation + defined for the dimension. - Contains all functionality necessary for retrieving the information - for subtotals. This functionality is used in the context - of headers and subtotals. + A subtotal can only involve valid (i.e. non-missing) elements. """ - def __init__(self, data, dim): - self._data = data - self._dim = dim + def __init__(self, insertion_dicts, valid_elements): + self._insertion_dicts = insertion_dicts + self._valid_elements = valid_elements + + def __getitem__(self, idx_or_slice): + """Implements indexed access.""" + return self._subtotals[idx_or_slice] + + def __iter__(self): + """Implements (efficient) iterability.""" + return iter(self._subtotals) + + def __len__(self): + """Implements len(subtotals).""" + return len(self._subtotals) + + def iter_for_anchor(self, anchor): + """Generate each subtotal having matching *anchor*.""" + return ( + subtotal for subtotal in self._subtotals + if subtotal.anchor == anchor + ) + + @lazyproperty + def _element_ids(self): + """frozenset of int id of each non-missing cat or subvar in dim.""" + return frozenset(self._valid_elements.element_ids) + + def _iter_valid_subtotal_dicts(self): + """Generate each insertion dict that represents a valid subtotal.""" + for insertion_dict in self._insertion_dicts: + # ---skip any non-dicts--- + if not isinstance(insertion_dict, dict): + continue + + # ---skip any non-subtotal insertions--- + if insertion_dict.get('function') != 'subtotal': + continue + + # ---skip any malformed subtotal-dicts--- + if not {'anchor', 'args', 'name'}.issubset(insertion_dict.keys()): + continue + + # ---skip if doesn't reference at least one non-missing element--- + if not self._element_ids.intersection(insertion_dict['args']): + continue + + # ---an insertion-dict that successfully runs this gauntlet + # ---is a valid subtotal dict + yield insertion_dict + + @lazyproperty + def _subtotals(self): + """Composed tuple storing actual sequence of _Subtotal objects.""" + return tuple( + _Subtotal(subtotal_dict, self._valid_elements) + for subtotal_dict in self._iter_valid_subtotal_dicts() + ) + + +class _Subtotal(object): + """A subtotal insertion on a cube dimension.""" + + def __init__(self, subtotal_dict, valid_elements): + self._subtotal_dict = subtotal_dict + self._valid_elements = valid_elements @lazyproperty def anchor(self): - """Get the anchor of the subtotal (if it's valid).""" - if not self.is_valid: - return None + """int or str indicating element under which to insert this subtotal. + + An int anchor is the id of the dimension element (category or + subvariable) under which to place this subtotal. The return value can + also be one of 'top' or 'bottom'. - anchor = self._data['anchor'] + The return value defaults to 'bottom' for an anchor referring to an + element that is no longer present in the dimension or an element that + represents missing data. + """ + anchor = self._subtotal_dict['anchor'] try: anchor = int(anchor) - if anchor not in self._all_dim_ids: + if anchor not in self._valid_elements.element_ids: return 'bottom' return anchor except (TypeError, ValueError): return anchor.lower() @lazyproperty - def args(self): - """Get H&S args.""" - hs_ids = self._data.get('args', None) - if hs_ids and self.is_valid: - return hs_ids - return [] + def anchor_idx(self): + """int or str representing index of anchor element in dimension. + + When the anchor is an operation, like 'top' or 'bottom' + """ + anchor = self.anchor + if anchor in ['top', 'bottom']: + return anchor + return self._valid_elements.get_by_id(anchor).index @lazyproperty - def data(self): - """Get data in JSON format.""" - return self._data + def addend_ids(self): + """tuple of int ids of elements contributing to this subtotal. + + Any element id not present in the dimension or present but + representing missing data is excluded. + """ + return tuple( + arg for arg in self._subtotal_dict.get('args', []) + if arg in self._valid_elements.element_ids + ) @lazyproperty - def is_valid(self): - """Test if the subtotal data is valid.""" - if isinstance(self._data, dict): - required_keys = {'anchor', 'args', 'function', 'name'} - has_keys = set(self._data.keys()) == required_keys - if has_keys and self._data['function'] == 'subtotal': - return any( - element for element in self._dim.elements() - if element['id'] in self._data['args'] - ) - return False + def addend_idxs(self): + """tuple of int index of each addend element for this subtotal. + + The length of the tuple is the same as that for `.addend_ids`, but + each value repesents the offset of that element within the dimension, + rather than its element id. + """ + return tuple( + self._valid_elements.get_by_id(addend_id).index + for addend_id in self.addend_ids + ) + + @property + def is_insertion(self): + """True if this item represents an insertion (e.g. subtotal). + + Unconditionally True for _Subtotal objects. + """ + return True @lazyproperty - def _all_dim_ids(self): - return [el.get('id') for el in self._dim.elements(include_missing=True)] + def label(self): + """str display name for this subtotal, suitable for use as label.""" + name = self._subtotal_dict.get('name') + return name if name else '' diff --git a/src/cr/cube/measures/scale_means.py b/src/cr/cube/measures/scale_means.py index 67541af83..5671a8f33 100644 --- a/src/cr/cube/measures/scale_means.py +++ b/src/cr/cube/measures/scale_means.py @@ -83,14 +83,18 @@ def _valid_indices(self, axis): # --CrunchSlice.reshaped_dimensions. reshaped_dimensions = [ dimension for dimension in self._slice.dimensions - if len(dimension.values) > 1 + if len(dimension.numeric_values) > 1 ] return tuple( ( - ~np.isnan(np.array(dim.values)) - if dim.values and any(~np.isnan(dim.values)) and axis == i else - slice(None) + ~np.isnan(np.array(dim.numeric_values)) + if ( + dim.numeric_values and + any(~np.isnan(dim.numeric_values)) and + axis == i + ) + else slice(None) ) for i, dim in enumerate(reshaped_dimensions) ) @@ -104,9 +108,12 @@ def values(self): """ return [ ( - np.array(dim.values) - if dim.values and any(~np.isnan(dim.values)) else - None + np.array(dim.numeric_values) + if ( + dim.numeric_values and + any(~np.isnan(dim.numeric_values)) + ) + else None ) for dim in self._slice.dimensions ] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/fixtures/__init__.py b/tests/integration/fixtures/__init__.py index 364e25878..51c3966a4 100644 --- a/tests/integration/fixtures/__init__.py +++ b/tests/integration/fixtures/__init__.py @@ -33,6 +33,7 @@ def _load(cube_file): LOGICAL_UNIVARIATE = _load('logical-univariate.json') # Various other Cubes +CA_SUBVAR_HS_X_MR_X_CA_CAT = _load('ca-subvar-hs-x-mr-x-ca-cat.json') CAT_X_NUM_X_DATETIME = _load('cat-x-num-x-datetime.json') SIMPLE_MR = _load('simple-mr.json') CAT_X_MR_SIMPLE = _load('cat-x-mr.json') diff --git a/tests/integration/fixtures/cubes/ca-subvar-hs-x-mr-x-ca-cat.json b/tests/integration/fixtures/cubes/ca-subvar-hs-x-mr-x-ca-cat.json new file mode 100644 index 000000000..4458150a6 --- /dev/null +++ b/tests/integration/fixtures/cubes/ca-subvar-hs-x-mr-x-ca-cat.json @@ -0,0 +1,1292 @@ +{ + "query": { + "dimensions": [ + { + "each": 3 + }, + { + "each": 2 + }, + { + "args": [ + { + "variable": "0000dd" + } + ], + "function": "as_selected" + }, + { + "variable": "0000d8" + } + ], + "measures": { + "count": { + "args": [], + "function": "cube_count" + } + }, + "weight": "http://127.0.0.1:8080/datasets/230d454e1b5942a99f870fdb0a31c8ca/variables/0000d0/" + }, + "query_environment": { + "filter": [] + }, + "result": { + "counts": [ + 34, + 39, + 26, + 37, + 6, + 1, + 0, + 0, + 21, + 36, + 23, + 26, + 18, + 4, + 0, + 0, + 257, + 340, + 222, + 427, + 129, + 16, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 312, + 415, + 271, + 490, + 153, + 21, + 0, + 0, + 29, + 43, + 26, + 33, + 7, + 3, + 0, + 0, + 23, + 35, + 20, + 28, + 17, + 3, + 0, + 0, + 260, + 337, + 225, + 429, + 129, + 15, + 0, + 0, + 251, + 335, + 223, + 424, + 140, + 16, + 0, + 0, + 6, + 11, + 7, + 6, + 1, + 2, + 0, + 0, + 55, + 69, + 41, + 60, + 12, + 3, + 0, + 0, + 15, + 36, + 23, + 62, + 6, + 1, + 0, + 0, + 16, + 35, + 25, + 32, + 18, + 2, + 0, + 0, + 141, + 288, + 234, + 573, + 135, + 20, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 172, + 359, + 282, + 667, + 159, + 23, + 0, + 0, + 19, + 31, + 30, + 50, + 7, + 4, + 0, + 0, + 14, + 35, + 23, + 34, + 17, + 3, + 0, + 0, + 139, + 293, + 229, + 583, + 135, + 16, + 0, + 0, + 139, + 294, + 233, + 560, + 146, + 17, + 0, + 0, + 5, + 8, + 7, + 10, + 1, + 2, + 0, + 0, + 28, + 57, + 42, + 97, + 12, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 143, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 128, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1391, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1662, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 141, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 126, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1395, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1389, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 33, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 240 + ], + "dimensions": [ + { + "derived": true, + "references": { + "alias": "abolitionists", + "description": "Do you have a favorable or an unfavorable opinion of the following abolitionists?", + "name": "Abolitionists", + "notes": "A categorical array variable, where one item has no responses", + "subreferences": [ + { + "alias": "douglass", + "description": "fav_ppdem", + "name": "Frederick Douglass" + }, + { + "alias": "brown", + "description": "fav_pprep", + "name": "John Brown" + }, + { + "alias": "truth", + "name": "Sojourner Truth" + } + ], + "view": { + "column_width": null, + "include_missing": false, + "show_counts": false, + "transform": { + "insertions": [ + { + "anchor": 2, + "args": [ + 0, + 2 + ], + "function": "subtotal", + "name": "favorable" + }, + { + "anchor": 4, + "args": [ + 3, + 4 + ], + "function": "subtotal", + "name": "unfavorable" + } + ] + } + } + }, + "type": { + "class": "enum", + "elements": [ + { + "id": 1, + "missing": false, + "value": { + "derived": false, + "id": "0061", + "references": { + "alias": "douglass", + "description": "fav_ppdem", + "name": "Frederick Douglass" + }, + "type": { + "categories": [ + { + "id": 0, + "missing": false, + "name": "Very favorable", + "numeric_value": 0, + "selected": false + }, + { + "id": 2, + "missing": false, + "name": "Somewhat favorable", + "numeric_value": 2, + "selected": false + }, + { + "id": 3, + "missing": false, + "name": "Somewhat unfavorable", + "numeric_value": 3, + "selected": false + }, + { + "id": 4, + "missing": false, + "name": "Very unfavorable", + "numeric_value": 4, + "selected": false + }, + { + "id": 5, + "missing": false, + "name": "Don't know", + "numeric_value": 5, + "selected": false + }, + { + "id": 32766, + "missing": true, + "name": "skipped", + "numeric_value": 32766, + "selected": false + }, + { + "id": 32767, + "missing": true, + "name": "not asked", + "numeric_value": 32767, + "selected": false + }, + { + "id": -1, + "missing": true, + "name": "No Data", + "numeric_value": null, + "selected": false + } + ], + "class": "categorical", + "ordinal": false + } + } + }, + { + "id": 2, + "missing": false, + "value": { + "derived": false, + "id": "0062", + "references": { + "alias": "brown", + "description": "fav_pprep", + "name": "John Brown" + }, + "type": { + "categories": [ + { + "id": 0, + "missing": false, + "name": "Very favorable", + "numeric_value": 0, + "selected": false + }, + { + "id": 2, + "missing": false, + "name": "Somewhat favorable", + "numeric_value": 2, + "selected": false + }, + { + "id": 3, + "missing": false, + "name": "Somewhat unfavorable", + "numeric_value": 3, + "selected": false + }, + { + "id": 4, + "missing": false, + "name": "Very unfavorable", + "numeric_value": 4, + "selected": false + }, + { + "id": 5, + "missing": false, + "name": "Don't know", + "numeric_value": 5, + "selected": false + }, + { + "id": 32766, + "missing": true, + "name": "skipped", + "numeric_value": 32766, + "selected": false + }, + { + "id": 32767, + "missing": true, + "name": "not asked", + "numeric_value": 32767, + "selected": false + }, + { + "id": -1, + "missing": true, + "name": "No Data", + "numeric_value": null, + "selected": false + } + ], + "class": "categorical", + "ordinal": false + } + } + }, + { + "id": 3, + "missing": false, + "value": { + "derived": false, + "id": "fb4492a07d5142f9a9a49de9c07ce8a1", + "references": { + "alias": "truth", + "name": "Sojourner Truth" + }, + "type": { + "categories": [ + { + "id": 0, + "missing": false, + "name": "Very favorable", + "numeric_value": 0, + "selected": false + }, + { + "id": 2, + "missing": false, + "name": "Somewhat favorable", + "numeric_value": 2, + "selected": false + }, + { + "id": 3, + "missing": false, + "name": "Somewhat unfavorable", + "numeric_value": 3, + "selected": false + }, + { + "id": 4, + "missing": false, + "name": "Very unfavorable", + "numeric_value": 4, + "selected": false + }, + { + "id": 5, + "missing": false, + "name": "Don't know", + "numeric_value": 5, + "selected": false + }, + { + "id": 32766, + "missing": true, + "name": "skipped", + "numeric_value": 32766, + "selected": false + }, + { + "id": 32767, + "missing": true, + "name": "not asked", + "numeric_value": 32767, + "selected": false + }, + { + "id": -1, + "missing": true, + "name": "No Data", + "numeric_value": null, + "selected": false + } + ], + "class": "categorical", + "ordinal": false + } + } + } + ], + "subtype": { + "class": "variable" + } + } + }, + { + "derived": true, + "references": { + "alias": "1984_countries", + "description": "Which of the following countries from 1984 would you live in? (select all that apply)", + "name": "Countries from 1984", + "notes": "A multiple response variable, where one item has no responses", + "subreferences": [ + { + "alias": "eurasia", + "description": "union_hhold_1", + "name": "Eurasia" + }, + { + "alias": "disputed", + "name": "Disputed" + }, + { + "alias": "oceania", + "description": "union_hhold_2", + "name": "Oceania" + }, + { + "alias": "eastasia", + "description": "union_hhold_3", + "name": "Eastasia" + } + ], + "uniform_basis": false + }, + "type": { + "class": "enum", + "elements": [ + { + "id": 1, + "missing": false, + "value": { + "derived": false, + "id": "00c5", + "references": { + "alias": "eurasia", + "description": "union_hhold_1", + "name": "Eurasia" + }, + "type": { + "categories": [ + { + "id": 1, + "missing": false, + "name": "Selected", + "numeric_value": 1, + "selected": true + }, + { + "id": 0, + "missing": false, + "name": "Other", + "numeric_value": 0 + }, + { + "id": -1, + "missing": true, + "name": "No Data", + "numeric_value": null + } + ], + "class": "categorical", + "ordinal": false + } + } + }, + { + "id": 2, + "missing": false, + "value": { + "derived": false, + "id": "11b45a85e5ac4011bd3cf884b8a2476f", + "references": { + "alias": "disputed", + "name": "Disputed" + }, + "type": { + "categories": [ + { + "id": 1, + "missing": false, + "name": "Selected", + "numeric_value": 1, + "selected": true + }, + { + "id": 0, + "missing": false, + "name": "Other", + "numeric_value": 0 + }, + { + "id": -1, + "missing": true, + "name": "No Data", + "numeric_value": null + } + ], + "class": "categorical", + "ordinal": false + } + } + }, + { + "id": 3, + "missing": false, + "value": { + "derived": false, + "id": "00c6", + "references": { + "alias": "oceania", + "description": "union_hhold_2", + "name": "Oceania" + }, + "type": { + "categories": [ + { + "id": 1, + "missing": false, + "name": "Selected", + "numeric_value": 1, + "selected": true + }, + { + "id": 0, + "missing": false, + "name": "Other", + "numeric_value": 0 + }, + { + "id": -1, + "missing": true, + "name": "No Data", + "numeric_value": null + } + ], + "class": "categorical", + "ordinal": false + } + } + }, + { + "id": 4, + "missing": false, + "value": { + "derived": false, + "id": "00c7", + "references": { + "alias": "eastasia", + "description": "union_hhold_3", + "name": "Eastasia" + }, + "type": { + "categories": [ + { + "id": 1, + "missing": false, + "name": "Selected", + "numeric_value": 1, + "selected": true + }, + { + "id": 0, + "missing": false, + "name": "Other", + "numeric_value": 0 + }, + { + "id": -1, + "missing": true, + "name": "No Data", + "numeric_value": null + } + ], + "class": "categorical", + "ordinal": false + } + } + } + ], + "subtype": { + "class": "variable" + } + } + }, + { + "derived": true, + "references": { + "alias": "1984_countries", + "description": "Which of the following countries from 1984 would you live in? (select all that apply)", + "name": "Countries from 1984", + "notes": "A multiple response variable, where one item has no responses", + "subreferences": [ + { + "alias": "eurasia", + "description": "union_hhold_1", + "name": "Eurasia" + }, + { + "alias": "disputed", + "name": "Disputed" + }, + { + "alias": "oceania", + "description": "union_hhold_2", + "name": "Oceania" + }, + { + "alias": "eastasia", + "description": "union_hhold_3", + "name": "Eastasia" + } + ], + "uniform_basis": false + }, + "type": { + "categories": [ + { + "id": 1, + "missing": false, + "name": "Selected", + "numeric_value": 1, + "selected": true + }, + { + "id": 0, + "missing": false, + "name": "Other", + "numeric_value": 0 + }, + { + "id": -1, + "missing": true, + "name": "No Data", + "numeric_value": null + } + ], + "class": "categorical", + "ordinal": false, + "subvariables": [ + "00c5", + "11b45a85e5ac4011bd3cf884b8a2476f", + "00c6", + "00c7" + ] + } + }, + { + "derived": false, + "references": { + "alias": "abolitionists", + "description": "Do you have a favorable or an unfavorable opinion of the following abolitionists?", + "name": "Abolitionists", + "notes": "A categorical array variable, where one item has no responses", + "subreferences": [ + { + "alias": "douglass", + "description": "fav_ppdem", + "name": "Frederick Douglass" + }, + { + "alias": "brown", + "description": "fav_pprep", + "name": "John Brown" + }, + { + "alias": "truth", + "name": "Sojourner Truth" + } + ], + "view": { + "column_width": null, + "include_missing": false, + "show_counts": false, + "transform": { + "insertions": [ + { + "anchor": 2, + "args": [ + 0, + 2 + ], + "function": "subtotal", + "name": "favorable" + }, + { + "anchor": 4, + "args": [ + 3, + 4 + ], + "function": "subtotal", + "name": "unfavorable" + } + ] + } + } + }, + "type": { + "categories": [ + { + "id": 0, + "missing": false, + "name": "Very favorable", + "numeric_value": 0, + "selected": false + }, + { + "id": 2, + "missing": false, + "name": "Somewhat favorable", + "numeric_value": 2, + "selected": false + }, + { + "id": 3, + "missing": false, + "name": "Somewhat unfavorable", + "numeric_value": 3, + "selected": false + }, + { + "id": 4, + "missing": false, + "name": "Very unfavorable", + "numeric_value": 4, + "selected": false + }, + { + "id": 5, + "missing": false, + "name": "Don't know", + "numeric_value": 5, + "selected": false + }, + { + "id": 32766, + "missing": true, + "name": "skipped", + "numeric_value": 32766, + "selected": false + }, + { + "id": 32767, + "missing": true, + "name": "not asked", + "numeric_value": 32767, + "selected": false + }, + { + "id": -1, + "missing": true, + "name": "No Data", + "numeric_value": null, + "selected": false + } + ], + "class": "categorical", + "ordinal": false, + "subvariables": [ + "0061", + "0062", + "fb4492a07d5142f9a9a49de9c07ce8a1" + ] + } + } + ], + "element": "crunch:cube", + "measures": { + "count": { + "data": [ + 22.26099250327014, + 34.4602057043984, + 25.563088877030076, + 32.401718138262495, + 4.991129397989441, + 0.752560977946403, + 0, + 0, + 16.984483530316655, + 38.9081180709724, + 35.7209072453127, + 31.035266136006467, + 28.002444742699026, + 6.919184332291868, + 0, + 0, + 222.31051106413815, + 318.45957711552364, + 202.83721619112774, + 438.0131298156195, + 179.35124397873264, + 23.028222178364448, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 261.55598709772505, + 391.82790089089457, + 264.12121231347066, + 501.4501140898886, + 212.34481811942115, + 30.699967488602727, + 0, + 0, + 21.4670764652966, + 44.01337663270962, + 25.335068002584972, + 33.84106799535904, + 8.545097091441706, + 3.60847290734546, + 0, + 0, + 18.762347138878738, + 40.29378632551246, + 30.437410841562908, + 33.331393844496965, + 26.346143477186818, + 5.740042205022288, + 0, + 0, + 221.32656349354963, + 307.5207379326725, + 208.34873346932272, + 434.2776522500326, + 177.4535775507926, + 21.351452376234974, + 0, + 0, + 219.18232447849093, + 314.36524891432765, + 217.58401250955848, + 438.9091648608562, + 198.80859162999, + 23.69171518220356, + 0, + 0, + 3.199266164300618, + 11.037444901300132, + 8.945965337412046, + 5.019667741252505, + 1.65630126551221, + 3.82636054837688, + 0, + 0, + 39.174396454933415, + 66.42520707526663, + 37.5912344665001, + 57.52128148777984, + 11.879925223918939, + 3.1818917580222825, + 0, + 0, + 15.106451517802475, + 32.56041000932026, + 25.12416755778091, + 41.5686964119083, + 5.2311713101206525, + 0.838798791964368, + 0, + 0, + 14.896428954467083, + 41.42346016529121, + 28.221835152107467, + 41.93341125911934, + 28.002444742699026, + 3.092823783914988, + 0, + 0, + 140.99549592400103, + 329.9257009025295, + 215.05009246581164, + 486.723309333154, + 187.65185151445908, + 23.653450203551206, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 170.9983763962706, + 403.90957107714144, + 268.3960951757001, + 570.2254170041821, + 220.8854675672788, + 27.58507277943056, + 0, + 0, + 21.02300969501045, + 34.32300344117949, + 29.553152424754533, + 40.04699026362585, + 8.225370781902221, + 3.63863248826485, + 0, + 0, + 12.966918811913406, + 41.211297404201865, + 26.87050214243102, + 43.584639421047704, + 26.346143477186818, + 3.931622575879356, + 0, + 0, + 137.00844788934674, + 328.3752702317596, + 211.97244060851443, + 486.59378731950824, + 186.31395330818972, + 20.014817715286352, + 0, + 0, + 135.9175154233714, + 338.33312724914975, + 217.765472615634, + 492.6355937339217, + 207.42892547525588, + 20.460423078094042, + 0, + 0, + 3.7245122738576018, + 9.330372140702387, + 8.59861013012241, + 6.889192934888112, + 1.65630126551221, + 3.4860172130716682, + 0, + 0, + 31.356348699041565, + 56.24607168728871, + 42.032012429943606, + 70.7006303353718, + 11.800240826510663, + 3.63863248826485, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 120.4296955988969, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 157.57040405759912, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1383.9999003434998, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1661.9999999999927, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 136.81015909473734, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 154.9111238326602, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1370.2787170725978, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1412.5410575754197, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 33.685005958154385, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 215.77393646642128 + ], + "metadata": { + "derived": true, + "references": {}, + "type": { + "class": "numeric", + "integer": false, + "missing_reasons": { + "No Data": -1 + }, + "missing_rules": {} + } + }, + "n_missing": 10 + } + }, + "missing": 10, + "n": 1662 + } +} diff --git a/tests/integration/test_crunch_cube.py b/tests/integration/test_crunch_cube.py index 81e20299d..6e564baef 100644 --- a/tests/integration/test_crunch_cube.py +++ b/tests/integration/test_crunch_cube.py @@ -1,5 +1,6 @@ from unittest import TestCase import numpy as np +import pytest from cr.cube.crunch_cube import CrunchCube from cr.cube.util import compress_pruned @@ -1316,6 +1317,7 @@ def test_cat_x_cat_props_by_cell_prune_cols(self): for i, actual in enumerate(pruned): np.testing.assert_array_equal(pruned[i], pruned_expected[i]) + @pytest.mark.filterwarnings('ignore:DeprecationWarning') def test_cat_x_cat_index_by_col_prune_cols(self): cube = CrunchCube(CAT_X_CAT_WITH_EMPTY_COLS) expected = np.array([ @@ -1596,9 +1598,11 @@ def test_means_cat_x_cat_arr_pets_first(self): def test_means_with_null_values(self): cube = CrunchCube(SCALE_WITH_NULL_VALUES) - expected = [[np.array([1.2060688, 1.0669344, 1.023199]), None]] - actual = cube.scale_means() - assert_scale_means_equal(actual, expected) + scale_means = cube.scale_means() + assert_scale_means_equal( + scale_means, + [[np.array([1.2060688, 1.0669344, 1.023199]), None]] + ) def test_values_services(self): cube = CrunchCube(VALUE_SERVICES) diff --git a/tests/integration/test_dimension.py b/tests/integration/test_dimension.py index af214e96b..2f9fcaf47 100644 --- a/tests/integration/test_dimension.py +++ b/tests/integration/test_dimension.py @@ -1,23 +1,34 @@ -from unittest import TestCase +# encoding: utf-8 + +"""Integration test suite for the cr.cube.dimension module.""" -from .fixtures import ECON_BLAME_WITH_HS -from .fixtures import ECON_BLAME_WITH_HS_MISSING -from .fixtures import ECON_BLAME_X_IDEOLOGY_ROW_HS -from .fixtures import CA_WITH_NETS -from .fixtures import LOGICAL_UNIVARIATE, LOGICAL_X_CAT +import numpy as np + +from unittest import TestCase from cr.cube.crunch_cube import CrunchCube +from cr.cube.dimension import Dimension, _Subtotal + +from .fixtures import ( + CA_SUBVAR_HS_X_MR_X_CA_CAT, CA_WITH_NETS, ECON_BLAME_WITH_HS, + ECON_BLAME_WITH_HS_MISSING, ECON_BLAME_X_IDEOLOGY_ROW_HS, + LOGICAL_UNIVARIATE, LOGICAL_X_CAT, MR_X_CAT_HS +) class TestDimension(TestCase): + + def test_dimension_type(self): + cube = CrunchCube(CA_SUBVAR_HS_X_MR_X_CA_CAT) + dimension_types = [d.dimension_type for d in cube.dimensions] + assert dimension_types == [ + 'categorical_array', 'multiple_response', 'categorical' + ] + def test_subtotals_indices_single_subtotal(self): dimension = CrunchCube(ECON_BLAME_WITH_HS).dimensions[0] - expected = [{ - 'anchor_ind': 1, - 'inds': [0, 1] - }] - actual = dimension.hs_indices - self.assertEqual(actual, expected) + hs_indices = dimension.hs_indices + self.assertEqual(hs_indices, ((1, (0, 1)),)) def test_inserted_hs_indices_single_subtotal(self): dimension = CrunchCube(ECON_BLAME_WITH_HS).dimensions[0] @@ -27,17 +38,83 @@ def test_inserted_hs_indices_single_subtotal(self): actual = dimension.inserted_hs_indices assert actual == expected + def test_labels_for_categoricals(self): + dimension_dict = { + 'type': { + 'class': 'categorical', + 'categories': [ + { + 'id': 1, + 'name': 'Cat', + 'missing': False, + }, + { + 'id': 2, + 'name': 'Mouse', + 'missing': False, + }, + { + 'id': -1, + 'name': 'Iguana', + 'missing': True, + }, + ] + } + } + dimension = Dimension(dimension_dict) + + # ---get only non-missing--- + labels = dimension.labels() + assert labels == ['Cat', 'Mouse'] + + # ---get all--- + labels = dimension.labels(include_missing=True) + assert labels == ['Cat', 'Mouse', 'Iguana'] + + def test_labels_for_numericals(self): + dimension_dict = { + 'type': { + 'class': 'enum', + 'elements': [ + { + 'id': 0, + 'value': 'smallish', + 'missing': False + }, + { + 'id': 1, + 'value': 'kinda big', + 'missing': False + }, + { + 'id': 2, + 'value': {}, + 'missing': True + } + ], + 'subtype': { + 'class': 'numeric' + } + } + } + dimension = Dimension(dimension_dict) + + # ---non-missing labels--- + labels = dimension.labels() + assert labels == ['smallish', 'kinda big'] + + # ---all labels, both valid and missing--- + labels = dimension.labels(include_missing=True) + assert labels == ['smallish', 'kinda big', ''] + + # ---all labels, both valid and missing--- + labels = dimension.labels(include_cat_ids=True) + assert labels == [('smallish', 0), ('kinda big', 1)] + def test_subtotals_indices_two_subtotals(self): dimension = CrunchCube(ECON_BLAME_WITH_HS_MISSING).dimensions[0] - expected = [{ - 'anchor_ind': 1, - 'inds': [0, 1] - }, { - 'anchor_ind': 'bottom', - 'inds': [3, 4, 5] - }] - actual = dimension.hs_indices - self.assertEqual(actual, expected) + hs_indices = dimension.hs_indices + self.assertEqual(hs_indices, ((1, (0, 1)), ('bottom', (3, 4)))) def test_inserted_hs_indices_two_subtotals(self): dimension = CrunchCube(ECON_BLAME_WITH_HS_MISSING).dimensions[0] @@ -47,6 +124,88 @@ def test_inserted_hs_indices_two_subtotals(self): actual = dimension.inserted_hs_indices self.assertEqual(actual, expected) + def test_inserted_hs_indices_order_and_labels(self): + dimension_dict = { + 'references': { + 'view': { + 'transform': { + 'insertions': [ + { + 'anchor': 'bottom', + 'args': [111], + 'function': 'subtotal', + 'name': 'bottoms up one', + }, + { + 'anchor': 'bottom', + 'args': [222], + 'function': 'subtotal', + 'name': 'bottoms up two', + }, + { + 'anchor': 'bottom', + 'args': [333], + 'function': 'subtotal', + 'name': 'bottoms up three', + }, + { + 'anchor': 'top', + 'args': [444], + 'function': 'subtotal', + 'name': 'on top one', + }, + { + 'anchor': 'top', + 'args': [555], + 'function': 'subtotal', + 'name': 'on top two', + }, + { + 'anchor': 333, + 'args': [555], + 'function': 'subtotal', + 'name': 'in the middle one', + }, + { + 'anchor': 333, + 'args': [555], + 'function': 'subtotal', + 'name': 'in the middle two', + } + ] + } + } + }, + 'type': { + "categories": [ + { + "id": 111, + }, + { + "id": 222, + }, + { + "id": 333, + }, + { + "id": 444, + }, + { + "id": 555, + } + ], + "class": "categorical" + } + } + dimension = Dimension(dimension_dict) + + assert dimension.inserted_hs_indices == [0, 1, 5, 6, 9, 10, 11] + assert dimension.labels(include_transforms=True) == [ + 'on top one', 'on top two', '', '', '', + 'in the middle one', 'in the middle two', '', '', + 'bottoms up one', 'bottoms up two', 'bottoms up three' + ] + def test_has_transforms_false(self): dimension = CrunchCube( ECON_BLAME_X_IDEOLOGY_ROW_HS @@ -63,31 +222,193 @@ def test_has_transforms_true(self): actual = dimension.has_transforms self.assertEqual(actual, expected) + def test_hs_indices_for_mr(self): + dimension = CrunchCube(MR_X_CAT_HS).all_dimensions[1] + hs_indices = dimension.hs_indices + assert hs_indices == () + def test_hs_indices_with_bad_data(self): cube = CrunchCube(CA_WITH_NETS) - expected = ['bottom', 'bottom'] - ca_dim = cube.dimensions[0] - actual = [entry['anchor_ind'] for entry in ca_dim.hs_indices] - assert actual == expected + subvar_dim = cube.dimensions[0] + anchor_idxs = [anchor_idx for anchor_idx, _ in subvar_dim.hs_indices] + assert anchor_idxs == ['bottom', 'bottom'] cat_dim = cube.dimensions[1] - actual = [entry['anchor_ind'] for entry in cat_dim.hs_indices] - assert actual == expected + anchor_idxs = [anchor_idx for anchor_idx, _ in cat_dim.hs_indices] + assert anchor_idxs == ['bottom', 'bottom'] + + def test_skips_bad_data_for_hs_indices(self): + """Test H&S indices with bad input data. + + This test ensures that H&S functionality doesn't break if it + encounters bad transformations data, as is possible with some of the + leftovers in the variables. + """ + dimension_dict = { + 'references': { + 'view': { + 'transform': { + 'insertions': [ + { + 'anchor': 101, + 'name': 'This is respondent ideology', + }, + { + 'anchor': 2, + 'args': [1, 2], + 'function': 'subtotal', + 'name': 'Liberal net', + }, + { + 'anchor': 5, + 'args': [5, 4], + 'function': 'subtotal', + 'name': 'Conservative net', + }, + { + 'anchor': 'fake anchor', + 'args': ['fake_arg_1', 'fake_arg_2'], + 'function': 'fake_fcn_name_not_subtotal', + 'name': 'Fake Name', + } + ] + } + } + }, + 'type': { + 'categories': [ + { + 'numeric_value': 1, + 'id': 1, + 'name': 'President Obama', + 'missing': False + }, + { + 'numeric_value': 2, + 'id': 2, + 'name': 'Republicans in Congress', + 'missing': False + }, + { + 'numeric_value': 5, + 'id': 5, + 'name': 'Not sure', + 'missing': False + }, + { + 'numeric_value': 4, + 'id': 4, + 'name': 'Neither', + 'missing': False + } + ], + 'class': 'categorical', + 'ordinal': False + } + } + dimension = Dimension(dimension_dict) + + hs_indices = dimension.hs_indices + + print('hs_indices == %s' % [hs_indices]) + assert hs_indices == ((1, (0, 1)), (2, (2, 3))) def test_logical_univariate_dim(self): cube = CrunchCube(LOGICAL_UNIVARIATE) dimension = cube.dimensions[0] expected = 'categorical' - actual = dimension.type + actual = dimension.dimension_type self.assertEqual(expected, actual) self.assertFalse(dimension.is_mr_selections(cube.all_dimensions)) def test_logical_x_cat_dims(self): cube = CrunchCube(LOGICAL_X_CAT) logical_dim = cube.dimensions[1] - self.assertEqual(cube.dimensions[0].type, 'categorical') - self.assertEqual(logical_dim.type, 'categorical') + self.assertEqual(cube.dimensions[0].dimension_type, 'categorical') + self.assertEqual(logical_dim.dimension_type, 'categorical') self.assertTrue(logical_dim.is_selections) self.assertFalse(logical_dim.is_mr_selections(cube.all_dimensions)) + + def test_subtotals(self): + dimension_dict = { + 'references': { + 'view': { + 'transform': { + 'insertions': [ + { + 'anchor': 101, + 'name': 'This is respondent ideology', + }, + { + 'anchor': 2, + 'args': [1, 2], + 'function': 'subtotal', + 'name': 'Liberal net', + }, + { + 'anchor': 5, + 'args': [5, 4], + 'function': 'subtotal', + 'name': 'Conservative net', + }, + { + 'anchor': 'fake anchor', + 'args': ['fake_arg_1', 'fake_arg_2'], + 'function': 'fake_fcn_name_not_subtotal', + 'name': 'Fake Name', + } + ] + } + } + }, + 'type': { + 'categories': [ + {'id': 1}, + {'id': 5}, + {'id': 8}, + {'id': 9}, + {'id': -1}, + ], + 'class': 'categorical' + } + } + dimension = Dimension(dimension_dict) + + subtotals = dimension._subtotals + + assert len(subtotals) == 2 + + subtotal = subtotals[0] + assert isinstance(subtotal, _Subtotal) + assert subtotal.anchor == 'bottom' + assert subtotal.addend_ids == (1,) + assert subtotal.addend_idxs == (0,) + assert subtotal.label == 'Liberal net' + + subtotal = subtotals[1] + assert isinstance(subtotal, _Subtotal) + assert subtotal.anchor == 5 + assert subtotal.addend_ids == (5,) + assert subtotal.addend_idxs == (1,) + assert subtotal.label == 'Conservative net' + + def test_numeric_values(self): + dimension_dict = { + 'type': { + 'categories': [ + {'id': 42, 'missing': False, 'numeric_value': 1}, + {'id': 43, 'missing': False, 'numeric_value': 2}, + {'id': 44, 'missing': True, 'numeric_value': 3}, + {'id': 45, 'missing': False, 'numeric_value': None}, + {'id': 46, 'missing': False} + ], + 'class': 'categorical' + } + } + dimension = Dimension(dimension_dict) + + numeric_values = dimension.numeric_values + + self.assertEqual(numeric_values, (1, 2, np.nan, np.nan)) diff --git a/tests/integration/test_headers_and_subtotals.py b/tests/integration/test_headers_and_subtotals.py index 8f779b535..1dd40f436 100644 --- a/tests/integration/test_headers_and_subtotals.py +++ b/tests/integration/test_headers_and_subtotals.py @@ -100,7 +100,7 @@ def test_subtotals_as_array_one_transform_do_not_fetch(self): def test_subtotals_as_array_two_transforms_missing_excluded(self): cube = CrunchCube(ECON_BLAME_WITH_HS_MISSING) - expected = np.array([285, 396, 681, 242, 6, 68, 77]) + expected = np.array([285, 396, 681, 242, 6, 68, 74]) actual = cube.as_array(include_transforms_for_dims=[0]) np.testing.assert_array_equal(actual, expected) @@ -129,7 +129,7 @@ def test_subtotals_proportions_two_transforms_missing_excluded(self): .2427282, .0060181, .0682046, - .0772317, + .0742227, ]) actual = cube.proportions(include_transforms_for_dims=[0]) np.testing.assert_almost_equal(actual, expected) diff --git a/tests/unit/test_crunch_cube.py b/tests/unit/test_crunch_cube.py index e0330bbba..8da88346d 100644 --- a/tests/unit/test_crunch_cube.py +++ b/tests/unit/test_crunch_cube.py @@ -1,12 +1,38 @@ -'''Unit tests for the CrunchCube class.''' +# encoding: utf-8 + +"""Unit test suite for the cr.cube.crunch_cube module.""" -from unittest import TestCase -from mock import Mock -from mock import patch import numpy as np +import pytest + +from unittest import TestCase from cr.cube.crunch_cube import CrunchCube +from ..unitutil import Mock, patch + + +class DescribeCrunchCube(object): + + def it_knows_whether_cube_contains_means_data(self, has_means_fixture): + cube_response, expected_value = has_means_fixture + cube = CrunchCube(cube_response) + + has_means = cube.has_means + + assert has_means is expected_value + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ({'result': {}}, False), + ({'result': {'measures': {}}}, False), + ({'result': {'measures': {'mean': {}}}}, True), + ]) + def has_means_fixture(self, request): + cube_response, expected_value = request.param + return cube_response, expected_value + # pylint: disable=invalid-name, no-self-use, protected-access @patch('cr.cube.crunch_cube.CrunchCube.get_slices', lambda x: None) @@ -199,13 +225,6 @@ def test_missing_with_means(self): actual = CrunchCube(fake_cube).missing self.assertEqual(actual, expected) - def test_has_means(self): - has_means = Mock() - with patch('cr.cube.crunch_cube.CrunchCube.has_means', has_means): - expected = has_means - actual = CrunchCube({}).has_means - self.assertEqual(actual, expected) - def test_test_filter_annotation(self): mock_cube = {'filter_names': Mock()} expected = mock_cube['filter_names'] @@ -324,21 +343,6 @@ def test_margin_pruned_indices_with_insertions_and_nans(self): actual = CrunchCube._margin_pruned_indices(table, insertions, 0) np.testing.assert_array_equal(actual, expected) - def test_insertions_with_empty_indices(self): - cc = CrunchCube({}) - - class DummyDimension: - @property - def hs_indices(self): - return [{'anchor_ind': 0, 'inds': []}] - - result = Mock() - dimension_index = 0 - dimension = DummyDimension() - - insertions = cc._insertions(result, dimension, dimension_index) - assert insertions == [], insertions - @patch('numpy.array') @patch('cr.cube.crunch_cube.CrunchCube.inserted_hs_indices') @patch('cr.cube.crunch_cube.CrunchCube.ndim', 1) @@ -362,8 +366,8 @@ def test_inserted_inds(self, mock_inserted_hs_indices, mock_inserted_hs_indices.assert_called_once() @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_adjust_axis_cat_x_cat(self): '''Test axes for CAT x CAT.''' @@ -388,9 +392,9 @@ def test_adjust_axis_cat_x_cat(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='categorical', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_adjust_axis_cat_x_cat_x_cat(self): '''Test axes for CAT x CAT.''' @@ -420,8 +424,8 @@ def test_adjust_axis_cat_x_cat_x_cat(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='multiple_response'), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='multiple_response'), + Mock(dimension_type='categorical', is_selections=True), ]) def test_adjust_axis_univariate_mr(self): '''Test axes for univariate MR.''' @@ -439,9 +443,9 @@ def test_adjust_axis_univariate_mr(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='multiple_response'), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='multiple_response'), + Mock(dimension_type='categorical', is_selections=True), ]) def test_adjust_axis_cat_x_mr(self): '''Test axes for CAT x MR.''' @@ -466,9 +470,9 @@ def test_adjust_axis_cat_x_mr(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), ]) def test_adjust_axis_mr_x_cat_x_cat(self): '''Test axes for MR x CAT x CAT.''' @@ -493,10 +497,10 @@ def test_adjust_axis_mr_x_cat_x_cat(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), ]) def test_adjust_axis_mr_x_mr(self): '''Test axes for MR x MR.''' @@ -521,11 +525,11 @@ def test_adjust_axis_mr_x_mr(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), ]) def test_adjust_axis_cat_mr_x_mr(self): '''Test axes for CAT x MR x MR.''' @@ -555,11 +559,11 @@ def test_adjust_axis_cat_mr_x_mr(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='categorical', is_selections=False), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), ]) def test_adjust_axis_mr_x_cat_x_mr(self): '''Test axes for MR x CAT x MR.''' @@ -589,11 +593,11 @@ def test_adjust_axis_mr_x_cat_x_mr(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), ]) def test_adjust_axis_mr_x_mr_cat(self): '''Test axes for MR x MR x CAT.''' @@ -623,8 +627,8 @@ def test_adjust_axis_mr_x_mr_cat(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_adjust_axis_simple_ca(self): '''Test axes for simple CA.''' @@ -647,9 +651,9 @@ def test_adjust_axis_simple_ca(self): adjust((0, 1)) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_adjust_axis_ca_x_cat(self): '''Test axes for CA x CAT.''' @@ -679,9 +683,9 @@ def test_adjust_axis_ca_x_cat(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_adjust_axis_cat_x_ca(self): '''Test axes for CAT x CA.''' @@ -704,10 +708,10 @@ def test_adjust_axis_cat_x_ca(self): adjust((1, 2)) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), ]) def test_adjust_axis_ca_x_mr(self): '''Test axes for CAT x MR.''' @@ -738,10 +742,10 @@ def test_adjust_axis_ca_x_mr(self): assert expected == actual @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_adjust_axis_mr_x_cat_x_ca(self): '''Test axes for MR x CAT x CA.''' @@ -773,8 +777,8 @@ def test_adjust_axis_mr_x_cat_x_ca(self): adjust((1, 2)) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_axis_allowed_simple_ca(self): cc = CrunchCube({}) @@ -785,9 +789,9 @@ def test_axis_allowed_simple_ca(self): assert is_allowed(None) is False @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_axis_allowed_ca_x_cat(self): cc = CrunchCube({}) @@ -800,9 +804,9 @@ def test_axis_allowed_ca_x_cat(self): assert is_allowed((1, 2)) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_axis_allowed_cat_x_ca(self): cc = CrunchCube({}) @@ -815,10 +819,10 @@ def test_axis_allowed_cat_x_ca(self): assert is_allowed((1, 2)) is False @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_axis_allowed_mr_x_ca(self): cc = CrunchCube({}) @@ -831,10 +835,10 @@ def test_axis_allowed_mr_x_ca(self): assert is_allowed((1, 2)) is False @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=False), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), ]) def test_axis_allowed_ca_x_mr(self): cc = CrunchCube({}) @@ -847,7 +851,7 @@ def test_axis_allowed_ca_x_mr(self): assert is_allowed((1, 2)) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_axis_allowed_univ_cat(self): cc = CrunchCube({}) @@ -857,8 +861,8 @@ def test_axis_allowed_univ_cat(self): assert is_allowed(None) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=False), ]) def test_axis_allowed_cat_x_cat(self): cc = CrunchCube({}) @@ -870,11 +874,11 @@ def test_axis_allowed_cat_x_cat(self): assert is_allowed((0, 1)) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), ]) def test_axis_allowed_cat_x_mr_x_mr(self): cc = CrunchCube({}) @@ -887,11 +891,11 @@ def test_axis_allowed_cat_x_mr_x_mr(self): assert is_allowed((1, 2)) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='categorical', is_selections=False), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), ]) def test_axis_allowed_mr_x_cat_x_mr(self): cc = CrunchCube({}) @@ -904,11 +908,11 @@ def test_axis_allowed_mr_x_cat_x_mr(self): assert is_allowed((1, 2)) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='multiple_response', is_selections=False), - Mock(type='categorical', is_selections=True), - Mock(type='categorical', is_selections=False), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='multiple_response', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), ]) def test_axis_allowed_mr_x_mr_x_cat(self): cc = CrunchCube({}) @@ -921,8 +925,8 @@ def test_axis_allowed_mr_x_mr_x_cat(self): assert is_allowed((1, 2)) @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical_array', is_selections=False), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='categorical_array', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), ]) def test_ca_dim_ind_is_zero(self): cc = CrunchCube({}) @@ -931,8 +935,8 @@ def test_ca_dim_ind_is_zero(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='categorical_array', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical_array', is_selections=True), ]) def test_ca_dim_ind_is_one(self): cc = CrunchCube({}) @@ -941,8 +945,8 @@ def test_ca_dim_ind_is_one(self): assert actual == expected @patch('cr.cube.crunch_cube.CrunchCube.all_dimensions', [ - Mock(type='categorical', is_selections=False), - Mock(type='categorical', is_selections=True), + Mock(dimension_type='categorical', is_selections=False), + Mock(dimension_type='categorical', is_selections=True), ]) def test_ca_dim_ind_is_none(self): cc = CrunchCube({}) diff --git a/tests/unit/test_dimension.py b/tests/unit/test_dimension.py index db69f7c03..89672062d 100644 --- a/tests/unit/test_dimension.py +++ b/tests/unit/test_dimension.py @@ -1,509 +1,711 @@ +# encoding: utf-8 + +"""Unit test suite for cr.cube.dimension module.""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + import numpy as np +import pytest -from mock import Mock -from mock import patch -from unittest import TestCase -from cr.cube.dimension import Dimension, _Subtotal - - -class TestDimension(TestCase): - - insertions_with_bad_data = [ - { - u'anchor': 101, - u'name': u'This is respondent ideology', - }, - { - u'anchor': 2, - u'args': [1, 2], - u'function': u'subtotal', - u'name': u'Liberal net', - }, - { - u'anchor': 5, - u'args': [5, 4], - u'function': u'subtotal', - u'name': u'Conservative net', - }, - { - u'anchor': 'fake anchor', - u'args': ['fake_arg_1', 'fake_arg_2'], - u'function': u'fake_fcn_name_not_subtotal', - u'name': u'Fake Name', - } - ] +from cr.cube.dimension import ( + _AllElements, _BaseElement, _BaseElements, _Category, Dimension, + _Element, _Subtotal, _Subtotals, _ValidElements +) - def test_get_type_categorical_array(self): - dim = { - 'references': {'subreferences': []}, - 'type': {'class': 'enum'}, - } - expected = 'categorical_array' - actual = Dimension._get_type(dim) - self.assertEqual(actual, expected) +from ..unitutil import ( + call, class_mock, instance_mock, method_mock, property_mock +) - def test_get_type_categorical(self): - dim = { - 'type': {'class': 'categorical'}, - } - expected = 'categorical' - actual = Dimension._get_type(dim) - self.assertEqual(actual, expected) - def test_get_type_numeric(self): - dim = { - 'type': {'subtype': {'class': 'numeric'}}, - } - expected = 'numeric' - actual = Dimension._get_type(dim) - self.assertEqual(actual, expected) - - def test_get_type_datetime(self): - dim = { - 'type': {'subtype': {'class': 'datetime'}}, - 'class': 'enum', - } - expected = 'datetime' - actual = Dimension._get_type(dim) - self.assertEqual(actual, expected) +class DescribeDimension(object): + + def it_knows_its_description(self, description_fixture): + dimension_dict, expected_value = description_fixture + dimension = Dimension(dimension_dict) + + description = dimension.description + + assert description == expected_value + + def it_knows_its_dimension_type(self, type_fixture): + dimension_dict, next_dimension_dict, expected_value = type_fixture + dimension = Dimension(dimension_dict, next_dimension_dict) + + dimension_type = dimension.dimension_type + + assert dimension_type == expected_value + + def it_provides_subtotal_indices( + self, hs_indices_fixture, is_selections_prop_, _subtotals_prop_): + is_selections, subtotals_, expected_value = hs_indices_fixture + is_selections_prop_.return_value = is_selections + _subtotals_prop_.return_value = subtotals_ + dimension = Dimension(None, None) + + hs_indices = dimension.hs_indices + + assert hs_indices == expected_value + + def it_knows_the_numeric_values_of_its_elements( + self, request, _valid_elements_prop_): + _valid_elements_prop_.return_value = tuple( + instance_mock(request, _BaseElement, numeric_value=numeric_value) + for numeric_value in (1, 2.2, np.nan) + ) + dimension = Dimension(None, None) + + numeric_values = dimension.numeric_values - def test_get_type_text(self): - dim = { - 'type': {'subtype': {'class': 'text'}}, + assert numeric_values == (1, 2.2, np.nan) + + def it_provides_access_to_its_subtotals_to_help( + self, subtotals_fixture, _Subtotals_, subtotals_, + _valid_elements_prop_, valid_elements_): + dimension_dict, insertion_dicts = subtotals_fixture + _valid_elements_prop_.return_value = valid_elements_ + _Subtotals_.return_value = subtotals_ + dimension = Dimension(dimension_dict, None) + + subtotals = dimension._subtotals + + _Subtotals_.assert_called_once_with(insertion_dicts, valid_elements_) + assert subtotals is subtotals_ + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ({'references': {}}, ''), + ({'references': {'description': None}}, ''), + ({'references': {'description': ''}}, ''), + ({'references': {'description': 'Crunchiness'}}, 'Crunchiness'), + ]) + def description_fixture(self, request): + dimension_dict, expected_value = request.param + return dimension_dict, expected_value + + @pytest.fixture(params=[ + (True, ()), + (False, ((0, (1, 2)), (3, (4, 5)))), + ]) + def hs_indices_fixture(self, request): + is_selections, expected_value = request.param + subtotals_ = ( + instance_mock( + request, _Subtotal, anchor_idx=idx * 3, + addend_idxs=(idx + 1 + (2 * idx), idx + 2 + (2 * idx)) + ) for idx in range(2) + ) + return is_selections, subtotals_, expected_value + + @pytest.fixture(params=[ + ({}, []), + ({'references': {}}, []), + ({'references': {'view': {}}}, []), + ({'references': {'view': {'transform': {}}}}, []), + ({'references': {'view': {'transform': {'insertions': []}}}}, []), + ({'references': {'view': {'transform': {'insertions': [ + {'insertion': 'dict-1'}, + {'insertion': 'dict-2'}]}}}}, + [ + {'insertion': 'dict-1'}, + {'insertion': 'dict-2'}]) + ]) + def subtotals_fixture(self, request): + dimension_dict, insertion_dicts = request.param + dimension_dict['type'] = { + 'class': 'categorical', + 'categories': [], } - expected = 'text' - actual = Dimension._get_type(dim) - self.assertEqual(actual, expected) - - def test_labels_for_categoricals(self): - name_cat_1 = Mock() - name_cat_2 = Mock() - name_cat_3 = Mock() - dim = { - 'type': { - 'class': 'categorical', - 'categories': [ - { - 'name': name_cat_1, - 'missing': False, - }, - { - 'name': name_cat_2, - 'missing': False, - }, - { - 'name': name_cat_3, - 'missing': True, - }, - ] - } + return dimension_dict, insertion_dicts + + # fixture components --------------------------------------------- + + @pytest.fixture + def is_selections_prop_(self, request): + return property_mock(request, Dimension, 'is_selections') + + @pytest.fixture + def _Subtotals_(self, request): + return class_mock(request, 'cr.cube.dimension._Subtotals') + + @pytest.fixture + def subtotals_(self, request): + return instance_mock(request, _Subtotals) + + @pytest.fixture + def _subtotals_prop_(self, request): + return property_mock(request, Dimension, '_subtotals') + + @pytest.fixture(params=[ + ({'type': {'class': 'categorical'}}, + None, 'categorical'), + ({'type': {'class': 'enum', 'subtype': {'class': 'datetime'}}, + 'references': {}}, + None, 'datetime'), + ({'type': {'class': 'enum'}, 'references': {'subreferences': []}}, + None, 'categorical_array'), + ({'type': {'class': 'enum'}, 'references': {'subreferences': []}}, + {'type': {'categories': [{'id': 1}, {'id': 0}, {'id': -1}, ]}}, + 'multiple_response'), + ({'type': {'subtype': {'class': 'numeric'}}}, + None, 'numeric'), + ({'type': {'subtype': {'class': 'text'}}}, + None, 'text'), + ]) + def type_fixture(self, request): + dimension_dict, next_dimension_dict, expected_value = request.param + return dimension_dict, next_dimension_dict, expected_value + + @pytest.fixture + def valid_elements_(self, request): + return instance_mock(request, _ValidElements) + + @pytest.fixture + def _valid_elements_prop_(self, request): + return property_mock(request, Dimension, '_valid_elements') + + +class Describe_BaseElements(object): + + def it_has_sequence_behaviors(self, request, _elements_prop_): + _elements_prop_.return_value = (1, 2, 3) + elements = _BaseElements(None) + + assert elements[1] == 2 + assert elements[1:3] == (2, 3) + assert len(elements) == 3 + assert list(n for n in elements) == [1, 2, 3] + + def it_knows_the_element_ids(self, request, _elements_prop_): + _elements_prop_.return_value = tuple( + instance_mock(request, _BaseElement, element_id=n) + for n in (1, 2, 5) + ) + elements = _BaseElements(None) + + element_ids = elements.element_ids + + assert element_ids == (1, 2, 5) + + def it_knows_the_element_indices(self, request, _elements_prop_): + _elements_prop_.return_value = tuple( + instance_mock(request, _BaseElement, index=index) + for index in (1, 3, 4) + ) + elements = _BaseElements(None) + + element_idxs = elements.element_idxs + + assert element_idxs == (1, 3, 4) + + def it_can_find_an_element_by_id(self, request, _elements_by_id_prop_): + elements_ = tuple( + instance_mock(request, _BaseElement, element_id=element_id) + for element_id in (3, 7, 11) + ) + _elements_by_id_prop_.return_value = { + element_.element_id: element_ for element_ in elements_ } - # Get only non-missing - expected = [name_cat_1, name_cat_2] - actual = Dimension(dim).labels() - self.assertEqual(actual, expected) - # Get all - expected = [name_cat_1, name_cat_2, name_cat_3] - actual = Dimension(dim).labels(include_missing=True) - self.assertEqual(actual, expected) - - def test_labels_for_numericals(self): - val_num_1 = 'fake val 1' - val_num_2 = 'fake val 2' - val_num_3 = {} - dim = { - 'type': { - "subtype": { - "class": "numeric" - }, - "elements": [ - { - "id": 0, - "value": val_num_1, - "missing": False, - }, - { - "id": 1, - "value": val_num_2, - "missing": False, - }, - { - "id": 2, - "value": val_num_3, - "missing": True, - } - ], - } + elements = _BaseElements(None) + + element = elements.get_by_id(7) + + assert element is elements_[1] + + def it_provides_element_factory_inputs_to_help(self, makings_fixture): + type_dict, expected_element_class = makings_fixture[:2] + expected_element_dicts = makings_fixture[2] + elements = _BaseElements(type_dict) + + ElementCls, element_dicts = elements._element_makings + + assert ElementCls == expected_element_class + assert element_dicts == expected_element_dicts + + def it_maintains_a_dict_of_elements_by_id_to_help( + self, request, _elements_prop_): + elements_ = tuple( + instance_mock(request, _BaseElement, element_id=element_id) + for element_id in (4, 6, 7) + ) + _elements_prop_.return_value = elements_ + elements = _BaseElements(None) + + elements_by_id = elements._elements_by_id + + assert elements_by_id == { + 4: elements_[0], + 6: elements_[1], + 7: elements_[2], } - # Get only non-missing - expected = [val_num_1, val_num_2] - actual = Dimension(dim).labels() - self.assertEqual(actual, expected) - # Get all - expected = [val_num_1, val_num_2, None] - actual = Dimension(dim).labels(include_missing=True) - self.assertEqual(actual, expected) - - def test_is_not_multiple_response(self): - expected = False - actual = Dimension._is_multiple_response({'type': {'fake': Mock()}}) - self.assertEqual(actual, expected) - - def test_get_name_from_element_name(self): - name = Mock() - expected = name - actual = Dimension._get_name({'name': name}) - self.assertEqual(actual, expected) - - def test_get_name_from_element_list_vals(self): - list_vals = [1.2, 3.4] - expected = '-'.join(str(el) for el in list_vals) - actual = Dimension._get_name({'value': list_vals}) - self.assertEqual(actual, expected) - - def test_get_name_from_element_numeric_value(self): - num_val = 1.2 - expected = str(num_val) - actual = Dimension._get_name({'value': num_val}) - self.assertEqual(actual, expected) - - def test_get_name_none(self): - expected = None - actual = Dimension._get_name({}) - self.assertEqual(actual, expected) - - def test_dimension_description(self): - desc = Mock() - dim = Dimension({'type': Mock(), 'references': {'description': desc}}) - expected = desc - actual = dim.description - self.assertEqual(actual, expected) - - @patch('cr.cube.dimension.Dimension._elements', [ - {'id': 1}, {'id': 2}, {'id': 5}, {'id': 4} + + def it_stores_its_elements_in_a_tuple_to_help(self): + base_elements = _BaseElements(None) + # ---must be implemented by each subclass--- + with pytest.raises(NotImplementedError): + base_elements._elements + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ({'class': 'categorical', 'categories': ['cat', 'dicts']}, + _Category, ['cat', 'dicts']), + ({'class': 'enum', 'elements': ['element', 'dicts']}, + _Element, ['element', 'dicts']), ]) - @patch('cr.cube.dimension._Subtotal._all_dim_ids', [1, 2, 4, 5]) - @patch('cr.cube.dimension.Dimension._get_type') - def test_hs_names_with_bad_data(self, mock_type): - '''Test H&S names with bad input data. - - This test ensures that H&S functionality doesn't break if it encounters - bad transformations data, as is possible with some of the leftovers in - the variables. - ''' - mock_type.return_value = None - insertions_with_bad_data = [ - { - u'anchor': 0, - u'name': u'This is respondent ideology', - }, - { - u'anchor': 2, - u'args': [1, 2], - u'function': u'subtotal', - u'name': u'Liberal net', - }, - { - u'anchor': 5, - u'args': [5, 4], - u'function': u'subtotal', - u'name': u'Conservative net', - } + def makings_fixture(self, request): + type_dict, expected_element_cls, expected_element_dicts = request.param + return type_dict, expected_element_cls, expected_element_dicts + + # fixture components --------------------------------------------- + + @pytest.fixture + def _elements_by_id_prop_(self, request): + return property_mock(request, _BaseElements, '_elements_by_id') + + @pytest.fixture + def _elements_prop_(self, request): + return property_mock(request, _BaseElements, '_elements') + + +class Describe_AllElements(object): + + def it_provides_access_to_the_ValidElements_object( + self, request, _elements_prop_, _ValidElements_, valid_elements_): + elements_ = tuple( + instance_mock(request, _BaseElement, name='el%s' % idx) + for idx in range(3) + ) + _elements_prop_.return_value = elements_ + _ValidElements_.return_value = valid_elements_ + all_elements = _AllElements(None) + + valid_elements = all_elements.valid_elements + + _ValidElements_.assert_called_once_with(elements_) + assert valid_elements is valid_elements_ + + def it_creates_its_Element_objects_in_its_local_factory( + self, request, _element_makings_prop_, _BaseElement_): + element_dicts_ = ( + {'element': 'dict-A'}, + {'element': 'dict-B'}, + {'element': 'dict-C'}, + ) + elements_ = tuple( + instance_mock(request, _BaseElement, name='element-%s' % idx) + for idx in range(3) + ) + _element_makings_prop_.return_value = _BaseElement_, element_dicts_ + _BaseElement_.side_effect = iter(elements_) + all_elements = _AllElements(None) + + elements = all_elements._elements + + assert _BaseElement_.call_args_list == [ + call({'element': 'dict-A'}, 0), + call({'element': 'dict-B'}, 1), + call({'element': 'dict-C'}, 2), ] - transform_data = { - 'references': { - 'view': { - 'transform': {'insertions': insertions_with_bad_data} - } - } - } - dim = Dimension(transform_data) - actual = dim.subtotals - actual_anchors = [st.anchor for st in actual] - self.assertEqual(actual_anchors, [2, 5]) + assert elements == (elements_[0], elements_[1], elements_[2]) + + # fixture components --------------------------------------------- + + @pytest.fixture + def _BaseElement_(self, request): + return class_mock(request, 'cr.cube.dimension._BaseElement') + + @pytest.fixture + def _element_makings_prop_(self, request): + return property_mock(request, _AllElements, '_element_makings') + + @pytest.fixture + def _elements_prop_(self, request): + return property_mock(request, _AllElements, '_elements') + + @pytest.fixture + def _ValidElements_(self, request): + return class_mock(request, 'cr.cube.dimension._ValidElements') + + @pytest.fixture + def valid_elements_(self, request): + return instance_mock(request, _ValidElements) + + +class Describe_ValidElements(object): + + def it_gets_its_Element_objects_from_an_AllElements_object( + self, request, all_elements_): + elements_ = tuple( + instance_mock( + request, _BaseElement, + name='element-%s' % idx, + missing=missing + ) + for idx, missing in enumerate([False, True, False]) + ) + all_elements_.__iter__.return_value = iter(elements_) + valid_elements = _ValidElements(all_elements_) + + elements = valid_elements._elements + + assert elements == (elements_[0], elements_[2]) + + # fixture components --------------------------------------------- + + @pytest.fixture + def all_elements_(self, request): + return instance_mock(request, _AllElements) + + +class Describe_BaseElement(object): + + def it_knows_its_element_id(self): + element_dict = {'id': 42} + element = _BaseElement(element_dict, None) + + element_id = element.element_id + + assert element_id == 42 + + def it_knows_its_position_among_all_the_dimension_elements(self): + element = _BaseElement(None, 17) + index = element.index + assert index == 17 + + def it_knows_whether_its_missing_or_valid(self, missing_fixture): + element_dict, expected_value = missing_fixture + element = _BaseElement(element_dict, None) + + missing = element.missing + + # ---only True or False, no Truthy or Falsy (so use `is` not `==`)--- + assert missing is expected_value - @patch('cr.cube.dimension.Dimension._elements', [ - {'id': 1}, {'id': 2}, {'id': 5}, {'id': 4} + def it_knows_its_numeric_value(self, numeric_value_fixture): + element_dict, expected_value = numeric_value_fixture + element = _BaseElement(element_dict, None) + + numeric_value = element.numeric_value + + # ---np.nan != np.nan, but np.nan in [np.nan] works--- + assert numeric_value in [expected_value] + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ({}, False), + ({'missing': None}, False), + ({'missing': False}, False), + ({'missing': True}, True), + # ---not expected values, but just in case--- + ({'missing': 0}, False), + ({'missing': 1}, True), ]) - @patch('cr.cube.dimension.Dimension._get_type') - def test_hs_indices_with_bad_data(self, mock_type): - '''Test H&S indices with bad input data. - - This test ensures that H&S functionality doesn't break if it encounters - bad transformations data, as is possible with some of the leftovers in - the variables. - ''' - mock_type.return_value = None - dim_data = { - 'references': { - 'view': { - 'transform': {'insertions': self.insertions_with_bad_data} - } - } - } - dim = Dimension(dim_data) - expected = [ - {'anchor_ind': 1, 'inds': [0, 1]}, - {'anchor_ind': 2, 'inds': [2, 3]} - ] - actual = dim.hs_indices - self.assertEqual(actual, expected) + def missing_fixture(self, request): + element_dict, expected_value = request.param + return element_dict, expected_value + + @pytest.fixture(params=[ + ({}, np.nan), + ({'numeric_value': None}, np.nan), + ({'numeric_value': 0}, 0), + ({'numeric_value': 7}, 7), + ({'numeric_value': -3.2}, -3.2), + # ---not expected values, just to document the behavior that + # ---no attempt is made to convert values to numeric + ({'numeric_value': '666'}, '666'), + ({'numeric_value': {}}, {}), + ({'numeric_value': {'?': 8}}, {'?': 8}), + ]) + def numeric_value_fixture(self, request): + element_dict, expected_value = request.param + return element_dict, expected_value + + +class Describe_Category(object): + + def it_knows_its_label(self, label_fixture): + category_dict, expected_value = label_fixture + category = _Category(category_dict, None) - @patch('cr.cube.dimension.Dimension._elements', [ - {'id': 1}, {'id': 2}, {'id': 5}, {'id': 4} + label = category.label + + assert label == expected_value + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ({}, ''), + ({'name': ''}, ''), + ({'name': None}, ''), + ({'name': 'Bob'}, 'Bob'), + ({'name': 'Hinzufägen'}, 'Hinzufägen'), ]) - @patch('cr.cube.dimension.Dimension._get_type') - def test_hs_indices_with_empty_indices(self, mock_type): - - mock_type.return_value = None - dim_data = { - 'references': { - 'view': { - 'transform': {'insertions': [{ - "function": "subtotal", - "args": [ - 7, - 8, - 9, - 10, - 11 - ], - "anchor": "bottom", - "name": "test subtotal" - }]} - } - } - } - dim = Dimension(dim_data) - expected = [] - actual = dim.hs_indices - self.assertEqual(actual, expected) - - # pylint: disable=protected-access, missing-docstring - @patch('cr.cube.dimension.Dimension.elements') - @patch('cr.cube.dimension.Dimension._get_type') - def test_subtotals(self, mock_type, mock_elements): - mock_type.return_value = None - mock_elements.return_value = [{'id': 1}, {'id': 5}] - dim_data = { - 'references': { - 'view': { - 'transform': {'insertions': self.insertions_with_bad_data} - } - } - } - dim = Dimension(dim_data) - actual = dim.subtotals - assert len(actual) == 2 - assert isinstance(actual[0], _Subtotal) - assert actual[0]._data == self.insertions_with_bad_data[1] - assert isinstance(actual[1], _Subtotal) - assert actual[1]._data == self.insertions_with_bad_data[2] - assert actual[0].anchor == 'bottom' - assert actual[1].anchor == 5 - - @patch('cr.cube.dimension.Dimension._elements', [ - {'id': 111}, {'id': 222}, {'id': 333}, {'id': 444}, {'id': 555} + def label_fixture(self, request): + category_dict, expected_value = request.param + return category_dict, expected_value + + +class Describe_Element(object): + + def it_knows_its_label(self, label_fixture): + element_dict, expected_value = label_fixture + element = _Element(element_dict, None) + + label = element.label + + assert label == expected_value + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ({}, ''), + ({'value': ['A', 'F']}, 'A-F'), + ({'value': [1.2, 3.4]}, '1.2-3.4'), + ({'value': 42}, '42'), + ({'value': 4.2}, '4.2'), + ({'value': 'Bill'}, 'Bill'), + ({'value': 'Fähig'}, 'Fähig'), + ({'value': {'references': {}}}, ''), + ({'value': {'references': {'name': 'Tom'}}}, 'Tom'), ]) - @patch('cr.cube.dimension.Dimension._get_type') - def test_inserted_hs_indices_and_order(self, mock_type): - mock_type.return_value = None - dim_data = { - 'references': { - 'view': { - 'transform': { - 'insertions': [ - { - u'anchor': u'bottom', - u'args': [111], - u'function': u'subtotal', - u'name': u'bottoms up one', - }, - { - u'anchor': u'bottom', - u'args': [222], - u'function': u'subtotal', - u'name': u'bottoms up two', - }, - { - u'anchor': u'bottom', - u'args': [333], - u'function': u'subtotal', - u'name': u'bottoms up three', - }, - { - u'anchor': u'top', - u'args': [444], - u'function': u'subtotal', - u'name': u'on top one', - }, - { - u'anchor': u'top', - u'args': [555], - u'function': u'subtotal', - u'name': u'on top two', - }, - { - u'anchor': 333, - u'args': [555], - u'function': u'subtotal', - u'name': u'in the middle one', - }, - { - u'anchor': 333, - u'args': [555], - u'function': u'subtotal', - u'name': u'in the middle two', - } - ] - } - } - } - } - dim = Dimension(dim_data) - self.assertEqual(dim.inserted_hs_indices, [0, 1, 5, 6, 9, 10, 11]) - labels = [ - u'on top one', u'on top two', None, None, None, - u'in the middle one', u'in the middle two', None, None, - u'bottoms up one', u'bottoms up two', u'bottoms up three' - ] - self.assertEqual(labels, dim.labels(include_transforms=True)) + def label_fixture(self, request): + element_dict, expected_value = request.param + return element_dict, expected_value + + +class Describe_Subtotals(object): + + def it_has_sequence_behaviors(self, request, _subtotals_prop_): + _subtotals_prop_.return_value = (1, 2, 3) + subtotals = _Subtotals(None, None) + + assert subtotals[1] == 2 + assert subtotals[1:3] == (2, 3) + assert len(subtotals) == 3 + assert list(n for n in subtotals) == [1, 2, 3] + + def it_can_iterate_subtotals_having_a_given_anchor( + self, request, _subtotals_prop_): + subtotals_ = tuple( + instance_mock( + request, _Subtotal, name='subtotal-%d' % idx, anchor=anchor + ) + for idx, anchor in enumerate(['bottom', 2, 'bottom']) + ) + _subtotals_prop_.return_value = subtotals_ + subtotals = _Subtotals(None, None) + + subtotals_with_anchor = tuple(subtotals.iter_for_anchor('bottom')) + + assert subtotals_with_anchor == (subtotals_[0], subtotals[2]) + + def it_provides_the_element_ids_as_a_set_to_help( + self, request, valid_elements_): + valid_elements_.element_ids = tuple(range(3)) + subtotals = _Subtotals(None, valid_elements_) + + element_ids = subtotals._element_ids + + assert element_ids == {0, 1, 2} + + def it_iterates_the_valid_subtotal_insertion_dicts_to_help( + self, iter_valid_fixture, _element_ids_prop_): + insertion_dicts, element_ids, expected_value = iter_valid_fixture + _element_ids_prop_.return_value = element_ids + subtotals = _Subtotals(insertion_dicts, None) + + subtotal_dicts = tuple(subtotals._iter_valid_subtotal_dicts()) + + assert subtotal_dicts == expected_value + + def it_constructs_its_subtotal_objects_to_help( + self, request, _iter_valid_subtotal_dicts_, valid_elements_, + _Subtotal_): + subtotal_dicts_ = tuple({'subtotal-dict': idx} for idx in range(3)) + subtotal_objs_ = tuple( + instance_mock(request, _Subtotal, name='subtotal-%d' % idx) + for idx in range(3) + ) + _iter_valid_subtotal_dicts_.return_value = iter(subtotal_dicts_) + _Subtotal_.side_effect = iter(subtotal_objs_) + subtotals = _Subtotals(None, valid_elements_) - @patch('cr.cube.dimension.Dimension._elements', [ - {'numeric_value': 1}, - {'numeric_value': 2, 'missing': False}, - {'numeric_value': 3, 'missing': True}, - {'numeric_value': None}, + subtotal_objs = subtotals._subtotals + + assert _Subtotal_.call_args_list == [ + call(subtot_dict_, valid_elements_) + for subtot_dict_ in subtotal_dicts_ + ] + assert subtotal_objs == subtotal_objs_ + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ([], (), ()), + (['not-a-dict', None], (), ()), + ([{'function': 'hyperdrive'}], (), ()), + ([{'function': 'subtotal', 'arghhs': []}], (), ()), + ([{'function': 'subtotal', 'anchor': 9, 'args': [1, 2]}], (), ()), + ([{'function': 'subtotal', 'anchor': 9, 'args': [1, 2], 'name': 'A'}], + {3, 4}, ()), + ([{'function': 'subtotal', 'anchor': 9, 'args': [1, 2], 'name': 'B'}], + {1, 2, 3, 4, 5, 8, -1}, + ({'function': 'subtotal', 'anchor': 9, 'args': [1, 2], 'name': 'B'},)), + ([{'function': 'subtotal', 'anchor': 9, 'args': [1, 2], 'name': 'C'}, + {'function': 'subtotal', 'anchor': 9, 'args': [3, 4], 'name': 'Z'}, + {'function': 'subtotal', 'anchor': 9, 'args': [5, 6], 'name': 'D'}], + {1, 2, 5, 8, -1}, + ({'function': 'subtotal', 'anchor': 9, 'args': [1, 2], 'name': 'C'}, + {'function': 'subtotal', 'anchor': 9, 'args': [5, 6], 'name': 'D'})), ]) - @patch('cr.cube.dimension.Dimension._get_type') - def test_values(self, mock_type): - dim = Dimension({}) - mock_type.return_value = None - expected = [1, 2, np.nan] - actual = dim.values - self.assertEqual(actual, expected) - - @patch('cr.cube.dimension.Dimension.is_selections', True) - @patch('cr.cube.dimension.Dimension._get_type') - def test_hs_indices_for_mr(self, mock_type): - dim = Dimension({}) - expected = [] - actual = dim.hs_indices - assert actual == expected - - -class Test_Subtotal(TestCase): - - invalid_subtotal_1 = { - u'anchor': 0, - u'name': u'This is respondent ideology', - } - invalid_subtotal_2 = { - u'anchor': 2, - u'args': [1, 2], - u'function': u'fake_fcn_not_subtotal', - u'name': u'Liberal net', - } - valid_subtotal = { - u'anchor': 5, - u'args': [5, 4], - u'function': u'subtotal', - u'name': u'Conservative net', - } - - valid_subtotal_anchor_bottom = { - u'anchor': 'Bottom', - u'args': [5, 4], - u'function': u'subtotal', - u'name': u'Conservative net', - } - - def test_data(self): - subtotal = _Subtotal(self.valid_subtotal, Mock()) - expected = self.valid_subtotal - actual = subtotal.data - self.assertEqual(actual, expected) - - def test_is_invalid_when_missing_keys(self): - subtotal = _Subtotal(self.invalid_subtotal_1, Mock()) - expected = False - actual = subtotal.is_valid - self.assertEqual(actual, expected) - - def test_is_invalid_when_not_subtotal(self): - dim = Mock() - dim.elements.return_value = [{'id': 5}, {'id': 4}] - subtotal = _Subtotal(self.invalid_subtotal_2, dim) - expected = False - actual = subtotal.is_valid - self.assertEqual(actual, expected) - - def test_is_valid(self): - dim = Mock() - dim.elements.return_value = [{'id': 5}, {'id': 4}] - subtotal = _Subtotal(self.valid_subtotal, dim) - expected = True - actual = subtotal.is_valid - self.assertEqual(actual, expected) - - def test_is_invalid_when_hs_ids_not_in_dim_elements(self): - dim = Mock() - dim.elements.return_value = [{'id': 101}, {'id': 102}] - subtotal = _Subtotal(self.valid_subtotal, dim) - expected = False - actual = subtotal.is_valid - self.assertEqual(actual, expected) - - def test_anchor_on_invalid_missing_keys(self): - subtotal = _Subtotal(self.invalid_subtotal_1, Mock()) - expected = None - actual = subtotal.anchor - self.assertEqual(actual, expected) - - def test_anchor_on_invalid_not_subtotal(self): - dim = Mock() - dim.elements.return_value = [{'id': 5}, {'id': 4}] - subtotal = _Subtotal(self.invalid_subtotal_2, dim) - expected = None - actual = subtotal.anchor - self.assertEqual(actual, expected) - - @patch('cr.cube.dimension._Subtotal._all_dim_ids', [1, 3, 5]) - def test_anchor_on_valid(self): - dim = Mock() - dim.elements.return_value = [{'id': 5}, {'id': 4}] - subtotal = _Subtotal(self.valid_subtotal, dim) - expected = 5 - actual = subtotal.anchor - self.assertEqual(actual, expected) - - def test_args_on_invalid_1(self): - dim = Mock() - dim.elements.return_value = [{'id': 5}, {'id': 4}] - subtotal = _Subtotal(self.invalid_subtotal_1, dim) - expected = [] - actual = subtotal.args - self.assertEqual(actual, expected) - - def test_args_on_invalid_2(self): - dim = Mock() - dim.elements.return_value = [{'id': 5}, {'id': 4}] - subtotal = _Subtotal(self.invalid_subtotal_2, dim) - expected = [] - actual = subtotal.args - self.assertEqual(actual, expected) - - def test_args_on_valid(self): - dim = Mock() - dim.elements.return_value = [{'id': 5}, {'id': 4}] - subtotal = _Subtotal(self.valid_subtotal, dim) - expected = [5, 4] - actual = subtotal.args - self.assertEqual(actual, expected) - - def test_anchor_on_uppercased_bottom(self): - dim = Mock() - dim.elements.return_value = [{'id': 5}, {'id': 4}] - subtotal = _Subtotal(self.valid_subtotal_anchor_bottom, dim) + def iter_valid_fixture(self, request): + insertion_dicts, element_ids, expected_value = request.param + return insertion_dicts, element_ids, expected_value + + # fixture components --------------------------------------------- + + @pytest.fixture + def _element_ids_prop_(self, request): + return property_mock(request, _Subtotals, '_element_ids') + + @pytest.fixture + def _iter_valid_subtotal_dicts_(self, request): + return method_mock(request, _Subtotals, '_iter_valid_subtotal_dicts') + + @pytest.fixture + def _Subtotal_(self, request): + return class_mock(request, 'cr.cube.dimension._Subtotal') + + @pytest.fixture + def _subtotals_prop_(self, request): + return property_mock(request, _Subtotals, '_subtotals') + + @pytest.fixture + def valid_elements_(self, request): + return instance_mock(request, _ValidElements) + + +class Describe_Subtotal(object): + + def it_knows_the_insertion_anchor(self, anchor_fixture, valid_elements_): + subtotal_dict, element_ids, expected_value = anchor_fixture + valid_elements_.element_ids = element_ids + subtotal = _Subtotal(subtotal_dict, valid_elements_) + anchor = subtotal.anchor - assert anchor == 'bottom' + + assert anchor == expected_value + + def it_knows_the_index_of_the_anchor_element( + self, anchor_idx_fixture, anchor_prop_, valid_elements_, + element_): + anchor, index, calls, expected_value = anchor_idx_fixture + anchor_prop_.return_value = anchor + valid_elements_.get_by_id.return_value = element_ + element_.index = index + subtotal = _Subtotal(None, valid_elements_) + + anchor_idx = subtotal.anchor_idx + + assert valid_elements_.get_by_id.call_args_list == calls + assert anchor_idx == expected_value + + def it_provides_access_to_the_addend_element_ids( + self, addend_ids_fixture, valid_elements_): + subtotal_dict, element_ids, expected_value = addend_ids_fixture + valid_elements_.element_ids = element_ids + subtotal = _Subtotal(subtotal_dict, valid_elements_) + + addend_ids = subtotal.addend_ids + + assert addend_ids == expected_value + + def it_provides_access_to_the_addend_element_indices( + self, request, addend_ids_prop_, valid_elements_): + addend_ids_prop_.return_value = (3, 6, 9) + valid_elements_.get_by_id.side_effect = iter( + instance_mock(request, _BaseElement, index=index) + for index in (2, 4, 6) + ) + subtotal = _Subtotal(None, valid_elements_) + + addend_idxs = subtotal.addend_idxs + + assert valid_elements_.get_by_id.call_args_list == [ + call(3), call(6), call(9) + ] + assert addend_idxs == (2, 4, 6) + + def it_knows_the_subtotal_label(self, label_fixture): + subtotal_dict, expected_value = label_fixture + subtotal = _Subtotal(subtotal_dict, None) + + label = subtotal.label + + assert label == expected_value + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ({}, {}, ()), + ({'args': [1]}, {1}, (1,)), + ({'args': [1, 2, 3]}, {1, 2, 3}, (1, 2, 3)), + ({'args': [1, 2, 3]}, {1, 3}, (1, 3)), + ({'args': [3, 2]}, {1, 2, 3}, (3, 2)), + ({'args': []}, {1, 2, 3}, ()), + ({'args': [1, 2, 3]}, {}, ()), + ]) + def addend_ids_fixture(self, request): + subtotal_dict, element_ids, expected_value = request.param + return subtotal_dict, element_ids, expected_value + + @pytest.fixture(params=[ + ({'anchor': 1}, {1, 2, 3}, 1), + ({'anchor': 4}, {1, 2, 3}, 'bottom'), + ({'anchor': 'Top'}, {1, 2, 3}, 'top'), + ]) + def anchor_fixture(self, request): + subtotal_dict, element_ids, expected_value = request.param + return subtotal_dict, element_ids, expected_value + + @pytest.fixture(params=[ + ('top', None, 0, 'top'), + ('bottom', None, 0, 'bottom'), + (42, 7, 1, 7), + ]) + def anchor_idx_fixture(self, request): + anchor, index, call_count, expected_value = request.param + calls = [call(anchor)] * call_count + return anchor, index, calls, expected_value + + @pytest.fixture(params=[ + ({}, ''), + ({'name': None}, ''), + ({'name': ''}, ''), + ({'name': 'Joe'}, 'Joe'), + ]) + def label_fixture(self, request): + subtotal_dict, expected_value = request.param + return subtotal_dict, expected_value + + # fixture components --------------------------------------------- + + @pytest.fixture + def addend_ids_prop_(self, request): + return property_mock(request, _Subtotal, 'addend_ids') + + @pytest.fixture + def anchor_prop_(self, request): + return property_mock(request, _Subtotal, 'anchor') + + @pytest.fixture + def element_(self, request): + return instance_mock(request, _BaseElement) + + @pytest.fixture + def valid_elements_(self, request): + return instance_mock(request, _ValidElements) diff --git a/tests/unit/test_scale_means.py b/tests/unit/test_scale_means.py index 9459a3429..4db11e2c0 100644 --- a/tests/unit/test_scale_means.py +++ b/tests/unit/test_scale_means.py @@ -1,12 +1,17 @@ -# pylint: disable=missing-docstring, redefined-outer-name, protected-access +# encoding: utf-8 + +"""Unit test suite for the cr.cube.measures.scale_means module.""" + from __future__ import division -from mock import Mock + import numpy as np import pytest from cr.cube.measures.scale_means import ScaleMeans -from .unitutil import property_mock +from ..unitutil import Mock, property_mock + +# pylint: disable=missing-docstring, redefined-outer-name, protected-access COLS_DIM_VALUES = np.array([0, 1, 2]) ROWS_DIM_VALUES = np.array([3, 4, 5, 6]) @@ -82,18 +87,19 @@ def test_valid_indices(valid_indices_fixture): @pytest.fixture(params=[ - ([Mock(values=[])], 0, []), - ([Mock(values=[1, 2, 3])], 0, [np.array([True, True, True])]), + ([Mock(numeric_values=[])], 0, []), + ([Mock(numeric_values=[1, 2, 3])], 0, [np.array([True, True, True])]), ( - [Mock(values=[1, 2, np.nan, 4])], 0, + [Mock(numeric_values=[1, 2, np.nan, 4])], 0, [np.array([True, True, False, True])], ), - ([Mock(values=[1])], 0, []), + ([Mock(numeric_values=[1])], 0, []), ( - [Mock(values=[1, 2, 3]), Mock(values=[])], 0, + [Mock(numeric_values=[1, 2, 3]), Mock(numeric_values=[])], 0, [np.array([True, True, True])], ), - ([Mock(values=[1, 2, 3]), Mock(values=[])], 1, [slice(None)]), + ([Mock(numeric_values=[1, 2, 3]), Mock(numeric_values=[])], 1, + [slice(None)]), ]) def valid_indices_fixture(request): dimensions, axis, expected = request.param diff --git a/tests/unit/unitutil.py b/tests/unitutil.py similarity index 54% rename from tests/unit/unitutil.py rename to tests/unitutil.py index 06a1693f7..ded717246 100644 --- a/tests/unit/unitutil.py +++ b/tests/unitutil.py @@ -6,10 +6,44 @@ absolute_import, division, print_function, unicode_literals ) -from mock import ANY, call # noqa +from mock import ANY, call, Mock # noqa from mock import create_autospec, patch, PropertyMock +def class_mock(request, q_class_name, autospec=True, **kwargs): + """Return mock patching class with qualified name *q_class_name*. + + The mock is autospec'ed based on the patched class unless the optional + argument *autospec* is set to False. Any other keyword arguments are + passed through to Mock(). Patch is reversed after calling test returns. + """ + _patch = patch(q_class_name, autospec=autospec, **kwargs) + request.addfinalizer(_patch.stop) + return _patch.start() + + +def function_mock(request, q_function_name, autospec=True, **kwargs): + """Return mock patching function with qualified name *q_function_name*. + + Patch is reversed after calling test returns. + """ + _patch = patch(q_function_name, autospec=autospec, **kwargs) + request.addfinalizer(_patch.stop) + return _patch.start() + + +def initializer_mock(request, cls, autospec=True, **kwargs): + """Return mock for __init__() method on *cls*. + + The patch is reversed after pytest uses it. + """ + _patch = patch.object( + cls, '__init__', autospec=autospec, return_value=None, **kwargs + ) + request.addfinalizer(_patch.stop) + return _patch.start() + + def instance_mock(request, cls, name=None, spec_set=True, **kwargs): """Return mock for instance of *cls* that draws its spec from the class.