Spaces:
Running
Running
| """Regression tests for liveboard search-query validation. | |
| Covers the three query bugs from run TRI_08250424_PY3 (Triumph Financial, | |
| liveboard ca684d71-dd94-4d70-a24a-efb2e443f9ea): | |
| 1. Cross-fact date on a granularity token (blank KPI tile) | |
| 2. Cross-fact date on a time filter (blank/misleading chart) | |
| 3. Granularity finer than the fact's data grain (.weekly on monthly snapshot) | |
| No network calls. The fixtures mirror what ThoughtSpot ACTUALLY exports: | |
| the model TML has no 'calendar' property on date columns (TS strips it on | |
| export), so date detection must come from the associated table TMLs' | |
| db_column_properties.data_type — exactly what build_column_map_from_tml_objects | |
| consumes. | |
| """ | |
| import copy | |
| import json | |
| import pytest | |
| from liveboard_creator import ( | |
| build_column_map_from_tml_objects, | |
| build_search_column_map, | |
| validate_search_query, | |
| validate_liveboard_queries, | |
| _apply_query_fix_to_viz, | |
| _infer_date_grain, | |
| ) | |
| def _model_col(name, table, physical, ctype): | |
| # Real exported model TML: no 'calendar', just column_type/index_type | |
| return { | |
| 'name': name, | |
| 'column_id': f'{table}::{physical}', | |
| 'properties': {'column_type': ctype, 'index_type': 'DONT_INDEX'}, | |
| } | |
| def _table_tml(table_name, col_types): | |
| return {'table': { | |
| 'name': table_name, | |
| 'db': 'DB', 'schema': 'SCHEMA', 'db_table': table_name, | |
| 'columns': [ | |
| {'name': col, 'db_column_name': col, | |
| 'properties': {'column_type': 'ATTRIBUTE'}, | |
| 'db_column_properties': {'data_type': dtype}} | |
| for col, dtype in col_types.items() | |
| ], | |
| }} | |
| # Mirrors the Triumph model: load transactions are daily (Load Date), | |
| # carrier capacity is a monthly snapshot (Capacity Month). | |
| MODEL_TML = {'guid': 'model-guid', 'model': {'name': 'TRI_mdl', 'columns': [ | |
| _model_col('Load Date', 'FACT_LOAD_TRANSACTION', 'LOAD_DATE', 'ATTRIBUTE'), | |
| _model_col('Loads Tendered', 'FACT_LOAD_TRANSACTION', 'LOADS_TENDERED', 'MEASURE'), | |
| _model_col('Potential Savings Usd', 'FACT_LOAD_TRANSACTION', 'POTENTIAL_SAVINGS_USD', 'MEASURE'), | |
| _model_col('Spot Rate Per Mile', 'FACT_LOAD_TRANSACTION', 'SPOT_RATE_PER_MILE', 'MEASURE'), | |
| _model_col('Network Rate Per Mile', 'FACT_LOAD_TRANSACTION', 'NETWORK_RATE_PER_MILE', 'MEASURE'), | |
| _model_col('Capacity Month', 'FACT_CARRIER_CAPACITY', 'CAPACITY_MONTH', 'ATTRIBUTE'), | |
| _model_col('Capacity Utilization Rate', 'FACT_CARRIER_CAPACITY', 'CAPACITY_UTILIZATION_RATE', 'MEASURE'), | |
| # Attribute with a grain word in its name but an INT type — NOT a date | |
| _model_col('Member Since Year', 'DIM_BROKER', 'MEMBER_SINCE_YEAR', 'ATTRIBUTE'), | |
| _model_col('Lane Tier', 'DIM_LANE', 'LANE_TIER', 'ATTRIBUTE'), | |
| _model_col('Dim Broker Name', 'DIM_BROKER', 'DIM_BROKER_NAME', 'ATTRIBUTE'), | |
| ]}} | |
| TABLE_TMLS = [ | |
| _table_tml('FACT_LOAD_TRANSACTION', { | |
| 'LOAD_DATE': 'DATE', 'LOADS_TENDERED': 'INT64', | |
| 'POTENTIAL_SAVINGS_USD': 'DOUBLE', 'SPOT_RATE_PER_MILE': 'DOUBLE', | |
| 'NETWORK_RATE_PER_MILE': 'DOUBLE', | |
| }), | |
| _table_tml('FACT_CARRIER_CAPACITY', { | |
| 'CAPACITY_MONTH': 'DATE', 'CAPACITY_UTILIZATION_RATE': 'DOUBLE', | |
| }), | |
| _table_tml('DIM_BROKER', {'MEMBER_SINCE_YEAR': 'INT64', 'DIM_BROKER_NAME': 'VARCHAR'}), | |
| _table_tml('DIM_LANE', {'LANE_TIER': 'VARCHAR'}), | |
| ] | |
| def column_map(): | |
| cmap = build_column_map_from_tml_objects([MODEL_TML] + TABLE_TMLS) | |
| assert cmap is not None | |
| return cmap | |
| def test_date_detection_comes_from_table_data_types(column_map): | |
| # Model TML has no 'calendar' (real export) — dates must still be found | |
| load_date = column_map['columns']['loaddate'] | |
| assert load_date['is_date'] and load_date['grain'] == 'daily' | |
| cap_month = column_map['columns']['capacitymonth'] | |
| assert cap_month['is_date'] and cap_month['grain'] == 'monthly' | |
| # INT column with a grain word in its name is NOT a date | |
| assert column_map['columns']['membersinceyear']['is_date'] is False | |
| # Fact tables carry their own date columns | |
| assert column_map['tables']['FACT_LOAD_TRANSACTION']['date_cols'][0]['name'] == 'Load Date' | |
| def test_calendar_property_is_only_a_fallback(): | |
| # Without table data types, a column is only a date if 'calendar' is present | |
| cols = copy.deepcopy(MODEL_TML['model']['columns']) | |
| assert build_search_column_map(cols)['columns']['loaddate']['is_date'] is False | |
| cols[0]['properties']['calendar'] = 'calendar' | |
| assert build_search_column_map(cols)['columns']['loaddate']['is_date'] is True | |
| def test_grain_inference_uses_last_word_only(): | |
| assert _infer_date_grain('Capacity Month') == 'monthly' | |
| assert _infer_date_grain('Month End Date') == 'daily' # not monthly | |
| assert _infer_date_grain('Fiscal Year') == 'yearly' | |
| assert _infer_date_grain('Snapshot Week') == 'weekly' | |
| assert _infer_date_grain('Load Date') == 'daily' | |
| def test_cross_fact_date_on_granularity_token(column_map): | |
| # Triumph bug 1: measure on load fact, date from capacity fact -> blank tile | |
| fixed, corrections = validate_search_query( | |
| "sum [Potential Savings Usd] [Capacity Month].monthly", column_map) | |
| assert fixed == "sum [Potential Savings Usd] [Load Date].monthly" | |
| assert len(corrections) == 1 | |
| assert corrections[0]['old_col'] == 'Capacity Month' | |
| assert corrections[0]['new_col'] == 'Load Date' | |
| def test_cross_fact_date_on_filter_token(column_map): | |
| # Triumph bug 2: load-fact measures filtered by the capacity fact's date | |
| fixed, corrections = validate_search_query( | |
| "[Spot Rate Per Mile] [Network Rate Per Mile] [Lane Tier] " | |
| "[Capacity Month] = 'last 18 months'", column_map) | |
| assert fixed == ("[Spot Rate Per Mile] [Network Rate Per Mile] [Lane Tier] " | |
| "[Load Date] = 'last 18 months'") | |
| assert corrections and corrections[0]['old_gran'] is None | |
| def test_granularity_finer_than_data_grain(column_map): | |
| # Triumph bug 3: .weekly on a monthly snapshot fact | |
| fixed, corrections = validate_search_query( | |
| "average [Capacity Utilization Rate] [Capacity Month].weekly", column_map) | |
| assert fixed == "average [Capacity Utilization Rate] [Capacity Month].monthly" | |
| assert corrections[0]['old_gran'] == 'weekly' | |
| assert corrections[0]['new_gran'] == 'monthly' | |
| def test_valid_queries_pass_untouched(column_map): | |
| for query in [ | |
| "sum [Loads Tendered] [Load Date].monthly", | |
| "average [Capacity Utilization Rate] [Capacity Month].monthly", | |
| "sum [Loads Tendered] [Load Date].weekly [Load Date].'last 8 quarters'", | |
| "top 10 [Dim Broker Name] [Load Date] = 'last 12 months' sort by [Potential Savings Usd]", | |
| ]: | |
| fixed, corrections = validate_search_query(query, column_map) | |
| assert fixed == query | |
| assert corrections == [] | |
| def test_mixed_fact_measures_are_left_alone(column_map): | |
| # Measures from BOTH facts: no single "right" date column exists, so the | |
| # validator must not rewrite anything (rewriting would just move the | |
| # cross-fact problem onto the other measure). | |
| query = ("sum [Potential Savings Usd] [Capacity Utilization Rate] " | |
| "[Capacity Month].monthly") | |
| fixed, corrections = validate_search_query(query, column_map) | |
| assert fixed == query | |
| assert corrections == [] | |
| def test_kpi_double_date_usage_replaces_both_tokens(column_map): | |
| # KPI pattern: same date appears twice (granularity + time filter) | |
| fixed, corrections = validate_search_query( | |
| "sum [Potential Savings Usd] [Capacity Month].monthly " | |
| "[Capacity Month].'last 8 quarters'", column_map) | |
| assert fixed == ("sum [Potential Savings Usd] [Load Date].monthly " | |
| "[Load Date].'last 8 quarters'") | |
| def test_query_without_known_measures_is_left_alone(column_map): | |
| fixed, corrections = validate_search_query( | |
| "[Dim Broker Name] [Capacity Month].weekly", column_map) | |
| assert fixed == "[Dim Broker Name] [Capacity Month].weekly" | |
| assert corrections == [] | |
| def _kpi_viz(search_query, date_label, measure_label, name): | |
| """A minimal exported-TML KPI viz with the dependent column labels.""" | |
| return { | |
| 'id': 'Viz_1', | |
| 'answer': { | |
| 'name': name, | |
| 'tables': [{'id': 'mdl', 'name': 'mdl'}], | |
| 'search_query': search_query, | |
| 'answer_columns': [{'name': date_label}, {'name': measure_label}], | |
| 'table': { | |
| 'table_columns': [ | |
| {'column_id': date_label, 'headline_aggregation': 'MIN-MAX'}, | |
| {'column_id': measure_label, 'headline_aggregation': 'SUM'}, | |
| ], | |
| 'ordered_column_ids': [date_label, measure_label], | |
| }, | |
| 'chart': { | |
| 'type': 'KPI', | |
| 'chart_columns': [{'column_id': date_label}, {'column_id': measure_label}], | |
| 'axis_configs': [{'x': [date_label], 'y': [measure_label]}], | |
| 'client_state_v2': json.dumps({ | |
| 'axisProperties': [{'properties': {'linkedColumns': [date_label]}}] | |
| }), | |
| }, | |
| }, | |
| } | |
| def test_apply_fix_rewrites_dependent_labels(column_map): | |
| viz = _kpi_viz( | |
| "sum [Potential Savings Usd] [Capacity Month].monthly", | |
| 'Month(Capacity Month)', 'Total Potential Savings Usd', | |
| 'Total Potential Savings Monthly', | |
| ) | |
| tml = {'liveboard': {'visualizations': [copy.deepcopy(viz)]}} | |
| fixed_count, messages = validate_liveboard_queries(tml, column_map) | |
| assert fixed_count == 1 | |
| answer = tml['liveboard']['visualizations'][0]['answer'] | |
| assert answer['search_query'] == "sum [Potential Savings Usd] [Load Date].monthly" | |
| assert answer['answer_columns'][0]['name'] == 'Month(Load Date)' | |
| assert answer['table']['ordered_column_ids'][0] == 'Month(Load Date)' | |
| assert answer['chart']['chart_columns'][0]['column_id'] == 'Month(Load Date)' | |
| assert answer['chart']['axis_configs'][0]['x'] == ['Month(Load Date)'] | |
| assert 'Month(Load Date)' in answer['chart']['client_state_v2'] | |
| assert 'Capacity Month' not in json.dumps(answer) | |
| def test_apply_fix_updates_title_on_granularity_change(column_map): | |
| viz = _kpi_viz( | |
| "average [Capacity Utilization Rate] [Capacity Month].weekly", | |
| 'Week(Capacity Month)', 'Average Capacity Utilization Rate', | |
| 'Average Capacity Utilization Rate Weekly', | |
| ) | |
| tml = {'liveboard': {'visualizations': [viz]}} | |
| fixed_count, _ = validate_liveboard_queries(tml, column_map) | |
| assert fixed_count == 1 | |
| answer = tml['liveboard']['visualizations'][0]['answer'] | |
| assert answer['search_query'] == "average [Capacity Utilization Rate] [Capacity Month].monthly" | |
| assert answer['name'] == 'Average Capacity Utilization Rate Monthly' | |
| assert answer['answer_columns'][0]['name'] == 'Month(Capacity Month)' | |
| def test_apply_fix_bails_out_when_labels_missing(column_map): | |
| # Dependent label absent from the answer -> viz must be left untouched | |
| viz = _kpi_viz( | |
| "sum [Potential Savings Usd] [Capacity Month].monthly", | |
| 'SomethingUnexpected', 'Total Potential Savings Usd', | |
| 'Total Potential Savings Monthly', | |
| ) | |
| original = copy.deepcopy(viz) | |
| fixed_query, corrections = validate_search_query( | |
| viz['answer']['search_query'], column_map) | |
| assert corrections | |
| assert _apply_query_fix_to_viz(viz, fixed_query, corrections) is False | |
| assert viz == original | |
| def test_error_on_one_viz_does_not_abandon_the_rest(column_map): | |
| # First viz has a non-JSON-serializable value; second is a normal fixable | |
| # KPI. The bad viz is reported and skipped, the good one still gets fixed. | |
| bad = _kpi_viz( | |
| "sum [Potential Savings Usd] [Capacity Month].monthly", | |
| 'Month(Capacity Month)', 'Total Potential Savings Usd', 'Bad Viz', | |
| ) | |
| bad['answer']['chart']['weird'] = object() # json.dumps will raise | |
| good = _kpi_viz( | |
| "sum [Potential Savings Usd] [Capacity Month].monthly", | |
| 'Month(Capacity Month)', 'Total Potential Savings Usd', 'Good Viz', | |
| ) | |
| good['id'] = 'Viz_2' | |
| tml = {'liveboard': {'visualizations': [bad, good]}} | |
| fixed_count, messages = validate_liveboard_queries(tml, column_map) | |
| assert fixed_count == 1 | |
| assert any('Bad Viz' in m and 'error' in m for m in messages) | |
| assert good['answer']['search_query'] == "sum [Potential Savings Usd] [Load Date].monthly" | |
| def test_note_tiles_and_queryless_vizzes_are_skipped(column_map): | |
| tml = {'liveboard': {'visualizations': [ | |
| {'id': 'Viz_1', 'note_tile': {'html_parsed_string': '<div/>'}}, | |
| {'id': 'Viz_2', 'answer': {'name': 'no query here'}}, | |
| ]}} | |
| fixed_count, messages = validate_liveboard_queries(tml, column_map) | |
| assert fixed_count == 0 | |
| assert messages == [] | |