""" 代码生成器模块单元测试 ============================================================= 验证代码生成器 generator 模块的核心纯函数(无需数据库): - 表分页:_paginate_tables(总数/页数/切片/越界回退) - 表项解析:_parse_table_item(管道分隔/纯表名/字典三种格式) - 生成入口校验:generate_tables(空/非数组/超限抛 BizError,单项失败继续) - 字段类型映射:TableSchemaParser(DB 类型 -> SQLAlchemy 类型、长度、choices) - 代码块构建:_build_column_block / _build_form_field_block / _build_filters_literal - 自动注册:_insert_module_import / _insert_blueprint(插入与幂等) """ import pytest from src.core.exceptions import BizError from src.modules.generator import service as gen from src.modules.generator.code_generator import ( CodeGenerator, _build_column_block, _build_filters_literal, _build_form_field_block, _choice_tag_type, normalize_config, ) from src.modules.generator.parser import TableSchemaParser # ============================================================ # 表分页 # ============================================================ class TestPaginateTables: """内存分页:切片 + 页数计算 + 越界回退""" def test_basic(self): tables = [{'tableName': f't{i}'} for i in range(25)] result = gen._paginate_tables(tables, 1, 10) assert result['total'] == 25 assert result['pages'] == 3 assert result['size'] == 10 assert result['current'] == 1 assert len(result['records']) == 10 assert result['records'][0]['tableName'] == 't0' def test_last_page(self): tables = [{'tableName': f't{i}'} for i in range(25)] result = gen._paginate_tables(tables, 3, 10) assert result['current'] == 3 assert len(result['records']) == 5 def test_page_out_of_range_falls_back_to_first(self): tables = [{'tableName': 't0'}] * 5 result = gen._paginate_tables(tables, 99, 10) assert result['current'] == 1 assert result['pages'] == 1 def test_empty(self): result = gen._paginate_tables([], 1, 10) assert result['total'] == 0 assert result['pages'] == 0 assert result['records'] == [] # ============================================================ # 表项解析 # ============================================================ class TestParseTableItem: """tableNames 项三种格式解析""" def test_pipe_format(self): assert gen._parse_table_item('flask_article|文章管理表') == ('flask_article', '文章管理表') def test_plain_name(self): assert gen._parse_table_item('flask_article') == ('flask_article', '') def test_pipe_with_empty_comment(self): assert gen._parse_table_item('flask_article|') == ('flask_article', '') def test_dict_format(self): assert gen._parse_table_item({'name': 'flask_user', 'comment': '用户表'}) == ('flask_user', '用户表') # ============================================================ # 生成入口校验 # ============================================================ class TestGenerateTables: """参数校验与批量容错""" def test_missing_key_raises(self): with pytest.raises(BizError, match='表名不能为空'): gen.generate_tables({}) def test_empty_list_raises(self): with pytest.raises(BizError, match='表名不能为空'): gen.generate_tables({'tableNames': []}) def test_not_list_raises(self): with pytest.raises(BizError, match='必须是数组'): gen.generate_tables({'tableNames': 'flask_article'}) def test_too_many_raises(self): names = [f't{i}' for i in range(51)] with pytest.raises(BizError, match='最多支持'): gen.generate_tables({'tableNames': names}) def test_success_returns_count(self, monkeypatch): monkeypatch.setattr(gen, '_generate_one', lambda name, comment: None) count = gen.generate_tables({'tableNames': ['flask_a|A表', 'flask_b']}) assert count == 2 def test_partial_failure_continues(self, monkeypatch): def fake_generate(table_name, table_comment): if table_name == 'bad': raise RuntimeError('boom') monkeypatch.setattr(gen, '_generate_one', fake_generate) count = gen.generate_tables({'tableNames': ['good|A', 'bad', 'ok']}) assert count == 2 # ============================================================ # 字段类型映射(不查库,仅构造列字典) # ============================================================ def _col(**overrides): """构造 information_schema 列字典""" base = { 'DATA_TYPE': 'varchar', 'COLUMN_NAME': 'title', 'COLUMN_COMMENT': '标题', 'IS_NULLABLE': 'NO', 'CHARACTER_MAXIMUM_LENGTH': 255, 'NUMERIC_PRECISION': None, 'NUMERIC_SCALE': None, 'COLUMN_DEFAULT': None, 'EXTRA': '', } base.update(overrides) return base class TestFieldTypeMapping: """DB 数据类型 -> SQLAlchemy 类型 / 构造参数 / choices""" def test_varchar_to_string(self): parser = TableSchemaParser('flask_t') assert parser._get_field_type(_col(DATA_TYPE='varchar')) == 'String' def test_text_to_text(self): parser = TableSchemaParser('flask_t') assert parser._get_field_type(_col(DATA_TYPE='longtext')) == 'Text' def test_bigint_to_biginteger(self): parser = TableSchemaParser('flask_t') assert parser._get_field_type(_col(DATA_TYPE='bigint')) == 'BigInteger' def test_decimal_to_numeric(self): parser = TableSchemaParser('flask_t') assert parser._get_field_type(_col(DATA_TYPE='decimal')) == 'Numeric' def test_db_args_string_length(self): parser = TableSchemaParser('flask_t') assert parser._get_db_args(_col(DATA_TYPE='varchar', CHARACTER_MAXIMUM_LENGTH=100)) == '100' def test_db_args_numeric_precision(self): parser = TableSchemaParser('flask_t') assert parser._get_db_args(_col(DATA_TYPE='decimal', NUMERIC_PRECISION=10, NUMERIC_SCALE=2)) == '10, 2' def test_db_args_none_for_text(self): parser = TableSchemaParser('flask_t') assert parser._get_db_args(_col(DATA_TYPE='text')) is None def test_form_type_integer(self): parser = TableSchemaParser('flask_t') assert parser._get_form_type(_col(DATA_TYPE='int')) == 'IntegerField' def test_choices_parse(self): parser = TableSchemaParser('flask_t') choices, min_value, max_value = parser._get_choices(_col(COLUMN_COMMENT='0-正常 1-下架')) assert choices == [(0, '正常'), (1, '下架')] assert (min_value, max_value) == (0, 1) def test_choices_none(self): parser = TableSchemaParser('flask_t') choices, min_value, max_value = parser._get_choices(_col(COLUMN_COMMENT='标题')) assert choices is None assert min_value is None def test_filter_type_string_fuzzy(self): parser = TableSchemaParser('flask_t') assert parser._get_filter_type(_col(COLUMN_NAME='title', DATA_TYPE='varchar')) == '模糊查询' def test_filter_type_code_exact(self): parser = TableSchemaParser('flask_t') assert parser._get_filter_type(_col(COLUMN_NAME='code', DATA_TYPE='varchar')) == '精确匹配' def test_numeric_default_normalized(self): parser = TableSchemaParser('flask_t') col = _col(DATA_TYPE='int', COLUMN_NAME='sort', COLUMN_DEFAULT='0') fields = parser._build_fields([col], set()) # MySQL 默认值以字符串返回,整数列应转为 int(生成 default=0 而非 default='0') assert fields[0]['default_repr'] == '0' assert isinstance(fields[0]['default'], int) def test_string_default_kept_as_string(self): parser = TableSchemaParser('flask_t') col = _col(DATA_TYPE='varchar', COLUMN_NAME='name', COLUMN_DEFAULT="'normal'") fields = parser._build_fields([col], set()) assert fields[0]['default'] == 'normal' assert fields[0]['default_repr'] == "'normal'" # ============================================================ # 代码块构建 # ============================================================ class TestBlockBuilders: """models/schema/repository 代码块预构建""" def test_column_block(self): field = dict(name='title', comment='标题', py_comment='标题', db_type='String', db_args='200', nullable=False, default_repr=None, is_unique=False, db_index=False) block = _build_column_block(field) assert 'title = db.Column(' in block assert 'db.String(200)' in block assert 'nullable=False' in block assert 'comment="标题"' in block def test_column_block_with_default_unique_index(self): field = dict(name='status', comment='状态', py_comment='状态', db_type='Integer', db_args=None, nullable=False, default_repr='1', is_unique=True, db_index=True) block = _build_column_block(field) assert 'db.Integer' in block assert 'default=1' in block assert 'unique=True' in block assert 'index=True' in block def test_form_field_block(self): field = dict(camel_name='title', form_type='StringField', comment='标题', py_comment='标题', required=True, max_length=200, min_value=None, max_value=None) block = _build_form_field_block(field) assert 'title = StringField(' in block assert "DataRequired(message='标题不能为空')" in block assert 'Length(max=200' in block def test_filters_literal_empty(self): assert _build_filters_literal([]) == '{}' def test_filters_literal(self): literal = _build_filters_literal([('status', 'status'), ('name', 'name')]) assert "'status': params.get('status')," in literal assert "'name': params.get('name')," in literal # ============================================================ # 自动注册辅助 # ============================================================ # ============================================================ # 配置标准化(兼容 config.json 参考配置) # ============================================================ class TestNormalizeConfig: """config.json 参考字段类型 -> SQLAlchemy 类型转换""" def test_django_config_normalized(self): config = { 'app_name': 'example', 'fields': [ {'name': 'name', 'type': 'CharField', 'max_length': 100, 'comment': '名称', 'default': None}, {'name': 'status', 'type': 'IntegerField', 'comment': '状态', 'default': '1'}, {'name': 'sort', 'type': 'IntegerField', 'comment': '排序', 'default': '0'}, ], } out = normalize_config(config) name_field = out['fields'][0] assert name_field['db_type'] == 'String' assert name_field['db_args'] == '100' assert name_field['form_type'] == 'StringField' assert name_field['camel_name'] == 'name' status_field = out['fields'][1] assert status_field['db_type'] == 'Integer' assert status_field['form_type'] == 'IntegerField' assert status_field['default'] == 1 # 字符串默认值转 int assert status_field['default_repr'] == '1' assert status_field['is_status'] is True sort_field = out['fields'][2] assert sort_field['is_sort'] is True def test_flask_config_passthrough(self): # 已含 db_type 的 Flask 配置原样返回(幂等) config = {'app_name': 'x', 'fields': [{'name': 'a', 'db_type': 'String'}]} assert normalize_config(config) is config def test_empty_fields_passthrough(self): config = {'app_name': 'x', 'fields': []} assert normalize_config(config) is config # ============================================================ # 前端 Vue 上下文 # ============================================================ def _gen_config(fields, **extra): """构造最小生成配置""" base = { 'app_name': 'example', 'module_comment': '案例', 'model_class_name': 'Example', 'permission_prefix': 'sys:example', 'route_prefix': 'example', 'primary_key': 'id', 'display_field': 'name', 'display_field_camel': 'name', 'has_sort': True, 'has_status': True, 'has_unique_code': False, 'has_export': False, 'fields': fields, } base.update(extra) return base def _vf(name, db_type='String', comment='字段', choices=None, filterable=False, **kwargs): """构造最小字段配置""" field = { 'name': name, 'camel_name': name, 'comment': comment, 'py_comment': comment, 'db_type': db_type, 'db_args': '100' if db_type == 'String' else None, 'form_type': 'StringField', 'type_display': '字符串', 'max_length': 100 if db_type == 'String' else None, 'nullable': False, 'required': True, 'default_repr': None, 'is_unique': False, 'db_index': False, 'default': None, 'filterable': filterable, 'filter_type': '模糊查询' if filterable else '', 'in_form': True, 'in_list': True, 'editable': True, 'searchable': filterable, 'is_image': False, 'is_rich_text': False, 'is_sort': False, 'is_status': False, 'choices': choices, 'min_value': None, 'max_value': None, } field.update(kwargs) return field class TestCleanFieldComment: """清洗注释中的选项说明""" def test_strip_choices(self): gen = CodeGenerator(_gen_config([])) assert gen._clean_field_comment('案例类型:1-类型1 2-类型2') == '案例类型' assert gen._clean_field_comment('案例状态:1-正常 2-禁用') == '案例状态' def test_plain(self): gen = CodeGenerator(_gen_config([])) assert gen._clean_field_comment('标题') == '标题' assert gen._clean_field_comment('') == '' class TestBuildChoices: """选项元组 -> 前端字典列表""" def test_transformed(self): gen = CodeGenerator(_gen_config([])) assert gen._build_choices([(1, '正常'), (2, '禁用')]) == [ {'value': 1, 'label': '正常', 'type': 'success'}, {'value': 2, 'label': '禁用', 'type': 'danger'}, ] def test_none(self): gen = CodeGenerator(_gen_config([])) assert gen._build_choices(None) is None class TestChoiceTagType: """选项值 -> 标签颜色""" def test_values(self): assert _choice_tag_type(1) == 'success' assert _choice_tag_type(2) == 'danger' assert _choice_tag_type(3) == 'warning' assert _choice_tag_type(9) == 'info' class TestVueContext: """前端模板上下文:普通列表与树状列表""" def test_normal_context(self): fields = [ _vf('name', comment='案例名称', filterable=True), _vf('status', db_type='Integer', comment='案例状态:1-正常 2-禁用', choices=[(1, '正常'), (2, '禁用')], filterable=True), ] gen = CodeGenerator(_gen_config(fields)) vctx = gen._prepare_vue_context(gen._prepare_context()) assert vctx['is_tree_structure'] is False assert vctx['route_prefix'] == 'example' # 覆盖为 app_name,与后端蓝图一致 assert vctx['model_class_name_camel'] == 'example' assert vctx['module_name'] == 'example' assert vctx['api_path'] == 'tool/example' # 字段重塑:type 转配置字段类型名、label 清洗、choices 转字典 name_field = next(f for f in vctx['fields'] if f['name'] == 'name') assert name_field['type'] == 'CharField' assert name_field['label'] == '案例名称' status_field = next(f for f in vctx['fields'] if f['name'] == 'status') assert status_field['type'] == 'IntegerField' assert status_field['label'] == '案例状态' assert status_field['choices'][0] == {'value': 1, 'label': '正常', 'type': 'success'} def test_tree_context(self): fields = [_vf('name', comment='部门名称'), _vf('parent_id', db_type='Integer', comment='上级部门')] gen = CodeGenerator(_gen_config(fields)) vctx = gen._prepare_vue_context(gen._prepare_context()) assert vctx['is_tree_structure'] is True assert vctx['parent_id_field'] == 'parentId' assert vctx['has_search_form'] is False # 树形默认不显示搜索表单 def test_module_name_underscore_stripped(self): gen = CodeGenerator(_gen_config([], app_name='order_item', model_class_name='OrderItem')) vctx = gen._prepare_vue_context(gen._prepare_context()) assert vctx['module_name'] == 'orderitem' assert vctx['model_class_name_camel'] == 'orderItem' def test_filter_fields(self): fields = [_vf('name', filterable=True), _vf('type', db_type='Integer', filterable=True)] gen = CodeGenerator(_gen_config(fields)) vctx = gen._prepare_vue_context(gen._prepare_context()) assert [f['name'] for f in vctx['filter_fields']] == ['name', 'type'] assert vctx['has_search_form'] is True class TestRegisterHelpers: """_insert_module_import / _insert_blueprint 插入与幂等""" def test_insert_module_import(self): content = "from src.modules.auth.router import login_blue\nfrom src.modules.city.router import city_blue\n" out = CodeGenerator._insert_module_import( content, 'from src.modules.generator.router import generator_blue' ) assert 'from src.modules.generator.router import generator_blue\n' in out # 插入位置应在最后一个 src.modules 导入之后 assert out.index('generator') > out.index('city_blue') def test_insert_module_import_idempotent(self): content = "from src.modules.city.router import city_blue\n" out = CodeGenerator._insert_module_import(content, 'from src.modules.city.router import city_blue') assert out == content def test_insert_blueprint(self): content = "BLUEPRINTS = [\n login_blue,\n city_blue,\n]\n" out = CodeGenerator._insert_blueprint(content, 'generator_blue') assert 'generator_blue' in out assert out.index('generator_blue') > out.index('city_blue') assert out.endswith(' generator_blue,\n]\n') def test_insert_blueprint_idempotent(self): content = "BLUEPRINTS = [\n generator_blue,\n]\n" out = CodeGenerator._insert_blueprint(content, 'generator_blue') assert out == content