package com.battery.device.neware;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
import java.time.Clock;
|
import java.time.Instant;
|
import java.time.ZoneOffset;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Set;
|
import org.junit.jupiter.api.Test;
|
import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
|
import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
|
import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
|
import com.battery.device.api.TestDataObservationAdapter.StepRecord;
|
import com.battery.device.api.credentials.LocalCredentials;
|
import com.battery.device.api.model.AdapterCapabilities;
|
import com.battery.device.api.model.ChannelIdentity;
|
import com.battery.device.api.model.DetectedChannel;
|
import com.battery.device.api.model.DeviceConnectionStatus;
|
import com.battery.device.api.model.DeviceWorkStatus;
|
import com.battery.device.api.model.RealtimeSnapshot;
|
import com.battery.device.api.registry.AdapterInstanceConfig;
|
|
/**
|
* 直连新威库的适配器契约测试:用可控的假厂商库验证发现清单与最近数据时间、
|
* 最新测试按起始时间取、分表定位用测试记录给出的表名、活跃只看新采样行、单位换算、身份与水位。
|
*/
|
class NewareMysqlAdapterContractTest
|
{
|
private static final String ADAPTER_CODE = "neware76";
|
private static final long DEV_UID = 210036L;
|
private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2);
|
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-28T02:00:00Z"), ZoneOffset.UTC);
|
|
private static final String ACTIVE_TABLE = "data_210036_2026_09_18";
|
|
private NewareMysqlAdapter adapter(FakeVendorDatabase database)
|
{
|
AdapterInstanceConfig config = new AdapterInstanceConfig(ADAPTER_CODE,
|
NewareMysqlAdapter.ADAPTER_TYPE, "26.1.2.3", 3306, 60000, "Asia/Hong_Kong", true,
|
AdapterCapabilities.observeOnly(), Map.of("database", "bts63", "user", "visitor"));
|
// 缓存关闭,让每个断言都读到假厂商库的当前内容。
|
return new NewareMysqlAdapter(config, database.dataSource(), () -> { }, CLOCK, 0);
|
}
|
|
/** 一台在用设备(dev_uid 210036)、一台只出现在历史数据里但不在登记表中的设备(240098)。 */
|
private FakeVendorDatabase activeDeviceDatabase()
|
{
|
return new FakeVendorDatabase()
|
.registeredChannel(DEV_UID, 1, 1)
|
.registeredChannel(DEV_UID, 1, 2)
|
.registeredChannel(DEV_UID, 2, 1)
|
.test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 440L, "2026-09-18 09:00:00",
|
ACTIVE_TABLE, null))
|
// 已退役设备的历史数据:登记表里没有它,不得成为候选。
|
.test(new FakeVendorDatabase.TestRow(240098L, 1, 1, 900L, "2021-09-30 09:00:00",
|
"data_240098_2021_09_30", null))
|
.sample(new FakeVendorDatabase.SampleRow(ACTIVE_TABLE, 440L, 1, 2, 7435L, 3, 12, "2", 300000L,
|
13733, -11889, 50350024, 1103331, 50350024, 1103331, "2026-09-28 09:54:56"))
|
.sample(new FakeVendorDatabase.SampleRow(ACTIVE_TABLE, 440L, 1, 2, 7436L, 3, 9, "4", 0L,
|
13700, 0, 0, 0, 0, 0, "2026-09-28 09:54:56"));
|
}
|
|
@Test
|
void discoveryListsRegisteredChannelsWithLastDataTimeOnlyFromSampleRows()
|
{
|
List<DetectedChannel> channels = adapter(activeDeviceDatabase()).discoverChannels();
|
assertEquals(3, channels.size());
|
// 候选集合来自通道登记表:只出现在历史数据里的退役设备不出现。
|
assertTrue(channels.stream().allMatch(item -> item.identity().devid() == 36));
|
DetectedChannel active = channels.stream()
|
.filter(item -> item.identity().chlid() == 2).findFirst().orElseThrow();
|
assertEquals(new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2), active.identity());
|
assertTrue(active.available());
|
// 最近数据时间取该通道最新采样行的设备时间(按适配器时区解释):Asia/Hong_Kong 09:54:56 = 01:54:56Z。
|
assertEquals(Instant.parse("2026-09-28T01:54:56Z"), active.lastDataAt());
|
// 没有采样行的通道不报最近数据时间:活跃只看有没有新采样行写入。
|
DetectedChannel idle = channels.stream()
|
.filter(item -> item.identity().chlid() == 1 && item.identity().subdevid() == 1)
|
.findFirst().orElseThrow();
|
assertNull(idle.lastDataAt());
|
}
|
|
@Test
|
void latestTestIsPickedByStartTimeNotByTestId()
|
{
|
FakeVendorDatabase database = new FakeVendorDatabase()
|
.registeredChannel(DEV_UID, 1, 2)
|
// 测试编号更大的那条起始时间更早:按 test_id 排序会取错。
|
.test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 2818575907L, "2026-02-20 09:00:00",
|
"data_210036_2026_02_20", null))
|
.test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 2818575540L, "2026-09-28 09:00:00",
|
"data_210036_2026_09_28", null))
|
.sample(new FakeVendorDatabase.SampleRow("data_210036_2026_02_20", 2818575907L, 1, 2, 10L, 1, 1, "1",
|
1000L, 5000, 1000, 10, 10, 10, 10, "2026-02-20 09:00:01"))
|
.sample(new FakeVendorDatabase.SampleRow("data_210036_2026_09_28", 2818575540L, 1, 2, 20L, 4, 7, "2",
|
60000L, 31541, -12000, 700000, 900000, 800000, 1000000, "2026-09-28 09:01:00"));
|
CabinetRealtime latest = adapter(database).latestCabinet(CHANNEL);
|
assertEquals(20L, latest.sequenceId());
|
assertEquals(Instant.parse("2026-09-28T01:01:00Z"), latest.deviceTime());
|
}
|
|
@Test
|
void shardTableComesFromTheTestRecordInsteadOfTheDeviceTimeDate()
|
{
|
// 实测表名按建表日:9-28 启动的测试写进 9-18 的表。适配器必须用测试记录给出的表名。
|
CabinetRealtime latest = adapter(activeDeviceDatabase()).latestCabinet(CHANNEL);
|
assertEquals("2026-09-28 09:54:56", latest.deviceTimeRaw());
|
assertEquals(Instant.parse("2026-09-28T01:54:56Z"), latest.deviceTime());
|
assertEquals("Asia/Hong_Kong", latest.timezoneId());
|
assertEquals(CLOCK.instant(), latest.serverReceivedAt());
|
}
|
|
@Test
|
void sampledValuesAreConvertedWithTheFieldMeasuredFormula()
|
{
|
// 同秒两行的第一行承载本次实测的换算样本。
|
List<CabinetRealtime> rows = adapter(activeDeviceDatabase()).samplesSince(CHANNEL,
|
new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7434L));
|
CabinetRealtime latest = rows.get(0);
|
// test_vol 13733 → 1.3733 V;test_cur -11889 → -1.1889 A;放电容量与能量除以 36000000。
|
assertEquals(1.3733, latest.voltageV(), 1e-9);
|
assertEquals(-1.1889, latest.currentA(), 1e-9);
|
assertEquals(50350024d / 36000000d, latest.capacityAh(), 1e-9);
|
assertEquals(1103331d / 36000000d, latest.energyWh(), 1e-9);
|
// test_time 单位为毫秒,作为工步内相对时长输出秒。
|
assertEquals(300L, latest.relativeTimeSeconds());
|
assertEquals(3, latest.cycleNo());
|
assertEquals(12, latest.stepNo());
|
assertEquals("2", latest.stepType());
|
assertEquals("neware:440:7435", latest.sourceRecordId());
|
// 工步类型保留设备给出的数字,不按电流符号猜方向;同一秒的另一行靠工步与序号区分。
|
assertEquals(9, rows.get(1).stepNo());
|
}
|
|
@Test
|
void onlineFactIsSourceReachabilityAndChannelWorkStatusStaysUnobservable()
|
{
|
NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
|
Map<ChannelIdentity, RealtimeSnapshot> snapshots = adapter.inquire(Set.of(CHANNEL));
|
RealtimeSnapshot snapshot = snapshots.get(CHANNEL);
|
assertEquals(DeviceConnectionStatus.ONLINE, snapshot.connectionStatus());
|
// 读库路线下通道工作状态不可观测,不按数据行推断。
|
assertEquals(DeviceWorkStatus.UNOBSERVABLE, snapshot.workStatus());
|
assertFalse(adapter.capabilities().control());
|
// 辅助测量来自导入,不来自新威辅助表族。
|
assertNull(adapter.latestCollector(ADAPTER_CODE, "anything"));
|
assertEquals(NewareMysqlAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL));
|
}
|
|
@Test
|
void samplesSinceKeepsEveryRowInTheSameSecondAndDropsRowsAtOrBeforeTheWatermark()
|
{
|
NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
|
SampleWatermark watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7434L);
|
List<CabinetRealtime> rows = adapter.samplesSince(CHANNEL, watermark);
|
// 同一绝对秒内的两行都保留,并且靠协议序号区分。
|
assertEquals(2, rows.size());
|
assertEquals(List.of(7435L, 7436L), rows.stream().map(CabinetRealtime::sequenceId).toList());
|
assertEquals(List.of("neware:440:7435", "neware:440:7436"),
|
rows.stream().map(CabinetRealtime::sourceRecordId).toList());
|
// 严格大于水位:等于水位的行不返回。
|
assertEquals(1, adapter.samplesSince(CHANNEL,
|
new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7435L)).size());
|
// 水位之后没有新行时返回空。
|
assertTrue(adapter.samplesSince(CHANNEL,
|
new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L)).isEmpty());
|
}
|
|
@Test
|
void samplesWithoutWatermarkReturnNothingSoDeviceHistoryNeverEntersTheTest()
|
{
|
NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
|
assertTrue(adapter.samplesSince(CHANNEL, null).isEmpty());
|
}
|
|
@Test
|
void emptyWatermarkMeansTheChannelHadNoRowsSoEveryLaterRowBelongsToTheReception()
|
{
|
// 边界时该通道在来源里没有任何行(例如通道尚未开始测试),之后出现的行全部属于本次接收。
|
List<CabinetRealtime> rows = adapter(activeDeviceDatabase())
|
.samplesSince(CHANNEL, new SampleWatermark(null, 0L));
|
assertEquals(2, rows.size());
|
}
|
|
@Test
|
void watermarkIsTheLatestRowOfTheLatestTest()
|
{
|
SampleWatermark watermark = adapter(activeDeviceDatabase()).currentWatermark(CHANNEL);
|
assertEquals(Instant.parse("2026-09-28T01:54:56Z"), watermark.deviceTime());
|
assertEquals(7436L, watermark.sequenceId());
|
// 通道没有测试记录时水位为空,接收路径不把设备历史并入本测试。
|
NewareMysqlAdapter empty = adapter(new FakeVendorDatabase().registeredChannel(DEV_UID, 1, 2));
|
assertNull(empty.currentWatermark(CHANNEL).deviceTime());
|
}
|
|
@Test
|
void rejectsChannelIdentitiesThatBelongToAnotherInstance()
|
{
|
NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
|
ChannelIdentity foreign = new ChannelIdentity("other-source", 21, 36, 1, 2);
|
org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> adapter.latestCabinet(foreign));
|
assertTrue(adapter.discoverChannels().stream()
|
.allMatch(item -> ADAPTER_CODE.equals(item.identity().adapterCode())));
|
}
|
|
// ---------------------------------------------------------------- 工步层与循环聚合
|
|
/**
|
* 工步记录来自设备工步层:每工步一条,取该工步的结束行;四组容量能量与中值电压放在扩展测量里,
|
* 中值电压由设备字段承载,不用峰值或谷值替代。
|
*/
|
@Test
|
void stepRecordsComeFromTheDeviceStepLayerAndCarryAllFourCapacityGroups()
|
{
|
List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL);
|
// 只有进入行的工步仍在进行中,不出现:设备还没写后续工步的行。
|
assertEquals(9, steps.size());
|
assertTrue(steps.stream().noneMatch(step -> step.cycleNo() == 2));
|
|
StepRecord rest = step(steps, 0, 1);
|
assertEquals("4", rest.stepType());
|
assertNull(rest.stepName());
|
assertEquals(Instant.parse("2026-09-28T01:00:00Z"), rest.startedAt());
|
assertEquals(Instant.parse("2026-09-28T01:00:38Z"), rest.endedAt());
|
assertEquals(1.3588, rest.endVoltageV(), 1e-9);
|
// 工步记录同时带结束时的电流读数。
|
assertEquals(0d, rest.endCurrentA(), 1e-9);
|
assertEquals(-1.1889, step(steps, 1, 11).endCurrentA(), 1e-9);
|
// 工步时间是设备给的 test_time(毫秒 → 秒),不是按采样时间推算的。
|
assertEquals(0, new java.math.BigDecimal("38.800").compareTo(rest.measurements().get("stepDuration")));
|
assertEquals(0, new java.math.BigDecimal("1.358800000000").compareTo(rest.measurements().get("startVoltage")));
|
|
StepRecord charge = step(steps, 0, 4);
|
assertEquals("1", charge.stepType());
|
// 单项容量只在方向已确认时给出:充电工步取充电侧。
|
assertEquals(119277040d / 36000000d, charge.capacityAh(), 1e-9);
|
assertEquals(195220576d / 36000000d, charge.energyWh(), 1e-9);
|
// 充电中值电压直接取设备工步层的 middle_vol,设备给零就保留零。
|
assertEquals(0, java.math.BigDecimal.ZERO.compareTo(charge.measurements().get("chargeMedianVoltage")));
|
assertNull(charge.measurements().get("dischargeMedianVoltage"));
|
|
StepRecord discharge = step(steps, 0, 6);
|
assertEquals("2", discharge.stepType());
|
assertEquals(68462032d / 36000000d, discharge.capacityAh(), 1e-9);
|
assertTrue(discharge.measurements().containsKey("dischargeMedianVoltage"));
|
// 四组容量能量都是设备事实:每一组都保留,不只保留方向匹配的那一组。
|
for (String field : List.of("chargeCapacity", "dischargeCapacity", "chargeEnergy", "dischargeEnergy"))
|
assertTrue(charge.measurements().containsKey(field) && discharge.measurements().containsKey(field));
|
assertEquals("neware:440:step:0:4", charge.sourceRecordId());
|
assertEquals("Asia/Hong_Kong", charge.timezoneId());
|
assertEquals(charge.endedAt(), charge.serverReceivedAt());
|
}
|
|
/** 未知工步类型保留明细并保持未知:不按电流符号,也不按容量出现在哪一组去猜方向。 */
|
@Test
|
void unknownStepTypeKeepsItsDetailAndStaysOutOfTheCycleAggregate()
|
{
|
List<StepRecord> steps = adapter(stepLayerDatabase()).stepRecords(CHANNEL);
|
StepRecord unknown = step(steps, 1, 3);
|
assertEquals("7", unknown.stepType());
|
// 单项容量留空:方向未确认时不给出可归属的单值,但四组明细照原样保留供核对。
|
assertNull(unknown.capacityAh());
|
assertNull(unknown.energyWh());
|
assertEquals(500000000d / 36000000d, unknown.measurements().get("chargeCapacity").doubleValue(), 1e-9);
|
|
CycleRecord cycle = cycle(adapter(stepLayerDatabase()).cycleRecords(CHANNEL), 1);
|
// 未知方向的那一步容量没有进任何一侧:本循环的充放电仍只由已知方向的工步构成。
|
assertEquals((119267664d + 85510512d) / 36000000d, cycle.chargeCapacityAh(), 1e-9);
|
assertEquals((67587520d + 47559464d) / 36000000d, cycle.dischargeCapacityAh(), 1e-9);
|
}
|
|
/**
|
* 循环统计由工步层按循环聚合并标注为聚合来源:同一循环内同方向的工步容量求和
|
* (实测该化成协议 cycle 1 有两个充电工步与两个放电工步),效率是放电容量除以充电容量。
|
*/
|
@Test
|
void cycleRecordsAreAggregatedFromTheStepLayerAndMarkedAsAggregated()
|
{
|
List<CycleRecord> cycles = adapter(stepLayerDatabase()).cycleRecords(CHANNEL);
|
// 仍在进行的循环整体不出现:工步行还没写完,聚合出来的是半个循环。
|
assertEquals(2, cycles.size());
|
|
CycleRecord first = cycle(cycles, 0);
|
assertEquals(119277040d / 36000000d, first.chargeCapacityAh(), 1e-9);
|
// 两个放电工步求和:16219380 + 68462032。
|
assertEquals(84681412d / 36000000d, first.dischargeCapacityAh(), 1e-9);
|
assertEquals(195220576d / 36000000d, first.chargeEnergyWh(), 1e-9);
|
assertEquals(40000000d / 36000000d, first.dischargeEnergyWh(), 1e-9);
|
assertEquals(70.9956, first.efficiency(), 1e-3);
|
// 来源类别是聚合:界面与导出据此标注"这个数是推导出来的"。
|
assertEquals(CycleRecord.SOURCE_AGGREGATED, first.sourceKind());
|
assertEquals("neware:440:cycle:0", first.sourceRecordId());
|
// 循环的起止取该循环首末工步的设备时间,服务器接收时间同样取设备时刻,暂停与继续据此留白。
|
assertEquals(Instant.parse("2026-09-28T01:00:00Z"), first.startedAt());
|
assertEquals(Instant.parse("2026-09-28T14:04:47Z"), first.endedAt());
|
assertEquals(first.endedAt(), first.serverReceivedAt());
|
|
CycleRecord second = cycle(cycles, 1);
|
assertEquals((119267664d + 85510512d) / 36000000d, second.chargeCapacityAh(), 1e-9);
|
assertEquals((67587520d + 47559464d) / 36000000d, second.dischargeCapacityAh(), 1e-9);
|
assertEquals(56.2301, second.efficiency(), 1e-3);
|
}
|
|
/** 循环没有可用充电容量时效率留空:未知比率不写成零。 */
|
@Test
|
void cycleEfficiencyStaysEmptyWhenThereIsNoChargeCapacity()
|
{
|
FakeVendorDatabase database = new FakeVendorDatabase()
|
.registeredChannel(DEV_UID, 1, 5)
|
.test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 5, 440L, "2026-09-28 09:00:00", ACTIVE_TABLE, null))
|
.capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 1L, 9, 1, "2", 3600000L,
|
13000, -5000, 0, 0, 50000000, 9000000, 0, "2026-09-28 12:00:00"))
|
.capStep(new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 5, 2L, 10, 1, "4", 60000L,
|
13000, 0, 0, 0, 0, 0, 0, "2026-09-28 12:01:00"));
|
List<CycleRecord> cycles = adapter(database)
|
.cycleRecords(new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 5));
|
assertEquals(1, cycles.size());
|
assertEquals(0d, cycles.get(0).chargeCapacityAh(), 1e-9);
|
assertNull(cycles.get(0).efficiency());
|
}
|
|
/** 没有测试记录、没有工步行或通道属于别实例时,工步与循环都保持为空而不报错。 */
|
@Test
|
void stepAndCycleRecordsStayEmptyWithoutAStepLayerOrTest()
|
{
|
NewareMysqlAdapter withoutTest = adapter(new FakeVendorDatabase().registeredChannel(DEV_UID, 1, 2));
|
assertTrue(withoutTest.stepRecords(CHANNEL).isEmpty());
|
assertTrue(withoutTest.cycleRecords(CHANNEL).isEmpty());
|
assertTrue(adapter(activeDeviceDatabase()).stepRecords(CHANNEL).isEmpty());
|
org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class,
|
() -> adapter(activeDeviceDatabase()).cycleRecords(new ChannelIdentity("other-source", 21, 36, 1, 2)));
|
}
|
|
private static StepRecord step(List<StepRecord> steps, int cycleNo, int stepNo)
|
{
|
return steps.stream().filter(item -> item.cycleNo() == cycleNo && item.stepNo() == stepNo)
|
.findFirst().orElseThrow();
|
}
|
|
private static CycleRecord cycle(List<CycleRecord> cycles, int cycleNo)
|
{
|
return cycles.stream().filter(item -> item.cycleNo() == cycleNo).findFirst().orElseThrow();
|
}
|
|
/**
|
* 工步层假厂商库:cycle 0 走完搁置、放电、充电、放电,cycle 1 有两个充电工步、两个放电工步
|
* 与一个未知类型工步;末组(cycle 2 的 step 9)只有进入行——它仍在进行中。
|
*/
|
private FakeVendorDatabase stepLayerDatabase()
|
{
|
return new FakeVendorDatabase()
|
.registeredChannel(DEV_UID, 1, 2)
|
.test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 440L, "2026-09-28 09:00:00",
|
ACTIVE_TABLE, null))
|
// cycle 0:搁置、放电、充电、放电;结束行承载该工步累计容量与能量。
|
.capStep(step(1L, 0, 1, "4", 0L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:00"))
|
.capStep(step(2L, 0, 1, "4", 38800L, 13588, 0, 0, 0, 0, 0, "2026-09-28 09:00:38"))
|
.capStep(step(3L, 0, 2, "2", 0L, 13222, -4735, 0, 0, 0, 0, "2026-09-28 09:00:38"))
|
.capStep(step(4L, 0, 2, "2", 3425000L, 9998, -4736, 0, 0, 16219380, 10000000,
|
"2026-09-28 10:04:37"))
|
.capStep(step(5L, 0, 4, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 11:03:51"))
|
.capStep(step(6L, 0, 4, "1", 25200000L, 17085, 4733, 119277040, 195220576, 0, 0,
|
"2026-09-28 18:03:51"))
|
.capStep(step(7L, 0, 6, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-28 18:03:51"))
|
.capStep(step(8L, 0, 6, "2", 14455700L, 9998, -4736, 0, 0, 68462032, 30000000,
|
"2026-09-28 22:04:47"))
|
// cycle 1:两个充电工步 + 两个放电工步 + 一个未知类型工步。
|
.capStep(step(9L, 1, 1, "1", 0L, 13507, 4732, 0, 0, 0, 0, "2026-09-28 22:04:47"))
|
.capStep(step(10L, 1, 1, "1", 25200000L, 17085, 4733, 119267664, 196020320, 0, 0,
|
"2026-09-29 05:04:47"))
|
.capStep(step(11L, 1, 2, "2", 0L, 14574, -4735, 0, 0, 0, 0, "2026-09-29 05:04:47"))
|
.capStep(step(12L, 1, 2, "2", 14271500L, 9998, -4736, 0, 0, 67587520, 86258072,
|
"2026-09-29 09:02:38"))
|
// 未知类型 7:充电组有值,但方向未确认,所以不进聚合、单项容量留空。
|
.capStep(step(13L, 1, 3, "7", 0L, 13000, 500, 500000000, 900000000, 0, 0, "2026-09-29 09:02:39"))
|
.capStep(step(14L, 1, 3, "7", 600000L, 13200, 600, 500000000, 900000000, 0, 0,
|
"2026-09-29 09:12:39"))
|
.capStep(step(15L, 1, 9, "1", 0L, 13092, 11860, 0, 0, 0, 0, "2026-09-29 09:12:40"))
|
.capStep(step(16L, 1, 9, "1", 7200000L, 18176, 11875, 85510512, 145614320, 0, 0,
|
"2026-09-29 11:12:40"))
|
.capStep(step(17L, 1, 11, "2", 0L, 14313, -11878, 0, 0, 0, 0, "2026-09-29 11:12:41"))
|
.capStep(step(18L, 1, 11, "2", 4001000L, 9998, -11889, 0, 0, 47559464, 58816460,
|
"2026-09-29 12:19:21"))
|
// 仍在进行的工步:只有进入行。
|
.capStep(step(19L, 2, 9, "1", 0L, 13774, 11871, 0, 0, 0, 0, "2026-09-29 12:19:22"));
|
}
|
|
/** 工步层一行的简写:本用例只用本通道的数据,中值电压按现场实测给出零。 */
|
private static FakeVendorDatabase.CapStepRow step(long seqId, int cycle, int stepId, String stepType,
|
long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge,
|
int capacityDischarge, int energyDischarge, String testAtime)
|
{
|
return new FakeVendorDatabase.CapStepRow(440L, DEV_UID, 1, 2, seqId, cycle, stepId, stepType,
|
testTimeMillis, voltage, current, capacityCharge, energyCharge, capacityDischarge, energyDischarge,
|
0, testAtime);
|
}
|
|
/** 凭据只用现场取得的服务端只读账号;已知的全权限账号不进入配置。 */
|
@Test
|
void credentialsFallBackToTheReadOnlyAccountAndRefuseFullPrivilegeAccounts()
|
{
|
assertEquals("visitor", NewareMysqlAdapterFactory.readOnlyUser(Map.of()));
|
assertEquals("visitor", NewareMysqlAdapterFactory.readOnlyUser(Map.of("user", " ")));
|
assertEquals("another_ro", NewareMysqlAdapterFactory.readOnlyUser(Map.of("user", "another_ro")));
|
for (String account : List.of("BtsServer", "btsserver", "root", "ROOT"))
|
{
|
var failure = org.junit.jupiter.api.Assertions.assertThrows(
|
com.battery.device.api.DeviceAdapterException.class,
|
() -> NewareMysqlAdapterFactory.readOnlyUser(Map.of("user", account)));
|
assertTrue(failure.getMessage().contains(account));
|
}
|
}
|
|
/**
|
* 口令取值顺序:本地凭据文件 → 环境变量 → 启动参数;
|
* 三者都没有时失败信息同时点名文件路径与键名,使"忘了放口令"是可见的失败。
|
*/
|
@Test
|
void credentialPrefersTheLocalFileThenEnvironmentThenStartupParameter() throws Exception
|
{
|
java.nio.file.Path directory = java.nio.file.Files.createTempDirectory("vendor-credentials");
|
java.nio.file.Path file = directory.resolve("vendor-credentials.properties");
|
String key = "BATTERY_VENDOR_DB_PASSWORD";
|
try
|
{
|
// 文件、环境变量、启动参数都在时取文件。
|
java.nio.file.Files.writeString(file, key + "=from-file\n");
|
assertEquals("from-file", LocalCredentials.resolve(key, file,
|
Map.of(key, "from-env"), name -> "from-parameter"));
|
|
// 没有文件时退到环境变量。
|
java.nio.file.Files.delete(file);
|
assertEquals("from-env", LocalCredentials.resolve(key, file,
|
Map.of(key, "from-env"), name -> "from-parameter"));
|
|
// 前两者都没有时退到启动参数。
|
assertEquals("from-parameter", LocalCredentials.resolve(key, file,
|
Map.of(), name -> "from-parameter"));
|
|
// 三者都没有:失败信息里同时出现文件路径与键名。
|
var failure = org.junit.jupiter.api.Assertions.assertThrows(
|
com.battery.device.api.DeviceAdapterException.class,
|
() -> LocalCredentials.resolve(key, file, Map.of(), name -> null));
|
assertTrue(failure.getMessage().contains(file.toString()));
|
assertTrue(failure.getMessage().contains(key));
|
}
|
finally
|
{
|
java.nio.file.Files.deleteIfExists(file);
|
java.nio.file.Files.deleteIfExists(directory);
|
}
|
}
|
|
/** 凭据文件里的空白值不算配置过:否则一个空模板会把失败推到连接阶段。 */
|
@Test
|
void blankCredentialInTheFileIsTreatedAsMissing() throws Exception
|
{
|
java.nio.file.Path directory = java.nio.file.Files.createTempDirectory("vendor-credentials-blank");
|
java.nio.file.Path file = directory.resolve("vendor-credentials.properties");
|
String key = "BATTERY_VENDOR_DB_PASSWORD";
|
try
|
{
|
java.nio.file.Files.writeString(file, key + "=\n");
|
assertEquals("from-env", LocalCredentials.resolve(key, file, Map.of(key, "from-env"),
|
name -> null));
|
}
|
finally
|
{
|
java.nio.file.Files.deleteIfExists(file);
|
java.nio.file.Files.deleteIfExists(directory);
|
}
|
}
|
}
|