feat: add growth tracking page and related functionality
- Implemented a new Growth page to track practice time and trends. - Added API integration for fetching review data. - Created components for displaying practice statistics and trends. - Updated navigation titles for the main index and growth pages. - Removed unused styles from the index page. - Introduced a Projects management page for adding and editing practice projects. - Developed a Record form for logging practice sessions with validation. - Added utility functions for date manipulation and duration formatting. - Implemented error handling and session management in the practice service. - Created unit tests for the practice service to ensure reliability.
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
|
||||
// Exercise the exported client against the platform network/storage seam. No
|
||||
// frontend E2E framework or production authentication bypass is introduced.
|
||||
function client(network, options = {}) {
|
||||
const storage = new Map()
|
||||
let loginNumber = 0
|
||||
const taro = {
|
||||
login: async () => ({ code: `login-${++loginNumber}` }),
|
||||
getStorageSync: key => storage.get(key),
|
||||
setStorageSync: (key, value) => storage.set(key, value),
|
||||
removeStorageSync: key => { if (options.storageRemovalFails) throw new Error('storage unavailable'); storage.delete(key) },
|
||||
request: network,
|
||||
}
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/services/practice.ts'), 'utf8')
|
||||
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true } }).outputText
|
||||
const exports = {}
|
||||
vm.runInNewContext(js, { exports, require: name => { assert.equal(name, '@tarojs/taro'); return taro }, __API_BASE_URL__: 'https://local-test.invalid' })
|
||||
return exports
|
||||
}
|
||||
const ok = data => ({ statusCode: 200, data })
|
||||
const session = token => ok({ token, expiresAt: '2099-01-01T00:00:00Z' })
|
||||
const failure = (status, code) => ({ statusCode: status, data: { error: { code, message: code } } })
|
||||
|
||||
// Same public API calls as the pages: a renewed token cannot change a submitted
|
||||
// form or create a fresh operation, including when platform storage removal fails.
|
||||
test('expired sessions renew and retain the exact submission', async () => {
|
||||
let exchange = 0
|
||||
const payloads = []
|
||||
const service = client(async request => {
|
||||
if (request.url.endsWith('/session')) return session(`token-${++exchange}`)
|
||||
payloads.push(request.data)
|
||||
if (request.header.Authorization === 'Bearer token-1') return failure(401, 'unauthorized')
|
||||
return ok({ id: 'record-1' })
|
||||
}, { storageRemovalFails: true })
|
||||
const input = { projectId: 'p', date: '2026-09-28', minutes: 90, note: '', requestId: 'same-submission-1' }
|
||||
assert.equal((await service.api.saveRecord(input)).id, 'record-1')
|
||||
assert.equal(exchange, 2)
|
||||
assert.deepEqual(payloads, [input, input])
|
||||
})
|
||||
|
||||
test('uncertain save stays recoverable and an explicit retry keeps its key', async () => {
|
||||
let lost = true
|
||||
const payloads = []
|
||||
const service = client(async request => {
|
||||
if (request.url.endsWith('/session')) return session('token')
|
||||
payloads.push(request.data)
|
||||
if (lost) { lost = false; throw new Error('response lost') }
|
||||
return ok({ id: 'record-1' })
|
||||
})
|
||||
const pending = { input: { projectId: 'p', date: '2026-09-28', minutes: 90, note: 'keep this', requestId: service.submissionID() } }
|
||||
service.retainPendingRecord(pending)
|
||||
await assert.rejects(service.api.saveRecord(pending.input), error => error.status === 0)
|
||||
const resumed = service.getPendingRecord()
|
||||
assert.equal(resumed, pending)
|
||||
assert.equal((await service.api.saveRecord(resumed.input)).id, 'record-1')
|
||||
assert.equal(payloads[0].requestId, payloads[1].requestId)
|
||||
service.clearPendingRecord(resumed)
|
||||
assert.equal(service.getPendingRecord(), undefined)
|
||||
assert.notEqual(service.submissionID(), pending.input.requestId)
|
||||
})
|
||||
|
||||
test('failed loads stay errors and only confirmed writes notify visible pages', async () => {
|
||||
let failWrite = true
|
||||
const service = client(async request => {
|
||||
if (request.url.endsWith('/session')) return session('token')
|
||||
if (request.method === 'GET') return failure(503, 'service_unavailable')
|
||||
if (failWrite) { failWrite = false; return failure(503, 'service_unavailable') }
|
||||
return ok({ id: 'record-1' })
|
||||
})
|
||||
let changes = 0
|
||||
const stop = service.onPracticeChange(() => { changes++ })
|
||||
await assert.rejects(service.api.records(), error => error.status === 503)
|
||||
await assert.rejects(service.api.saveRecord({}), error => error.status === 503)
|
||||
assert.equal(changes, 0)
|
||||
await service.api.saveRecord({})
|
||||
assert.equal(changes, 1)
|
||||
stop()
|
||||
})
|
||||
Reference in New Issue
Block a user