|
|
@@ -93,8 +93,21 @@ assert.deepEqual(creationState, {
|
|
|
createdTeamId: '',
|
|
|
submitting: false,
|
|
|
stage: 'idle',
|
|
|
- completed: false
|
|
|
+ completed: false,
|
|
|
+ teamPayloadSnapshot: '',
|
|
|
+ teamQuestionnaireId: '',
|
|
|
+ publishResult: null
|
|
|
})
|
|
|
+const firstPayloadSnapshot = rules.createTeamPayloadSnapshot({
|
|
|
+ teamName: '团队', enterpriseWebsite: ' https://example.com ', coachId: 7
|
|
|
+})
|
|
|
+const equivalentPayloadSnapshot = rules.createTeamPayloadSnapshot({
|
|
|
+ enterpriseWebsite: 'https://example.com', teamName: '团队'
|
|
|
+})
|
|
|
+assert.equal(firstPayloadSnapshot, equivalentPayloadSnapshot)
|
|
|
+assert.notEqual(firstPayloadSnapshot, rules.createTeamPayloadSnapshot({
|
|
|
+ teamName: '团队', enterpriseWebsite: 'https://changed.example.com'
|
|
|
+}))
|
|
|
assert.equal(rules.beginTeamSubmission(creationState), true)
|
|
|
assert.equal(rules.beginTeamSubmission(creationState), false, 'a second submission must be locked')
|
|
|
assert.equal(rules.rememberCreatedTeam(creationState, 88), 88)
|
|
|
@@ -545,7 +558,9 @@ function readSfcScript(filePath) {
|
|
|
return source.slice(start + openingTag.length, end)
|
|
|
}
|
|
|
|
|
|
-async function loadVueComponent(filePath, { http = {}, uni = {}, wx = {}, apiRules = rules } = {}) {
|
|
|
+async function loadVueComponent(filePath, {
|
|
|
+ http = {}, uni = {}, wx = {}, apiRules = rules, timers = {}
|
|
|
+} = {}) {
|
|
|
const context = vm.createContext({
|
|
|
uni,
|
|
|
wx,
|
|
|
@@ -556,8 +571,10 @@ async function loadVueComponent(filePath, { http = {}, uni = {}, wx = {}, apiRul
|
|
|
Boolean,
|
|
|
Object,
|
|
|
Array,
|
|
|
- setInterval,
|
|
|
- clearInterval
|
|
|
+ setInterval: timers.setInterval || setInterval,
|
|
|
+ clearInterval: timers.clearInterval || clearInterval,
|
|
|
+ setTimeout: timers.setTimeout || setTimeout,
|
|
|
+ clearTimeout: timers.clearTimeout || clearTimeout
|
|
|
})
|
|
|
const vueStub = new vm.SyntheticModule(['default'], function () {
|
|
|
this.setExport('default', {})
|
|
|
@@ -611,6 +628,39 @@ function createDeferred() {
|
|
|
return { promise, resolve, reject }
|
|
|
}
|
|
|
|
|
|
+function createFakeTimers() {
|
|
|
+ let nextId = 1
|
|
|
+ const pending = new Map()
|
|
|
+ return {
|
|
|
+ setTimeout(callback) {
|
|
|
+ const id = nextId++
|
|
|
+ pending.set(id, callback)
|
|
|
+ return id
|
|
|
+ },
|
|
|
+ clearTimeout(id) {
|
|
|
+ pending.delete(id)
|
|
|
+ },
|
|
|
+ count() {
|
|
|
+ return pending.size
|
|
|
+ },
|
|
|
+ runNext() {
|
|
|
+ const entry = pending.entries().next().value
|
|
|
+ if (!entry) return false
|
|
|
+ pending.delete(entry[0])
|
|
|
+ entry[1]()
|
|
|
+ return true
|
|
|
+ },
|
|
|
+ runAll() {
|
|
|
+ while (this.runNext()) {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function flushPromises() {
|
|
|
+ await Promise.resolve()
|
|
|
+ await new Promise(resolve => setImmediate(resolve))
|
|
|
+}
|
|
|
+
|
|
|
const backgroundComponentPath = path.join(__dirname, '../components/CusTeamBackgroundFiles/index.vue')
|
|
|
const teamFillComponentPath = path.join(__dirname, '../components/CusTeamInfoFill/index.vue')
|
|
|
const createTeamPagePath = path.join(__dirname, '../pagesPublish/fillTeamInfo.vue')
|
|
|
@@ -661,6 +711,97 @@ async function testSessionRefreshFailureKeepsOldTimer() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+async function testPcSessionCreationIsSingleFlightPerTeam() {
|
|
|
+ const firstRequest = createDeferred()
|
|
|
+ let createCalls = 0
|
|
|
+ const component = await loadVueComponent(backgroundComponentPath, {
|
|
|
+ http: {
|
|
|
+ createTeamBackgroundSession() {
|
|
|
+ createCalls += 1
|
|
|
+ return firstRequest.promise
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const instance = instantiateComponent(component, {
|
|
|
+ teamId: '', editable: true, $showToast() {}, $emit() {}
|
|
|
+ })
|
|
|
+ instance.resetTeamContext(12)
|
|
|
+ const first = instance.openPcSession(12)
|
|
|
+ const second = instance.openPcSession(12)
|
|
|
+ assert.equal(createCalls, 1, 'same-team callers must share one session POST')
|
|
|
+ assert.equal(instance.sessionCreating, true)
|
|
|
+ instance.refreshPcSession()
|
|
|
+ assert.equal(createCalls, 1, 'disabled regenerate must not create another POST')
|
|
|
+
|
|
|
+ instance.resetTeamContext(13)
|
|
|
+ assert.equal(instance.sessionCreating, false, 'creating state follows the active team')
|
|
|
+ firstRequest.resolve({
|
|
|
+ code: '123456', uploadUrl: 'https://upload.example/', expiresAt: '2099-01-01 00:00:00'
|
|
|
+ })
|
|
|
+ assert.equal(await first, null)
|
|
|
+ assert.equal(await second, null)
|
|
|
+ assert.equal(instance.session, null, 'a stale context must not display the resolved session')
|
|
|
+ assert.equal(instance.pcDialogVisible, false)
|
|
|
+}
|
|
|
+
|
|
|
+async function testPcSessionSingleFlightSurvivesSameTeamReset() {
|
|
|
+ const request = createDeferred()
|
|
|
+ let createCalls = 0
|
|
|
+ const component = await loadVueComponent(backgroundComponentPath, {
|
|
|
+ http: {
|
|
|
+ createTeamBackgroundSession() {
|
|
|
+ createCalls += 1
|
|
|
+ return request.promise
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const instance = instantiateComponent(component, {
|
|
|
+ teamId: '', editable: true, $showToast() {}, $emit() {}
|
|
|
+ })
|
|
|
+ instance.resetTeamContext(21)
|
|
|
+ const staleCaller = instance.openPcSession(21)
|
|
|
+ instance.resetTeamContext(21)
|
|
|
+ assert.equal(instance.sessionCreating, true, 'same-team reset must retain the network lock')
|
|
|
+ const currentCaller = instance.openPcSession(21)
|
|
|
+ assert.equal(createCalls, 1, 'same-team reset must reuse the raw network promise')
|
|
|
+ const rawSession = {
|
|
|
+ code: '654321', uploadUrl: 'https://upload.example/current', expiresAt: '2099-01-01 00:00:00'
|
|
|
+ }
|
|
|
+ request.resolve(rawSession)
|
|
|
+ assert.equal(await staleCaller, null)
|
|
|
+ assert.equal(await currentCaller, rawSession)
|
|
|
+ assert.equal(instance.session, rawSession)
|
|
|
+ assert.equal(instance.pcDialogVisible, true)
|
|
|
+ assert.equal(instance.sessionCreating, false)
|
|
|
+ instance.closePcDialog()
|
|
|
+}
|
|
|
+
|
|
|
+async function testPcSessionFailureClearsSingleFlightForRetry() {
|
|
|
+ let createCalls = 0
|
|
|
+ const component = await loadVueComponent(backgroundComponentPath, {
|
|
|
+ http: {
|
|
|
+ createTeamBackgroundSession() {
|
|
|
+ createCalls += 1
|
|
|
+ if (createCalls === 1) return Promise.reject(new Error('session unavailable'))
|
|
|
+ return Promise.resolve({
|
|
|
+ code: '222222', uploadUrl: 'https://upload.example/retry', expiresAt: '2099-01-01 00:00:00'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const instance = instantiateComponent(component, {
|
|
|
+ teamId: '', editable: true, $showToast() {}, $emit() {}
|
|
|
+ })
|
|
|
+ instance.resetTeamContext(22)
|
|
|
+ await assert.rejects(instance.openPcSession(22), /session unavailable/)
|
|
|
+ assert.equal(instance.sessionCreating, false)
|
|
|
+ const session = await instance.openPcSession(22)
|
|
|
+ assert.equal(createCalls, 2)
|
|
|
+ assert.equal(session.code, '222222')
|
|
|
+ assert.equal(instance.sessionCreating, false)
|
|
|
+ instance.closePcDialog()
|
|
|
+}
|
|
|
+
|
|
|
async function testDeferredPcFinishWaitsForListRefresh() {
|
|
|
const component = await loadVueComponent(backgroundComponentPath, {
|
|
|
http: {
|
|
|
@@ -917,6 +1058,228 @@ async function testCreatedTeamResumeAndSubmitLock() {
|
|
|
await Promise.all([lockedFirst, lockedSecond])
|
|
|
}
|
|
|
|
|
|
+async function testCreatedTeamNavigationIsAwaitableAndResumable() {
|
|
|
+ const timers = createFakeTimers()
|
|
|
+ let navigationOptions
|
|
|
+ let teamPostCalls = 0
|
|
|
+ let publishCalls = 0
|
|
|
+ let teamPutCalls = 0
|
|
|
+ let flushCalls = 0
|
|
|
+ const toasts = []
|
|
|
+ const uni = {
|
|
|
+ getStorageSync(key) {
|
|
|
+ assert.equal(key, 'userInfo')
|
|
|
+ return JSON.stringify({ id: 7 })
|
|
|
+ },
|
|
|
+ removeStorageSync() {},
|
|
|
+ navigateTo(options) { navigationOptions = options }
|
|
|
+ }
|
|
|
+ const component = await loadVueComponent(createTeamPagePath, { uni, timers })
|
|
|
+ const originalFormat = Date.prototype.Format
|
|
|
+ Date.prototype.Format = () => '2026-07-21 12:00:00'
|
|
|
+ try {
|
|
|
+ const instance = instantiateComponent(component, {
|
|
|
+ next: '1', questionnaireId: 66, type: 'survey', title: '问卷',
|
|
|
+ $api: {
|
|
|
+ post(url, payload) {
|
|
|
+ if (url === '/core/user/team') {
|
|
|
+ teamPostCalls += 1
|
|
|
+ assert.equal(payload.enterpriseWebsite, 'https://old.example')
|
|
|
+ return Promise.resolve({ data: { code: 0, data: { teamId: 88 } } })
|
|
|
+ }
|
|
|
+ assert.equal(url, '/core/team/questionnaire/publish')
|
|
|
+ assert.deepEqual(Object.keys(payload).sort(), [
|
|
|
+ 'answerSetting', 'coachId', 'endTime', 'questionnaireId',
|
|
|
+ 'startTime', 'teamId', 'type'
|
|
|
+ ].sort())
|
|
|
+ publishCalls += 1
|
|
|
+ return Promise.resolve({ data: { code: 0, data: 321 } })
|
|
|
+ },
|
|
|
+ put(url, payload) {
|
|
|
+ teamPutCalls += 1
|
|
|
+ assert.equal(url, '/core/user/team')
|
|
|
+ assert.equal(payload.id, 88)
|
|
|
+ assert.equal(payload.enterpriseWebsite, 'https://new.example')
|
|
|
+ return Promise.resolve({ data: { code: 0, data: true } })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ $showToast(message) { toasts.push(message) },
|
|
|
+ $showModal: () => Promise.resolve(),
|
|
|
+ $refs: {
|
|
|
+ teamRef: {
|
|
|
+ flushBackgroundFiles() { flushCalls += 1; return Promise.resolve({ success: 0, failed: 0 }) },
|
|
|
+ hasUnresolvedBackgroundFiles: () => false,
|
|
|
+ hasDeferredPcUpload: () => false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const first = instance.handleConfirm({
|
|
|
+ teamName: '团队', enterpriseWebsite: 'https://old.example'
|
|
|
+ })
|
|
|
+ await flushPromises()
|
|
|
+ assert.equal(teamPostCalls, 1)
|
|
|
+ assert.equal(publishCalls, 1)
|
|
|
+ assert.equal(flushCalls, 1)
|
|
|
+ assert.equal(instance.creationState.teamQuestionnaireId, 321)
|
|
|
+ assert.equal(instance.creationState.completed, false)
|
|
|
+ assert.equal(instance.creationState.submitting, true)
|
|
|
+ assert.equal(timers.count(), 1)
|
|
|
+
|
|
|
+ timers.runNext()
|
|
|
+ await flushPromises()
|
|
|
+ assert.ok(navigationOptions)
|
|
|
+ assert.equal(instance.creationState.completed, false, 'navigation callback must settle first')
|
|
|
+ navigationOptions.fail({ errMsg: 'navigateTo:fail route missing' })
|
|
|
+ await first
|
|
|
+ assert.equal(instance.creationState.completed, false)
|
|
|
+ assert.equal(instance.creationState.submitting, false)
|
|
|
+ assert.equal(instance.creationState.stage, 'navigate')
|
|
|
+ assert.ok(toasts.some(message => String(message).includes('页面跳转失败')))
|
|
|
+
|
|
|
+ navigationOptions = null
|
|
|
+ const retry = instance.handleConfirm({
|
|
|
+ teamName: '团队', enterpriseWebsite: 'https://new.example'
|
|
|
+ })
|
|
|
+ await flushPromises()
|
|
|
+ assert.equal(teamPostCalls, 1, 'retry must reuse the created team id')
|
|
|
+ assert.equal(publishCalls, 1, 'retry after publish success must not publish again')
|
|
|
+ assert.equal(flushCalls, 1, 'navigate-stage retry must not repeat file processing')
|
|
|
+ assert.equal(teamPutCalls, 1, 'changed sanitized payload must update the same team')
|
|
|
+ assert.equal(timers.count(), 1)
|
|
|
+ timers.runNext()
|
|
|
+ await flushPromises()
|
|
|
+ assert.ok(navigationOptions)
|
|
|
+ assert.equal(instance.creationState.completed, false)
|
|
|
+ navigationOptions.success({})
|
|
|
+ await retry
|
|
|
+ assert.equal(instance.creationState.completed, true)
|
|
|
+ assert.equal(instance.creationState.submitting, false)
|
|
|
+ } finally {
|
|
|
+ if (originalFormat) Date.prototype.Format = originalFormat
|
|
|
+ else delete Date.prototype.Format
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function testCreatedTeamUnloadCancelsLateNavigation() {
|
|
|
+ const timers = createFakeTimers()
|
|
|
+ let navigateBackCalls = 0
|
|
|
+ const uni = {
|
|
|
+ navigateBack() { navigateBackCalls += 1 }
|
|
|
+ }
|
|
|
+ const component = await loadVueComponent(createTeamPagePath, { uni, timers })
|
|
|
+ const instance = instantiateComponent(component, {
|
|
|
+ $api: {
|
|
|
+ post: () => Promise.resolve({ data: { code: 0, data: { teamId: 89 } } })
|
|
|
+ },
|
|
|
+ $showToast() {},
|
|
|
+ $showModal: () => Promise.resolve(),
|
|
|
+ getOpenerEventChannel: () => ({ emit() {} }),
|
|
|
+ $refs: {
|
|
|
+ teamRef: {
|
|
|
+ flushBackgroundFiles: () => Promise.resolve({ success: 0, failed: 0 }),
|
|
|
+ hasUnresolvedBackgroundFiles: () => false,
|
|
|
+ hasDeferredPcUpload: () => false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const submission = instance.handleConfirm({ teamName: '团队', enterpriseWebsite: '无' })
|
|
|
+ await flushPromises()
|
|
|
+ assert.equal(timers.count(), 1)
|
|
|
+ component.onUnload.call(instance)
|
|
|
+ assert.equal(timers.count(), 0)
|
|
|
+ timers.runAll()
|
|
|
+ await submission
|
|
|
+ assert.equal(navigateBackCalls, 0)
|
|
|
+ assert.equal(instance.creationState.completed, false)
|
|
|
+ assert.equal(instance.creationState.submitting, false)
|
|
|
+}
|
|
|
+
|
|
|
+async function testTeamEditRedirectIsLockedAndResumable() {
|
|
|
+ const timers = createFakeTimers()
|
|
|
+ let redirectOptions
|
|
|
+ let putCalls = 0
|
|
|
+ let navigateBackCalls = 0
|
|
|
+ const toasts = []
|
|
|
+ const uni = {
|
|
|
+ redirectTo(options) { redirectOptions = options },
|
|
|
+ navigateBack() { navigateBackCalls += 1 }
|
|
|
+ }
|
|
|
+ const component = await loadVueComponent(teamEditPagePath, { uni, timers })
|
|
|
+ const instance = instantiateComponent(component, {
|
|
|
+ submitDto: { id: 12, teamName: '团队' },
|
|
|
+ show: true,
|
|
|
+ $api: {
|
|
|
+ put(url, payload) {
|
|
|
+ putCalls += 1
|
|
|
+ assert.equal(url, '/core/user/team')
|
|
|
+ assert.equal(payload.id, 12)
|
|
|
+ return Promise.resolve({ data: { code: 0, data: true } })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ $showToast(message) { toasts.push(message) }
|
|
|
+ })
|
|
|
+
|
|
|
+ const first = instance.editConfirm()
|
|
|
+ await flushPromises()
|
|
|
+ assert.equal(putCalls, 1)
|
|
|
+ assert.equal(instance.saveSucceeded, true)
|
|
|
+ assert.equal(instance.saving, true, 'save lock must cover the redirect delay')
|
|
|
+ assert.equal(timers.count(), 1)
|
|
|
+ instance.editConfirm()
|
|
|
+ instance.requestBack()
|
|
|
+ assert.equal(putCalls, 1, 'confirm during redirect delay must not PUT again')
|
|
|
+ assert.equal(navigateBackCalls, 0, 'back during redirect delay must stay locked')
|
|
|
+ assert.ok(toasts.some(message => String(message).includes('正在保存')))
|
|
|
+
|
|
|
+ timers.runNext()
|
|
|
+ await flushPromises()
|
|
|
+ assert.ok(redirectOptions)
|
|
|
+ assert.equal(instance.saving, true, 'redirect callback must settle before unlock')
|
|
|
+ redirectOptions.fail({ errMsg: 'redirectTo:fail route missing' })
|
|
|
+ await first
|
|
|
+ assert.equal(instance.saving, false)
|
|
|
+ assert.equal(instance.saveSucceeded, true)
|
|
|
+ assert.equal(instance.saveStage, 'navigate')
|
|
|
+
|
|
|
+ redirectOptions = null
|
|
|
+ const retry = instance.editConfirm()
|
|
|
+ await flushPromises()
|
|
|
+ assert.equal(putCalls, 1, 'redirect retry must reuse the successful PUT')
|
|
|
+ assert.equal(instance.saving, true)
|
|
|
+ assert.equal(timers.count(), 1)
|
|
|
+ timers.runNext()
|
|
|
+ await flushPromises()
|
|
|
+ assert.ok(redirectOptions)
|
|
|
+ redirectOptions.success({})
|
|
|
+ await retry
|
|
|
+ assert.equal(putCalls, 1)
|
|
|
+ assert.equal(instance.saving, false)
|
|
|
+}
|
|
|
+
|
|
|
+async function testTeamEditUnloadCancelsLateRedirect() {
|
|
|
+ const timers = createFakeTimers()
|
|
|
+ let redirectCalls = 0
|
|
|
+ const component = await loadVueComponent(teamEditPagePath, {
|
|
|
+ timers,
|
|
|
+ uni: { redirectTo() { redirectCalls += 1 } }
|
|
|
+ })
|
|
|
+ const instance = instantiateComponent(component, {
|
|
|
+ submitDto: { id: 12 },
|
|
|
+ $api: { put: () => Promise.resolve({ data: { code: 0, data: true } }) },
|
|
|
+ $showToast() {}
|
|
|
+ })
|
|
|
+ const saving = instance.editConfirm()
|
|
|
+ await flushPromises()
|
|
|
+ assert.equal(timers.count(), 1)
|
|
|
+ component.onUnload.call(instance)
|
|
|
+ assert.equal(timers.count(), 0)
|
|
|
+ timers.runAll()
|
|
|
+ await saving
|
|
|
+ assert.equal(redirectCalls, 0)
|
|
|
+ assert.equal(instance.saving, false)
|
|
|
+}
|
|
|
+
|
|
|
async function testCloseAndBackRequirePendingUploadConfirmation() {
|
|
|
let modalOptions
|
|
|
let navigateBackCalls = 0
|
|
|
@@ -979,11 +1342,18 @@ async function main() {
|
|
|
await testDownloadTransport()
|
|
|
await testDeferredPcSelectionSurvivesSessionFailure()
|
|
|
await testSessionRefreshFailureKeepsOldTimer()
|
|
|
+ await testPcSessionCreationIsSingleFlightPerTeam()
|
|
|
+ await testPcSessionSingleFlightSurvivesSameTeamReset()
|
|
|
+ await testPcSessionFailureClearsSingleFlightForRetry()
|
|
|
await testDeferredPcFinishWaitsForListRefresh()
|
|
|
await testTeamContextResetAndUploadIsolation()
|
|
|
await testFileActionsWaitForCurrentListAndDocumentContext()
|
|
|
await testTeamFillFlushesBeforeEmitAndLocks()
|
|
|
await testCreatedTeamResumeAndSubmitLock()
|
|
|
+ await testCreatedTeamNavigationIsAwaitableAndResumable()
|
|
|
+ await testCreatedTeamUnloadCancelsLateNavigation()
|
|
|
+ await testTeamEditRedirectIsLockedAndResumable()
|
|
|
+ await testTeamEditUnloadCancelsLateRedirect()
|
|
|
await testCloseAndBackRequirePendingUploadConfirmation()
|
|
|
console.log('team background transport: PASS')
|
|
|
console.log('team background component behavior: PASS')
|