/* * Copyright (c) 2025 SimcuTeam. All rights reserved. * 迁移 CLI 单元测试(纯逻辑 + 临时目录落盘,不连接数据库)。 * 覆盖:迁移源码生成(初始/增量/手写含索引与 rawSql)/ 注册文件重写 / * className 与文件名推导 / CLI 命令分发(help/未知/缺参数)。 */ package simcu::orm.tests import std.collection.* import std.fs.* import std.unittest.* import std.unittest.testmacro.* import simcu::orm.* import simcu::orm.cli.* /// 手写迁移(验证源码生成覆盖索引与 rawSql 转义) class HandWrittenMigration <: Migration { public init() { super("20250801000000_HandWritten", "手写迁移") } public override func up(builder: MigrationBuilder): Unit { builder.createTable("tags") { tb => tb.column("id", ColumnTypes.BigIntCol).primary().autoInc().notNull() tb.column("label", ColumnTypes.TextCol).withMaxLength(50).withUnique().notNull() } let idxCols = ArrayList() idxCols.add("label") builder.createIndex("ix_tags_label", "tags", idxCols, true) builder.rawSql("SELECT 1 -- comment \"quoted\"") } public override func down(builder: MigrationBuilder): Unit { builder.dropTable("tags") } } @Test class MigrationFileGeneratorTests { private func buildUserModel(): EntityModel { let model = EntityModel() model.tableName = "users" let pk = PropertyModel() pk.name = "id" pk.columnName = "id" pk.isKey = true pk.autoIncrement = true pk.isRequired = true pk.typeNameOverride = "Int64" model.properties.add(pk) let name = PropertyModel() name.name = "name" name.columnName = "name" name.isRequired = true name.maxLength = 100 name.typeNameOverride = "String" model.properties.add(name) model.keyProperty = Some(pk) model } private func buildOrderModel(): EntityModel { let model = EntityModel() model.tableName = "orders" let pk = PropertyModel() pk.name = "oid" pk.columnName = "oid" pk.isKey = true pk.clientGenerated = true pk.isRequired = true pk.typeNameOverride = "String" model.properties.add(pk) model.keyProperty = Some(pk) model } @TestCase public func testClassNameAndFileName(): Unit { @Expect(MigrationFileGenerator.classNameOf("20250701000000_InitialCreate") == "InitialCreate") @Expect(MigrationFileGenerator.fileNameOf("20250701000000_InitialCreate") == "20250701000000_InitialCreate.cj") @Expect(MigrationFileGenerator.classNameOf("NoTimestamp") == "NoTimestamp") } @TestCase public func testInitialMigrationSource(): Unit { let models = ArrayList() models.add(buildUserModel()) let gen = MigrationGenerator() let m = gen.initial("20250701000000_InitialCreate", "初始建表", models) let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m) @Expect(src.contains("package app")) @Expect(src.contains("public class InitialCreate <: Migration")) @Expect(src.contains("super(\"20250701000000_InitialCreate\", \"初始建表\")")) @Expect(src.contains("builder.createTable(\"users\") { tb =>")) @Expect(src.contains("tb.column(\"id\", ColumnTypes.BigIntCol).primary().autoInc().notNull()")) @Expect(src.contains("tb.column(\"name\", ColumnTypes.TextCol).notNull().withMaxLength(100)")) @Expect(src.contains("builder.dropTable(\"users\")")) } @TestCase public func testDiffMigrationSource(): Unit { let oldModels = ArrayList() oldModels.add(buildUserModel()) let newModels = ArrayList() newModels.add(buildUserModel()) newModels.add(buildOrderModel()) let gen = MigrationGenerator() let m = gen.diff("20250801000000_AddOrders", "新增 orders 表", oldModels, newModels) let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m) @Expect(src.contains("builder.createTable(\"orders\") { tb =>")) @Expect(src.contains("tb.column(\"oid\", ColumnTypes.TextCol).primary().notNull()")) // down 反转:先 drop 新表 @Expect(src.contains("builder.dropTable(\"orders\")")) } @TestCase public func testHandWrittenMigrationSource(): Unit { let m = HandWrittenMigration() let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m) @Expect(src.contains("builder.createIndex(\"ix_tags_label\", \"tags\", [\"label\"], true)")) @Expect(src.contains("tb.column(\"label\", ColumnTypes.TextCol).notNull().withUnique().withMaxLength(50)")) // rawSql 双引号转义 @Expect(src.contains("builder.rawSql(\"SELECT 1 -- comment \\\"quoted\\\"\")")) @Expect(src.contains("builder.dropTable(\"tags\")")) } @TestCase public func testRegistryRoundTrip(): Unit { let entries = ArrayList() entries.add("InitialCreate") entries.add("AddAge") let src = MigrationFileGenerator.registrySource("app", entries) @Expect(src.contains("package app")) @Expect(src.contains("registerMigration(InitialCreate())")) @Expect(src.contains("registerMigration(AddAge())")) // 提取回环 let extracted = MigrationFileGenerator.extractRegistryEntries(src) @Expect(extracted.size == 2) @Expect(extracted[0] == "InitialCreate") @Expect(extracted[1] == "AddAge") // 去重 entries.add("InitialCreate") let src2 = MigrationFileGenerator.registrySource("app", entries) @Expect(MigrationFileGenerator.extractRegistryEntries(src2).size == 2) } @TestCase public func testWriteMigrationAndRegistryToDisk(): Unit { let dir = "cli_migration_tmp" if (exists(Path(dir))) { remove(Path(dir), recursive: true) } try { let models = ArrayList() models.add(buildUserModel()) let gen = MigrationGenerator() let m = gen.initial("20250701000000_InitialCreate", "初始建表", models) let fg = MigrationFileGenerator() let path = fg.writeMigration("testpkg", m, dir) @Expect(exists(Path(path))) let text = String.fromUtf8(File.readFrom(Path(path))) @Expect(text.contains("package testpkg")) @Expect(text.contains("public class InitialCreate <: Migration")) // 注册文件:首次创建 + 追加 + 幂等 let registryPath = "${dir}/MigrationRegistry.cj" fg.updateRegistry("testpkg", registryPath, "InitialCreate") fg.updateRegistry("testpkg", registryPath, "AddAge") fg.updateRegistry("testpkg", registryPath, "AddAge") let reg = String.fromUtf8(File.readFrom(Path(registryPath))) @Expect(reg.contains("package testpkg")) @Expect(MigrationFileGenerator.extractRegistryEntries(reg).size == 2) } finally { if (exists(Path(dir))) { remove(Path(dir), recursive: true) } } } } @Test class MigrationCliDispatchTests { @TestCase public func testHelpReturnsZero(): Unit { let rc = MigrationCli.run(ArrayList(), { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(rc == 0) } @TestCase public func testUnknownCommandReturnsOne(): Unit { let args = ArrayList() args.add("bogus") let rc = MigrationCli.run(args, { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(rc == 1) } @TestCase public func testAddMissingNameReturnsOne(): Unit { let args = ArrayList() args.add("add") let rc = MigrationCli.run(args, { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(rc == 1) } @TestCase public func testRmMissingNameReturnsOne(): Unit { let args = ArrayList() args.add("rm") let rc = MigrationCli.run(args, { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(rc == 1) } @TestCase public func testDowngradeNoMigrationsReturnsOne(): Unit { let args = ArrayList() args.add("downgrade") let rc = MigrationCli.run(args, { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(rc == 1) } @TestCase public func testDowngradeWithTargetConsumesArgs(): Unit { let args = ArrayList() args.add("downgrade") args.add("20260819061048_InitialCreate") let rc = MigrationCli.run(args, { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(rc == 1) } @TestCase public func testTryRunWithOrmPrefixConsumesArgs(): Unit { let args = ArrayList() args.add("orm") args.add("help") let handled = MigrationCli.tryRun(args, { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(handled == true) } @TestCase public func testTryRunWithoutOrmPrefixReturnsFalse(): Unit { let args = ArrayList() args.add("serve") let handled = MigrationCli.tryRun(args, { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(handled == false) } @TestCase public func testTryRunEmptyArgsReturnsFalse(): Unit { let handled = MigrationCli.tryRun(ArrayList(), { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(handled == false) } }