Add next test button (#7247)

This commit is contained in:
Artyom Savchenko 2024-12-03 13:47:44 +07:00 committed by GitHub
parent 3775132777
commit abdf2572e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 435 additions and 13 deletions

View File

@ -160,6 +160,7 @@ export function createModel (builder: Builder): void {
defineTestSuite(builder)
defineTestCase(builder)
defineTestRun(builder)
defineTestResult(builder)
definePresenters(builder)
@ -261,7 +262,7 @@ function defineTestSuite (builder: Builder): void {
// Actions
builder.mixin(testManagement.class.TestSuite, core.class.Class, view.mixin.IgnoreActions, {
actions: [print.action.Print, tracker.action.EditRelatedTargets]
actions: [print.action.Print, tracker.action.EditRelatedTargets, tracker.action.NewRelatedIssue]
})
createAction(
@ -331,7 +332,7 @@ function defineTestCase (builder: Builder): void {
builder.mixin(testManagement.class.TestCase, core.class.Class, view.mixin.ClassFilters, {
filters: ['priority', 'status'],
ignoreKeys: ['createdBy', 'modifiedBy', 'createdOn', 'modifiedOn']
ignoreKeys: ['createdBy', 'modifiedBy', 'createdOn', 'modifiedOn', 'name']
})
builder.createDoc(
@ -419,6 +420,12 @@ function defineTestRun (builder: Builder): void {
component: testManagement.component.TestResultStatusPresenter
})
builder.mixin(testManagement.class.TestRun, core.class.Class, view.mixin.IgnoreActions, {
actions: [print.action.Print, tracker.action.EditRelatedTargets, tracker.action.NewRelatedIssue]
})
}
function defineTestResult (builder: Builder): void {
builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectPresenter, {
presenter: testManagement.component.TestResultPresenter
})
@ -448,7 +455,7 @@ function defineTestRun (builder: Builder): void {
builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ClassFilters, {
filters: ['assignee', 'status', 'testSuite'],
ignoreKeys: ['createdBy', 'modifiedBy', 'createdOn', 'modifiedOn']
ignoreKeys: ['createdBy', 'modifiedBy', 'createdOn', 'modifiedOn', 'name', 'attachedTo']
})
const viewOptions: ViewOptionsModel = {

View File

@ -23,7 +23,8 @@ import type { ActionCategory } from '@hcengineering/view'
export default mergeIds(testManagementId, testManganement, {
category: {
TestSuite: '' as Ref<ActionCategory>,
TestCase: '' as Ref<ActionCategory>
TestCase: '' as Ref<ActionCategory>,
TestResult: '' as Ref<ActionCategory>
},
component: {
CreateTestCase: '' as AnyComponent,

View File

@ -250,12 +250,15 @@ export class TTestResult extends TAttachedDoc implements TestResult {
testCase!: Ref<TestCase>
@Prop(TypeRef(testManagement.class.TestSuite), testManagement.string.TestSuite)
@Index(IndexKind.Indexed)
testSuite?: Ref<TestSuite>
@Prop(TypeTestRunStatus(), testManagement.string.TestRunStatus)
@Index(IndexKind.Indexed)
status?: TestRunStatus
@Prop(TypeRef(contact.mixin.Employee), testManagement.string.TestAssignee)
@Index(IndexKind.Indexed)
assignee?: Ref<Employee>
@Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files })

View File

@ -77,6 +77,8 @@
"DueDate": "Termín",
"RunTestCases": "Spustit",
"TestCaseDescription": "Popis testovacího případu",
"TestResultAttributes": "Výsledek"
"TestResultAttributes": "Výsledek",
"GoToNextTest": "Další",
"GoToNextTestTooltip": "Přejít na další test"
}
}

View File

@ -77,6 +77,8 @@
"DueDate": "Due date",
"RunTestCases": "Run",
"TestCaseDescription": "Test case description",
"TestResultAttributes": "Result"
"TestResultAttributes": "Result",
"GoToNextTest": "Next",
"GoToNextTestTooltip": "Go to next test"
}
}

View File

@ -77,6 +77,8 @@
"DueDate": "Fecha de vencimiento",
"RunTestCases": "Ejecutar",
"TestCaseDescription": "Descripción del caso de prueba",
"TestResultAttributes": "Resultado"
"TestResultAttributes": "Resultado",
"GoToNextTest": "Siguiente",
"GoToNextTestTooltip": "Ir al siguiente test"
}
}

View File

@ -77,6 +77,8 @@
"DueDate": "Date d'échéance",
"RunTestCases": "Exécuter",
"TestCaseDescription": "Description du cas de test",
"TestResultAttributes": "Résultat"
"TestResultAttributes": "Résultat",
"GoToNextTest": "Suivant",
"GoToNextTestTooltip": "Aller au test suivant"
}
}

View File

@ -77,6 +77,8 @@
"DueDate": "Data di scadenza",
"RunTestCases": "Esegui",
"TestCaseDescription": "Descrizione del caso di test",
"TestResultAttributes": "Risultato"
"TestResultAttributes": "Risultato",
"GoToNextTest": "Successivo",
"GoToNextTestTooltip": "Vai al test successivo"
}
}

View File

@ -77,6 +77,8 @@
"DueDate": "Data de vencimento",
"RunTestCases": "Executar",
"TestCaseDescription": "Descrição do caso de teste",
"TestResultAttributes": "Resultado"
"TestResultAttributes": "Resultado",
"GoToNextTest": "Próximo",
"GoToNextTestTooltip": "Ir para o próximo teste"
}
}

View File

@ -77,6 +77,8 @@
"DueDate": "Выполнить до",
"RunTestCases": "Выполнить",
"TestCaseDescription": "Описание тест-кейса",
"TestResultAttributes": "Результат"
"TestResultAttributes": "Результат",
"GoToNextTest": "Следующий",
"GoToNextTestTooltip": "Перейти к следующему тесту"
}
}

View File

@ -77,6 +77,8 @@
"DueDate": "到期日",
"RunTestCases": "运行",
"TestCaseDescription": "測試用例描述",
"TestResultAttributes": "結果"
"TestResultAttributes": "結果",
"GoToNextTest": "下一個",
"GoToNextTestTooltip": "轉到下一個測試"
}
}

View File

@ -24,6 +24,7 @@
import { DocAttributeBar, getDocMixins } from '@hcengineering/view-resources'
import RightHeader from './RightHeader.svelte'
import NextButton from './NextButton.svelte'
import TestCaseDetails from '../test-case/TestCaseDetails.svelte'
import testManagement from '../../plugin'
@ -92,6 +93,10 @@
/>
</div>
<svelte:fragment slot="extra">
<NextButton {object} />
</svelte:fragment>
<svelte:fragment slot="aside">
<DocAttributeBar {object} {mixins} ignoreKeys={['name']} />
<RightHeader>

View File

@ -0,0 +1,81 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { Button, Loading, Location, navigate } from '@hcengineering/ui'
import { initializeIterator, testResultIteratorProvider, testIteratorStore } from './store/testIteratorStore'
import testManagement, { TestResult } from '@hcengineering/test-management'
import { Doc, type DocumentQuery, WithLookup } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { getObjectLinkFragment } from '@hcengineering/view-resources'
import view from '@hcengineering/view'
export let object: WithLookup<TestResult> | undefined
let isLoading = true
let hasNext = false
const client = getClient()
const hierarchy = client.getHierarchy()
const unsubscribe = testIteratorStore.subscribe(() => {
hasNext = testResultIteratorProvider.getIterator()?.hasNext() ?? false
})
onMount(async () => {
const query: DocumentQuery<TestResult> = { attachedTo: object?.attachedTo } as any
await initializeIterator(query, object?._id)
hasNext = testResultIteratorProvider.getIterator()?.hasNext() ?? false
isLoading = false
})
onDestroy(() => {
testResultIteratorProvider.reset()
unsubscribe()
})
async function goToNextItem (): Promise<void> {
const iterator = testResultIteratorProvider.getIterator()
if (iterator !== undefined) {
const nextItem = iterator.next()
if (nextItem === undefined) {
console.error('No next item')
return
}
const link = await getLink(nextItem)
if (link !== undefined) {
navigate(link)
}
console.log('Next item:', nextItem)
}
}
async function getLink (object: Doc): Promise<Location> {
const { component } = hierarchy.classHierarchyMixin(testManagement.class.TestResult, view.mixin.ObjectPanel) as any
return await getObjectLinkFragment(hierarchy, object, {}, component)
}
</script>
{#if isLoading}
<Loading />
{:else}
<Button
label={testManagement.string.GoToNextTest}
kind={'primary'}
icon={view.icon.ArrowRight}
disabled={!hasNext}
on:click={goToNextItem}
showTooltip={{ label: testManagement.string.GoToNextTestTooltip }}
/>
{/if}

View File

@ -0,0 +1,51 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { writable, get } from 'svelte/store'
import {
type IteratorState,
type StoreAdapter,
ObjectIteratorProvider,
getDefaultIteratorState
} from '@hcengineering/view-resources'
import testManagement, { type TestResult } from '@hcengineering/test-management'
import type { DocumentQuery, Ref } from '@hcengineering/core'
export const testIteratorStore = writable<IteratorState<TestResult>>(getDefaultIteratorState<TestResult>({}))
const adapter: StoreAdapter<TestResult> = {
set (value: IteratorState<TestResult>) {
testIteratorStore.set(value)
},
update (updater: (value: IteratorState<TestResult>) => IteratorState<TestResult>) {
testIteratorStore.update(updater)
},
get () {
return get(testIteratorStore)
}
}
export const testResultIteratorProvider = new ObjectIteratorProvider<TestResult>(adapter)
export async function initializeIterator (
query: DocumentQuery<TestResult>,
currentObject: Ref<TestResult> | undefined
): Promise<void> {
await testResultIteratorProvider.initialize(testManagement.class.TestResult, query, currentObject)
}
export function resetTestObjectIterator (): void {
testResultIteratorProvider.reset()
}

View File

@ -185,7 +185,9 @@ export const testManagementPlugin = plugin(testManagementId, {
TestResults: '' as IntlString,
RunTestCases: '' as IntlString,
TestCaseDescription: '' as IntlString,
TestResultAttributes: '' as IntlString
TestResultAttributes: '' as IntlString,
GoToNextTest: '' as IntlString,
GoToNextTestTooltip: '' as IntlString
},
category: {
TestManagement: '' as Ref<ActionCategory>

View File

@ -0,0 +1,142 @@
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import { ObjectIterator, type StoreAdapter, type IteratorState, ObjectIteratorProvider } from '../objectIterator'
import { type DocumentQuery, type Doc, type Ref, type Class } from '@hcengineering/core'
let mockObjects: Doc[] = []
const findAll = jest.fn(() => mockObjects)
jest.mock('@hcengineering/presentation', () => ({
getClient: jest.fn(() => ({
findAll
}))
}))
describe('ObjectIterator', () => {
let storeAdapter: StoreAdapter<Doc>
beforeEach(() => {
jest.clearAllMocks()
let state: IteratorState<Doc> = {
query: {},
currentObjects: [],
iteratorIndex: 0,
limit: 0
}
storeAdapter = {
set: jest.fn((newState) => {
state = newState
}),
update: jest.fn((updater) => {
state = updater(state)
}),
get: jest.fn(() => state)
}
})
it('should initialize the store with the given query', async () => {
const query: DocumentQuery<Doc> = { key: 'value' }
const _class: Ref<Class<Doc>> = { id: 'class1' } as any
// eslint-disable-next-line no-new
new ObjectIterator(_class, query, storeAdapter)
expect(storeAdapter.set).toHaveBeenCalledWith({
query,
currentObjects: [],
iteratorIndex: 0,
limit: 100
})
})
it('should load objects and update the store', async () => {
mockObjects = [{ id: '1' }, { id: '2' }] as any
const query: DocumentQuery<Doc> = { key: 'value' }
const _class: Ref<Class<Doc>> = { id: 'class1' } as any
const iterator = new ObjectIterator(_class, query, storeAdapter)
await iterator.loadObjects(undefined)
expect(findAll).toHaveBeenCalledWith(_class, query, {
limit: 100,
total: true
})
expect(storeAdapter.update).toHaveBeenCalledWith(expect.any(Function))
const state = storeAdapter.get()
expect(state?.currentObjects.length).toBe(2)
expect(state?.iteratorIndex).toBe(0)
})
it('should return the next object and update the iterator', async () => {
mockObjects = [{ id: '1' }, { id: '2' }, { id: '3' }] as any
const query: DocumentQuery<Doc> = { key: 'value' }
const _class: Ref<Class<Doc>> = { id: 'class1' } as any
const iterator = new ObjectIterator(_class, query, storeAdapter)
await iterator.loadObjects(undefined)
let nextObject = iterator.next()
expect(nextObject).toEqual(mockObjects[0])
expect(storeAdapter.update).toHaveBeenCalledWith(expect.any(Function))
nextObject = iterator.next()
expect(nextObject).toEqual(mockObjects[1])
nextObject = iterator.next()
expect(nextObject).toEqual(mockObjects[2])
nextObject = iterator.next()
expect(nextObject).toBeUndefined()
})
it('should return undefined for empty array', async () => {
mockObjects = [] as any
const query: DocumentQuery<Doc> = { key: 'value' }
const _class: Ref<Class<Doc>> = { id: 'class1' } as any
const iterator = new ObjectIterator(_class, query, storeAdapter)
await iterator.loadObjects(undefined)
const nextObject = iterator.next()
expect(nextObject).toBeUndefined()
})
it('should not create a new ObjectIterator if already defined', async () => {
const query: DocumentQuery<Doc> = { key: 'value' }
const _class: Ref<Class<Doc>> = { id: 'class1' } as any
const provider = new ObjectIteratorProvider(storeAdapter)
await provider.initialize(_class, query, undefined)
const firstIterator = provider.getIterator()
await provider.initialize(_class, query, undefined)
const secondIterator = provider.getIterator()
expect(firstIterator).toBe(secondIterator)
expect(firstIterator).toBeDefined()
})
it('should reset the state of ObjectIterator', async () => {
const query: DocumentQuery<Doc> = { key: 'value' }
const _class: Ref<Class<Doc>> = { id: 'class1' } as any
const provider = new ObjectIteratorProvider(storeAdapter)
await provider.initialize(_class, query, undefined)
provider.reset()
expect(storeAdapter.set).toHaveBeenCalledWith({
query: {},
currentObjects: [],
iteratorIndex: 0,
limit: 100
})
})
})

View File

@ -175,6 +175,7 @@ export * from './middleware'
export * from './selection'
export * from './status'
export * from './utils'
export * from './objectIterator'
export {
buildModel,
getActiveViewletId,

View File

@ -0,0 +1,113 @@
//
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import type { DocumentQuery, Doc, Ref, Class } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
export interface IteratorState<T extends Doc> {
query: DocumentQuery<T>
currentObjects: T[]
iteratorIndex: number
limit: number
}
export interface StoreAdapter<T extends Doc> {
set: (value: IteratorState<T>) => void
update: (updater: (value: IteratorState<T>) => IteratorState<T>) => void
get: () => IteratorState<T>
}
export function getDefaultIteratorState<T extends Doc> (query: DocumentQuery<T>): IteratorState<T> {
return {
query,
currentObjects: [],
iteratorIndex: 0,
limit: 100
}
}
export class ObjectIterator<T extends Doc> {
private readonly storeAdapter: StoreAdapter<T>
private readonly class: Ref<Class<T>>
constructor (_class: Ref<Class<T>>, query: DocumentQuery<T>, storeAdapter: StoreAdapter<T>) {
this.class = _class
this.storeAdapter = storeAdapter
this.storeAdapter.set(getDefaultIteratorState<T>(query))
}
async loadObjects (currentObject: Ref<Doc> | undefined): Promise<void> {
const client = getClient()
const { query, limit } = this.storeAdapter.get()
const testResults = await client.findAll(this.class, query, {
limit,
total: true
})
this.storeAdapter.update((store) => {
store.currentObjects = [...store.currentObjects, ...testResults]
store.limit = testResults.total
if (currentObject !== undefined) {
store.iteratorIndex = store.currentObjects.findIndex((obj) => obj._id === currentObject) ?? 0
}
return store
})
}
next (): T | undefined {
let nextObject
this.storeAdapter.update((store) => {
if (store.iteratorIndex < store.currentObjects.length) {
store.iteratorIndex += 1
nextObject = store.currentObjects[store.iteratorIndex]
}
return store
})
return nextObject
}
hasNext (): boolean {
const { currentObjects, iteratorIndex } = this.storeAdapter.get()
return iteratorIndex < currentObjects.length - 1
}
}
export class ObjectIteratorProvider<T extends Doc> {
private objectIterator: ObjectIterator<T> | undefined = undefined
constructor (private readonly storeAdapter: StoreAdapter<T>) {}
async initialize (_class: Ref<Class<T>>, query: DocumentQuery<T>, currentObject: Ref<Doc> | undefined): Promise<void> {
if (this.objectIterator === undefined) {
this.objectIterator = new ObjectIterator(_class, query, this.storeAdapter)
await this.objectIterator.loadObjects(currentObject)
}
}
reset (): void {
this.objectIterator = undefined
this.storeAdapter.set(getDefaultIteratorState<T>({}))
}
getObject (): T | undefined {
if (this.objectIterator === undefined) {
console.error('ObjectIterator is not initialized')
return undefined
}
return this.objectIterator?.next()
}
getIterator (): ObjectIterator<T> | undefined {
return this.objectIterator
}
}