diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-07-10 00:37:42 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-07-10 01:35:25 +0200 |
commit | 12e1c29245792dc9a24a0d6757abf3b1f71eb1a7 (patch) | |
tree | 0a0333fe4d4f7a4b5ef1cb15e2cda71d6cb3a034 /cypress | |
parent | d82565d67d071dce0208323decc455da9d8625ef (diff) | |
download | nextcloud-server-12e1c29245792dc9a24a0d6757abf3b1f71eb1a7.tar.gz nextcloud-server-12e1c29245792dc9a24a0d6757abf3b1f71eb1a7.zip |
test: Adjust cypress tests to use reusable POM for header navigation
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'cypress')
-rw-r--r-- | cypress/dockerNode.ts | 13 | ||||
-rw-r--r-- | cypress/e2e/theming/admin-settings.cy.ts | 21 | ||||
-rw-r--r-- | cypress/e2e/theming/admin-settings_default-app.cy.ts | 91 | ||||
-rw-r--r-- | cypress/e2e/theming/user-settings_app-order.cy.ts (renamed from cypress/e2e/theming/navigation-bar-settings.cy.ts) | 205 | ||||
-rw-r--r-- | cypress/e2e/theming/user-settings_background.cy.ts (renamed from cypress/e2e/theming/user-background.cy.ts) | 29 | ||||
-rw-r--r-- | cypress/pages/NavigationHeader.ts | 58 |
6 files changed, 258 insertions, 159 deletions
diff --git a/cypress/dockerNode.ts b/cypress/dockerNode.ts index 530a013aa2c..180ff7894c6 100644 --- a/cypress/dockerNode.ts +++ b/cypress/dockerNode.ts @@ -150,22 +150,19 @@ export const applyChangesToNextcloud = async function() { './remote.php', './status.php', './version.php', - ] - - let needToApplyChanges = false - - folderPaths.forEach((folderPath) => { - const fullPath = path.join(htmlPath, folderPath) + ].filter((folderPath) => { + const fullPath = path.resolve(__dirname, '..', folderPath) if (existsSync(fullPath)) { - needToApplyChanges = true console.log(`├─ Copying ${folderPath}`) + return true } + return false }) // Don't try to apply changes, when there are none. Otherwise we // still execute the 'chown' command, which is not needed. - if (!needToApplyChanges) { + if (folderPaths.length === 0) { console.log('└─ No local changes found to apply') return } diff --git a/cypress/e2e/theming/admin-settings.cy.ts b/cypress/e2e/theming/admin-settings.cy.ts index 26bc6d26611..4207b98f711 100644 --- a/cypress/e2e/theming/admin-settings.cy.ts +++ b/cypress/e2e/theming/admin-settings.cy.ts @@ -13,6 +13,7 @@ import { validateUserThemingDefaultCss, expectBackgroundColor, } from './themingUtils' +import { NavigationHeader } from '../../pages/NavigationHeader' const admin = new User('admin', 'admin') @@ -225,6 +226,7 @@ describe('Remove the default background with a custom background color', functio }) describe('Remove the default background with a bright color', function() { + const navigationHeader = new NavigationHeader() let selectedColor = '' before(function() { @@ -271,15 +273,16 @@ describe('Remove the default background with a bright color', function() { it('See the header being inverted', function() { cy.waitUntil(() => - cy.window().then((win) => { - const firstEntry = win.document.querySelector( - '.app-menu-main li img', - ) - if (!firstEntry) { - return false - } - return getComputedStyle(firstEntry).filter === 'invert(1)' - }), + navigationHeader + .getNavigationEntries() + .find('img') + .then((el) => { + let ret = true + el.each(function() { + ret = ret && window.getComputedStyle(this).filter === 'invert(1)' + }) + return ret + }) ) }) }) diff --git a/cypress/e2e/theming/admin-settings_default-app.cy.ts b/cypress/e2e/theming/admin-settings_default-app.cy.ts new file mode 100644 index 00000000000..702f737bc15 --- /dev/null +++ b/cypress/e2e/theming/admin-settings_default-app.cy.ts @@ -0,0 +1,91 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { User } from '@nextcloud/cypress' +import { NavigationHeader } from '../../pages/NavigationHeader' + +const admin = new User('admin', 'admin') + +describe('Admin theming set default apps', () => { + const navigationHeader = new NavigationHeader() + + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + it('See the current default app is the dashboard', () => { + // check default route + cy.visit('/') + cy.url().should('match', /apps\/dashboard/) + + // Also check the top logo link + navigationHeader.logo().click() + cy.url().should('match', /apps\/dashboard/) + }) + + it('See the default app settings', () => { + cy.visit('/settings/admin/theming') + + cy.get('.settings-section').contains('Navigation bar settings').should('exist') + cy.get('[data-cy-switch-default-app]').should('exist') + cy.get('[data-cy-switch-default-app]').scrollIntoView() + }) + + it('Toggle the "use custom default app" switch', () => { + cy.get('[data-cy-switch-default-app] input').should('not.be.checked') + cy.get('[data-cy-switch-default-app] .checkbox-content').click() + cy.get('[data-cy-switch-default-app] input').should('be.checked') + }) + + it('See the default app order selector', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { + const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() + expect(appIDs).to.deep.eq(['dashboard', 'files']) + }) + }) + + it('Change the default app', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"]').scrollIntoView() + + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + + }) + + it('See the default app is changed', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { + const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() + expect(appIDs).to.deep.eq(['files', 'dashboard']) + }) + + // Check the redirect to the default app works + cy.request({ url: '/', followRedirect: false }).then((response) => { + expect(response.status).to.eq(302) + expect(response).to.have.property('headers') + expect(response.headers.location).to.contain('/apps/files') + }) + }) + + it('Toggle the "use custom default app" switch back to reset the default apps', () => { + cy.visit('/settings/admin/theming') + cy.get('[data-cy-switch-default-app]').scrollIntoView() + + cy.get('[data-cy-switch-default-app] input').should('be.checked') + cy.get('[data-cy-switch-default-app] .checkbox-content').click() + cy.get('[data-cy-switch-default-app] input').should('be.not.checked') + }) + + it('See the default app is changed back to default', () => { + // Check the redirect to the default app works + cy.request({ url: '/', followRedirect: false }).then((response) => { + expect(response.status).to.eq(302) + expect(response).to.have.property('headers') + expect(response.headers.location).to.contain('/apps/dashboard') + }) + }) +}) diff --git a/cypress/e2e/theming/navigation-bar-settings.cy.ts b/cypress/e2e/theming/user-settings_app-order.cy.ts index fea72d454b0..7e6efa7d0ea 100644 --- a/cypress/e2e/theming/navigation-bar-settings.cy.ts +++ b/cypress/e2e/theming/user-settings_app-order.cy.ts @@ -5,89 +5,19 @@ import { User } from '@nextcloud/cypress' import { installTestApp, uninstallTestApp } from '../../support/commonUtils' +import { NavigationHeader } from '../../pages/NavigationHeader' -const admin = new User('admin', 'admin') - -describe('Admin theming set default apps', () => { - before(function() { - // Just in case previous test failed - cy.resetAdminTheming() - cy.login(admin) - }) - - it('See the current default app is the dashboard', () => { - cy.visit('/') - cy.url().should('match', /apps\/dashboard/) - - // Also check the top logo link - cy.get('#nextcloud').click() - cy.url().should('match', /apps\/dashboard/) - }) - - it('See the default app settings', () => { - cy.visit('/settings/admin/theming') - - cy.get('.settings-section').contains('Navigation bar settings').should('exist') - cy.get('[data-cy-switch-default-app]').should('exist') - cy.get('[data-cy-switch-default-app]').scrollIntoView() - }) - - it('Toggle the "use custom default app" switch', () => { - cy.get('[data-cy-switch-default-app] input').should('not.be.checked') - cy.get('[data-cy-switch-default-app] .checkbox-content').click() - cy.get('[data-cy-switch-default-app] input').should('be.checked') - }) - - it('See the default app order selector', () => { - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) - }) - - it('Change the default app', () => { - cy.get('[data-cy-app-order] [data-cy-app-order-element="files"]').scrollIntoView() - - cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') - cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() - cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') - - }) - - it('See the default app is changed', () => { - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard']) - }) - - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/files') - }) - }) - - it('Toggle the "use custom default app" switch back to reset the default apps', () => { - cy.visit('/settings/admin/theming') - cy.get('[data-cy-switch-default-app]').scrollIntoView() - - cy.get('[data-cy-switch-default-app] input').should('be.checked') - cy.get('[data-cy-switch-default-app] .checkbox-content').click() - cy.get('[data-cy-switch-default-app] input').should('be.not.checked') - }) +/** + * Intercept setting the app order as `updateAppOrder` + */ +function interceptAppOrder() { + cy.intercept('POST', '/ocs/v2.php/apps/provisioning_api/api/v1/config/users/core/apporder').as('updateAppOrder') +} - it('See the default app is changed back to default', () => { - // Check the redirect to the default app works - cy.request({ url: '/', followRedirect: false }).then((response) => { - expect(response.status).to.eq(302) - expect(response).to.have.property('headers') - expect(response.headers.location).to.contain('/apps/dashboard') - }) - }) -}) +before(() => uninstallTestApp()) describe('User theming set app order', () => { + const navigationHeader = new NavigationHeader() let user: User before(() => { @@ -109,40 +39,43 @@ describe('User theming set app order', () => { }) it('See that the dashboard app is the first one', () => { + const appOrder = ['Dashboard', 'Files'] // Check the app order settings UI - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) // Check the top app menu order - cy.get('.app-menu-main .app-menu-entry').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) it('Change the app order', () => { + interceptAppOrder() cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + cy.wait('@updateAppOrder') - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard']) - }) + const appOrder = ['Files', 'Dashboard'] + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) }) it('See the app menu order is changed', () => { cy.reload() - cy.get('.app-menu-main .app-menu-entry').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard']) - }) + const appOrder = ['Files', 'Dashboard'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) + + // Check the top app menu order + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) }) describe('User theming set app order with default app', () => { + const navigationHeader = new NavigationHeader() let user: User before(() => { @@ -176,11 +109,11 @@ describe('User theming set app order with default app', () => { it('See the app order settings: files is the first one', () => { cy.visit('/settings/user/theming') cy.get('[data-cy-app-order]').scrollIntoView() - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - expect(elements).to.have.length(4) - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard', 'testapp1', 'testapp']) - }) + + const appOrder = ['Files', 'Dashboard', 'Test App 2', 'Test App'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) }) it('Can not change the default app', () => { @@ -195,32 +128,31 @@ describe('User theming set app order with default app', () => { }) it('Change the order of the other apps', () => { - cy.intercept('POST', '**/apps/provisioning_api/api/v1/config/users/core/apporder').as('setAppOrder') + interceptAppOrder() // Move the testapp up twice, it should be the first one after files cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').click() - cy.wait('@setAppOrder') + cy.wait('@updateAppOrder') cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').click() - cy.wait('@setAppOrder') + cy.wait('@updateAppOrder') // Can't get up anymore, files is enforced as default app cy.get('[data-cy-app-order] [data-cy-app-order-element="testapp"] [data-cy-app-order-button="up"]').should('not.be.visible') // Check the final list order - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - expect(elements).to.have.length(4) - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'testapp', 'dashboard', 'testapp1']) - }) + const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) }) it('See the app menu order is changed', () => { cy.reload() - cy.get('.app-menu-main .app-menu-entry').then(elements => { - expect(elements).to.have.length(4) - const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get() - expect(appIDs).to.deep.eq(['files', 'testapp', 'dashboard', 'testapp1']) - }) + + const appOrder = ['Files', 'Test App', 'Dashboard', 'Test App 2'] + // Check the top app menu order + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) }) @@ -247,8 +179,10 @@ describe('User theming app order list accessibility', () => { }) it('click the first button', () => { + interceptAppOrder() cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('be.visible').focus() cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').click() + cy.wait('@updateAppOrder') }) it('see the same app kept the focus', () => { @@ -259,8 +193,10 @@ describe('User theming app order list accessibility', () => { }) it('click the last button', () => { + interceptAppOrder() cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('be.visible').focus() cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').click() + cy.wait('@updateAppOrder') }) it('see the same app kept the focus', () => { @@ -272,6 +208,7 @@ describe('User theming app order list accessibility', () => { }) describe('User theming reset app order', () => { + const navigationHeader = new NavigationHeader() let user: User before(() => { @@ -293,17 +230,14 @@ describe('User theming reset app order', () => { }) it('See that the dashboard app is the first one', () => { + const appOrder = ['Dashboard', 'Files'] // Check the app order settings UI - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) // Check the top app menu order - cy.get('.app-menu-main .app-menu-entry').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-app-id')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) it('See the reset button is disabled', () => { @@ -312,15 +246,17 @@ describe('User theming reset app order', () => { }) it('Change the app order', () => { + interceptAppOrder() cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + cy.wait('@updateAppOrder') // Check the app order settings UI - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['files', 'dashboard']) - }) + const appOrder = ['Files', 'Dashboard'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) }) it('See the reset button is no longer disabled', () => { @@ -329,14 +265,25 @@ describe('User theming reset app order', () => { }) it('Reset the app order', () => { + cy.intercept('GET', '/ocs/v2.php/core/navigation/apps').as('loadApps') + interceptAppOrder() cy.get('[data-test-id="btn-apporder-reset"]').click({ force: true }) + + cy.wait('@updateAppOrder') + .its('request.body') + .should('have.property', 'configValue', '[]') + cy.wait('@loadApps') }) it('See the app order is restored', () => { - cy.get('[data-cy-app-order] [data-cy-app-order-element]').then(elements => { - const appIDs = elements.map((idx, el) => el.getAttribute('data-cy-app-order-element')).get() - expect(appIDs).to.deep.eq(['dashboard', 'files']) - }) + const appOrder = ['Dashboard', 'Files'] + // Check the app order settings UI + cy.get('[data-cy-app-order] [data-cy-app-order-element]') + .each((element, index) => expect(element).to.contain.text(appOrder[index])) + + // Check the top app menu order + navigationHeader.getNavigationEntries() + .each((entry, index) => expect(entry).contain.text(appOrder[index])) }) it('See the reset button is disabled again', () => { diff --git a/cypress/e2e/theming/user-background.cy.ts b/cypress/e2e/theming/user-settings_background.cy.ts index 445fb8a34f6..8abcb5bace1 100644 --- a/cypress/e2e/theming/user-background.cy.ts +++ b/cypress/e2e/theming/user-settings_background.cy.ts @@ -4,7 +4,8 @@ */ import { User } from '@nextcloud/cypress' -import { defaultPrimary, defaultBackground, pickRandomColor, validateBodyThemingCss } from './themingUtils' +import { defaultPrimary, defaultBackground, validateBodyThemingCss } from './themingUtils' +import { NavigationHeader } from '../../pages/NavigationHeader' const admin = new User('admin', 'admin') @@ -122,6 +123,8 @@ describe('User select a custom color', function() { }) describe('User select a bright custom color and remove background', function() { + const navigationHeader = new NavigationHeader() + before(function() { cy.createRandomUser().then((user: User) => { cy.login(user) @@ -159,12 +162,12 @@ describe('User select a bright custom color and remove background', function() { }) it('See the header being inverted', function() { - cy.waitUntil(() => cy.window().then((win) => { - const firstEntry = win.document.querySelector('.app-menu-main li img') - if (!firstEntry) { - return false - } - return getComputedStyle(firstEntry).filter === 'invert(1)' + cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => { + let ret = true + el.each(function() { + ret = ret && window.getComputedStyle(this).filter === 'invert(1)' + }) + return ret })) }) @@ -181,12 +184,12 @@ describe('User select a bright custom color and remove background', function() { }) it('See the header NOT being inverted this time', function() { - cy.waitUntil(() => cy.window().then((win) => { - const firstEntry = win.document.querySelector('.app-menu-main li') - if (!firstEntry) { - return false - } - return getComputedStyle(firstEntry).filter === 'none' + cy.waitUntil(() => navigationHeader.getNavigationEntries().find('img').then((el) => { + let ret = true + el.each(function() { + ret = ret && window.getComputedStyle(this).filter === 'none' + }) + return ret })) }) }) diff --git a/cypress/pages/NavigationHeader.ts b/cypress/pages/NavigationHeader.ts new file mode 100644 index 00000000000..5441b75de88 --- /dev/null +++ b/cypress/pages/NavigationHeader.ts @@ -0,0 +1,58 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Page object model for the Nextcloud navigation header + */ +export class NavigationHeader { + + /** + * Locator of the header bar wrapper + */ + header() { + return cy.get('header#header') + } + + /** + * Locator for the logo navigation entry (entry redirects to default app) + */ + logo() { + return this.header() + .find('#nextcloud') + } + + /** + * Locator of the app navigation bar + */ + navigation() { + return this.header() + .findByRole('navigation', { name: 'Applications menu' }) + } + + /** + * The toggle for the navigation overflow menu + */ + overflowNavigationToggle() { + return this.navigation() + } + + /** + * Get all navigation entries + */ + getNavigationEntries() { + return this.navigation() + .findAllByRole('listitem') + } + + /** + * Get the navigation entry for a given app + * @param name The app name + */ + getNavigationEntry(name: string) { + return this.navigation() + .findByRole('listitem', { name }) + } + +} |