Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e55bdd0
docs: add TDD landing plan spec for GrowingAnalytics
YoloMao Apr 7, 2026
b025b45
test: setup test directory structure and add hypium/hamock dependency
YoloMao Apr 7, 2026
03eddad
test: add Phase 1 pure function tests for Util.ts
YoloMao Apr 7, 2026
9e40e73
test: add Phase 1 GrowingConfig validation tests
YoloMao Apr 7, 2026
b477f8b
refactor: add _reset() to static singleton classes for test isolation
YoloMao Apr 7, 2026
0b8f2b9
test: add Phase 2 EventTimer state machine tests
YoloMao Apr 7, 2026
d4616d1
test: add Phase 2 Session core decision logic tests
YoloMao Apr 7, 2026
6a3edab
refactor: extract IO layer interfaces and add in-memory test doubles
YoloMao Apr 7, 2026
60aaf12
test: add Phase 3 EventPersistence serialization tests
YoloMao Apr 7, 2026
954e9ad
test: add Phase 3 EventSender dispatch logic tests
YoloMao Apr 7, 2026
d9196e3
test: add Phase 4 Network URL and header generation tests
YoloMao Apr 7, 2026
f6e9701
docs: update tdd-plan spec with completed step checkboxes
YoloMao Apr 7, 2026
7b15295
test: fix ArkTS type violations in unit test files
YoloMao Apr 7, 2026
081e3e8
test: fix remaining ArkTS type issues in test files
YoloMao Apr 7, 2026
c0ac1df
test: fix all ArkTS strict-mode violations in unit test files
YoloMao Apr 7, 2026
2588dd8
test: replace JSON.parse() with TypeScript JsonParser helper
YoloMao Apr 7, 2026
a4de15f
test: rename test files to xxx.test.ets convention and flatten testsuite
YoloMao Apr 8, 2026
39db119
test: fix invalid describe names to comply with hypium naming rules
YoloMao Apr 8, 2026
2ac51b2
fix: always remove xcontent in toSerializeByMeasurementProtocolV3 reg…
YoloMao Apr 8, 2026
b4475f1
test: fix hypium beforeEach scope causing inter-test state pollution
YoloMao Apr 8, 2026
faa0712
refactor: extract runSendCycle pure function from EventSender for tes…
YoloMao Apr 8, 2026
1a09a47
fix: add explicit return type and missing afterRemove in SendCycleIO …
YoloMao Apr 8, 2026
d37378d
test: fix xcontent null assertion to match SDK behavior
YoloMao Apr 8, 2026
184c2b3
chore: remove obsolete tdd-plan.md
YoloMao Apr 8, 2026
8e5a025
fix: guard EventSender.sendEvent against null context after tracker r…
YoloMao Apr 8, 2026
ba2ce6a
test: add unit tests for GeneralProps, Context and UserIdentifier
YoloMao Apr 8, 2026
4868be7
test: add unit tests for EventBuilder, PageEvent, CustomEvent and Vie…
YoloMao Apr 9, 2026
5255419
test: add unit tests for DeviceInfo.isNotIgnoreField and Queue
YoloMao Apr 9, 2026
03a4a24
chore: add GrowingAnalyticsTest run configuration
YoloMao Apr 9, 2026
8523758
refactor: replace closure-shared lastRequest with explicit SendResult…
YoloMao Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .run/GrowingAnalyticsTest.run.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="GrowingAnalyticsTest" type="LocalTest">
<HISTORY_PROJECT_MODEL value="{&quot;compileSdkVersion&quot;:20,&quot;historyProductMap&quot;:{&quot;default&quot;:&quot;default&quot;},&quot;projectModelType&quot;:&quot;HosProjectModelV2&quot;,&quot;projectPath&quot;:&quot;$PROJECT_DIR$&quot;,&quot;runtimeOSMap&quot;:{&quot;default&quot;:&quot;HARMONY_OS&quot;}}" />
<HISTORY_MODULE_MODEL value="{&quot;apiType&quot;:&quot;stageMode&quot;,&quot;language&quot;:&quot;ets&quot;,&quot;moduleName&quot;:&quot;GrowingAnalytics&quot;,&quot;modulePath&quot;:&quot;$PROJECT_DIR$/GrowingAnalytics&quot;}" />
<test_type value="2" />
<TESTING_TYPE value="0" />
<PACKAGE_NAME value="test" />
<WHOLE_PACKAGE_NAME value="$PROJECT_DIR$/GrowingAnalytics/src/test" />
<JS_SUITE_NAME value="" />
<KEEP_DATA value="false" />
<TIMEOUT value="15" />
<MODULE_NAME>GrowingAnalytics</MODULE_NAME>
<method v="2">
<option name="LocalTest.Build.Hvigor.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>
2 changes: 2 additions & 0 deletions GrowingAnalytics/consumer-rules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ CREATE_AT
SDK_VERSION
ACCOUNT_ID
DATASOURCE_ID
# --------- test-only helpers (do NOT call in production code) ---------
_reset
# --------- other/reserved ---------
gioId
globalSequenceId
Expand Down
5 changes: 4 additions & 1 deletion GrowingAnalytics/oh-package.json5
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"Tool"
],
"license": "Apache License 2.0",
"devDependencies": {},
"devDependencies": {
"@ohos/hypium": "1.0.21",
"@ohos/hamock": "1.0.2"
},
"name": "@growingio/analytics"
}
6 changes: 6 additions & 0 deletions GrowingAnalytics/src/main/ets/components/core/Context.ets
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,10 @@ export default class GrowingContext {
static isDefaultContext(context: GrowingContext): boolean {
return context == GrowingContext.defaultContext
}

/** 仅供单元测试使用:重置所有静态状态,保证用例间相互隔离 */
static _reset() {
GrowingContext.contexts = new Map()
GrowingContext.defaultContext = undefined
}
}
5 changes: 5 additions & 0 deletions GrowingAnalytics/src/main/ets/components/core/EventTimer.ets
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,9 @@ export default class EventTimer {
let duration = endTime - startTime
return (duration > 0 && duration < 24 * 60 * 60 * 1000) ? duration : 0
}

/** 仅供单元测试使用:重置所有静态状态,保证用例间相互隔离 */
static _reset() {
EventTimer.timers = new Map()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,12 @@ export default class GeneralProps {
let properties = Util.concatObject(props, dynamicProps)
return properties
}

/** 仅供单元测试使用:重置所有静态状态,保证用例间相互隔离 */
static _reset() {
GeneralProps._internalProps = {}
GeneralProps._dynamicGeneralPropsGenerator = () => {
return {}
}
}
}
6 changes: 6 additions & 0 deletions GrowingAnalytics/src/main/ets/components/core/Session.ets
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,10 @@ export default class Session {
let e = AppClosedEvent.create(context)
AnalyticsCore.writeEventToDisk(e, context)
}

/** 仅供单元测试使用:重置所有静态状态,保证用例间相互隔离 */
static _reset() {
Session.sessions = []
Session.sessionState = SessionState.Foreground
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,10 @@ export default class UserIdentifier {
}
}
}

/** 仅供单元测试使用:重置所有静态状态,保证用例间相互隔离 */
static _reset() {
UserIdentifier.users = new Map()
UserIdentifier.userInfo = JSON.parse('{}') as object
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,9 @@ export default class EventBuilder {
let key = context.config.accountId + '_' + context.config.dataSourceId
return EventBuilder._eventSequenceIds[key] ?? 0
}

/** 仅供单元测试使用:重置所有静态状态,保证用例间相互隔离 */
static _reset() {
EventBuilder._eventSequenceIds = {}
}
}
118 changes: 91 additions & 27 deletions GrowingAnalytics/src/main/ets/components/event/EventSender.ets
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
* limitations under the License.
*/

import rcp from '@hms.collaboration.rcp'
import emitter from '@ohos.events.emitter'
import EventDatabase from './EventDatabase'
import EventPersistence from './EventPersistence'
import Network from '../core/Network'
import GrowingContext from '../core/Context'
import { EMIT_EVENT_DATABASE_FLUSH, REQUEST_MAX_EVENT_COUNT, REQUEST_MAX_EVENT_SIZE } from '../utils/Constants'
Expand All @@ -28,6 +30,59 @@ const URL_PATH_NEW_SAAS = '/v3/projects/accountId/collect'
const URL_PATH_SAAS_PV = '/v3/accountId/harmonyos/pv'
const URL_PATH_SAAS_CSTM = '/v3/accountId/harmonyos/cstm'

/**
* sendEvents 的返回结果,将 statusCode 和 request 显式绑定,
* 避免调用方通过闭包共享可变状态。
*/
export interface SendResult {
statusCode: number
request: rcp.Request
}

/**
* 上报调度核心 IO 依赖
*
* 通过回调注入的方式将调度决策逻辑与平台 IO 解耦,
* 使 runSendCycle 成为可独立测试的纯调度函数。
* 每个回调职责单一,回调之间不共享可变状态。
*/
export interface SendCycleIO {
fetchEvents: () => Promise<EventPersistence[]>
sendEvents: (events: EventPersistence[]) => Promise<SendResult>
removeEvents: (events: EventPersistence[]) => Promise<void>
afterRemove: (events: EventPersistence[], request: rcp.Request) => void
countRemaining: () => Promise<number>
}

/**
* 单次上报调度循环(纯调度逻辑,不依赖任何平台 API)
*
* 决策规则:
* - DB 为空 → 不发请求,返回 false
* - 发送成功(2xx-3xx) → 删除事件,若还有更多返回 true,否则 false
* - 发送失败(4xx/5xx)→ 不删除,返回 false
* - sendEvents 抛出异常 → 不删除,异常向上传播
*
* @returns true 表示还有事件需要继续发送(调用方负责递归)
*/
export async function runSendCycle(io: SendCycleIO): Promise<boolean> {
const events = await io.fetchEvents()
if (events.length === 0) {
return false
}

const result = await io.sendEvents(events)

if (result.statusCode >= 200 && result.statusCode < 400) {
await io.removeEvents(events)
io.afterRemove(events, result.request)
const count = await io.countRemaining()
return count > REQUEST_MAX_EVENT_COUNT
}

return false
}

export enum EventSenderType {
NewSaaS = 0,
SaaS_PV,
Expand Down Expand Up @@ -81,38 +136,47 @@ export default class EventSender {
if (this.isUploading) {
return
}
this.isUploading = true

let context = gContext ?? GrowingContext.getContext(this.trackerId) as GrowingContext
let events = await EventDatabase.getEventsByCount(
REQUEST_MAX_EVENT_COUNT,
REQUEST_MAX_EVENT_SIZE,
context,
this.eventTypes
)
if (events.length == 0) {
this.isUploading = false
let maybeContext = gContext ?? GrowingContext.getContext(this.trackerId)
if (!maybeContext) {
return
}
const context: GrowingContext = maybeContext

this.isUploading = true

try {
let response = await Network.request(events, this.urlPath, context)
Plugins.onResponseReceive(response)
if (response.statusCode >= 200 && response.statusCode < 400) {
LogUtil.info(() => 'Request Success, Url = ' + response.request.url)
LogUtil.info(() => 'Send event = ' + response.request.content)

await EventDatabase.removeEvents([...events])
Plugins.onEventsDidSend(events, response.request)
Plugins.onEventsRemoveFromDisk(events.map(e => e.uuid))
this.isUploading = false
let count = await EventDatabase.countOfEvents(context, this.eventTypes)
if (count > REQUEST_MAX_EVENT_COUNT) {
this.sendEvent(context)
}
} else {
LogUtil.info(() => 'Request Failure, responseCode: ' + String(response.statusCode))
this.isUploading = false
const urlPath = this.urlPath
const eventTypes = this.eventTypes
const hasMore = await runSendCycle({
fetchEvents: () => EventDatabase.getEventsByCount(
REQUEST_MAX_EVENT_COUNT,
REQUEST_MAX_EVENT_SIZE,
context,
eventTypes
),
sendEvents: async (events: EventPersistence[]): Promise<SendResult> => {
let response = await Network.request(events, urlPath, context)
Plugins.onResponseReceive(response)
if (response.statusCode >= 200 && response.statusCode < 400) {
LogUtil.info(() => 'Request Success, Url = ' + response.request.url)
LogUtil.info(() => 'Send event = ' + response.request.content)
} else {
LogUtil.info(() => 'Request Failure, responseCode: ' + String(response.statusCode))
}
return { statusCode: response.statusCode, request: response.request } as SendResult
},
removeEvents: (events: EventPersistence[]): Promise<void> => EventDatabase.removeEvents([...events]),
afterRemove: (events: EventPersistence[], request: rcp.Request) => {
Plugins.onEventsDidSend(events, request)
Plugins.onEventsRemoveFromDisk(events.map(e => e.uuid))
},
countRemaining: () => EventDatabase.countOfEvents(context, eventTypes)
})

this.isUploading = false
if (hasMore) {
this.sendEvent(context)
}
} catch (e) {
LogUtil.info(() => 'Request Failure, error: ' + JSON.stringify(e))
Expand Down
5 changes: 5 additions & 0 deletions GrowingAnalytics/src/main/ets/components/event/PageEvent.ets
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,9 @@ export default class PageEvent extends Event implements Page {
static setLastPage(page: Page, context: GrowingContext) {
PageEvent.lastPage[context.trackerId] = page
}

/** 仅供单元测试使用:重置所有静态状态,保证用例间相互隔离 */
static _reset() {
PageEvent.lastPage = {}
}
}
47 changes: 44 additions & 3 deletions GrowingAnalytics/src/test/List.test.ets
Original file line number Diff line number Diff line change
@@ -1,5 +1,46 @@
import localUnitTest from './LocalUnit.test';
import utilTests from './utils/Util.test'
import niceTryTests from './utils/NiceTry.test'
import configTests from './interfaces/GrowingConfig.test'
import eventTimerTests from './core/EventTimer.test'
import sessionTests from './core/Session.test'
import generalPropsTests from './core/GeneralProps.test'
import contextTests from './core/Context.test'
import userIdentifierTests from './core/UserIdentifier.test'
import networkTests from './core/Network.test'
import deviceInfoTests from './core/DeviceInfo.test'
import queueTests from './mobileDebugger/Queue.test'
import eventBuilderTests from './event/EventBuilder.test'
import pageEventTests from './event/PageEvent.test'
import customEventTests from './event/CustomEvent.test'
import viewElementEventTests from './event/ViewElementEvent.test'
import eventPersistenceTests from './event/EventPersistence.test'
import eventSendCycleTests from './event/EventSendCycle.test'

export default function testsuite() {
localUnitTest();
}
// Phase 1 · 纯函数层
utilTests()
niceTryTests()
configTests()

// Phase 2 · 有状态业务逻辑层
eventTimerTests()
sessionTests()
generalPropsTests()
contextTests()
userIdentifierTests()

// Phase 3 · 数据管道层
eventBuilderTests()
pageEventTests()
customEventTests()
viewElementEventTests()
eventPersistenceTests()
eventSendCycleTests()

// Phase 4 · 网络层(URL / Header 生成)
networkTests()
deviceInfoTests()

// Phase 5 · 工具类
queueTests()
}
26 changes: 12 additions & 14 deletions GrowingAnalytics/src/test/LocalUnit.test.ets
Original file line number Diff line number Diff line change
@@ -1,33 +1,31 @@
import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium';
import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'

export default function localUnitTest() {
describe('localUnitTest', () => {
describe('LocalUnitTest', () => {
// Defines a test suite. Two parameters are supported: test suite name and test suite function.
beforeAll(() => {
// Presets an action, which is performed only once before all test cases of the test suite start.
// This API supports only one parameter: preset action function.
});
})
beforeEach(() => {
// Presets an action, which is performed before each unit test case starts.
// The number of execution times is the same as the number of test cases defined by **it**.
// This API supports only one parameter: preset action function.
});
})
afterEach(() => {
// Presets a clear action, which is performed after each unit test case ends.
// The number of execution times is the same as the number of test cases defined by **it**.
// This API supports only one parameter: clear action function.
});
})
afterAll(() => {
// Presets a clear action, which is performed after all test cases of the test suite end.
// This API supports only one parameter: clear action function.
});
it('assertContain', 0, () => {
})
it('assertEqual', 0, () => {
// Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function.
let a = 'abc';
let b = 'b';
let a = 'test'
// Defines a variety of assertion methods, which are used to declare expected boolean conditions.
expect(a).assertContain(b);
expect(a).assertEqual(a);
});
});
}
expect(a).assertEqual('test')
})
})
}
Loading
Loading