diff --git a/resources/assets/src/js/__tests__/admin.test.js b/resources/assets/src/js/__tests__/admin.test.js index d89c31a7..a425cbe1 100644 --- a/resources/assets/src/js/__tests__/admin.test.js +++ b/resources/assets/src/js/__tests__/admin.test.js @@ -302,6 +302,42 @@ describe('tests for "players" module', () => { expect(showAjaxError).toBeCalled(); }); + it('show nickname in swal', async () => { + const fetch = jest.fn() + .mockReturnValueOnce(Promise.resolve({ user: { nickname: 'name' } })) + .mockReturnValueOnce(Promise.reject()); + const url = jest.fn(path => path); + const trans = jest.fn(key => key); + window.fetch = fetch; + window.url = url; + window.trans = trans; + + document.body.innerHTML = ` + +
+ `; + const { showNicknameInSwal } = require(modulePath); + + await showNicknameInSwal(); + expect(fetch).not.toBeCalled(); + + $('input').val('-8'); + await showNicknameInSwal(); + expect(fetch).not.toBeCalled(); + + $('input').val('8'); + await showNicknameInSwal(); + expect(fetch).toBeCalledWith({ + type: 'GET', + url: 'admin/user/8', + dataType: 'json' + }); + expect($('div').html().includes('name')); + + await showNicknameInSwal(); + expect($('div').html().includes('admin.noSuchUser')); + }); + it('delete player', async () => { const fetch = jest.fn() .mockReturnValueOnce(Promise.resolve({ errno: 0, msg: 'success' })) @@ -506,10 +542,13 @@ describe('tests for "update" module', () => { it('download updates', async () => { const fetch = jest.fn() - .mockReturnValueOnce(Promise.resolve({ - file_size: 5000 - })) - .mockReturnValueOnce(Promise.resolve()); + .mockImplementationOnce(({ beforeSend }) => { + beforeSend && beforeSend(); + return Promise.resolve({ + file_size: 5000 + }); + }) + .mockImplementationOnce(() => Promise.resolve()); const url = jest.fn(path => path); const toastr = { success: jest.fn(), @@ -525,6 +564,7 @@ describe('tests for "update" module', () => { document.body.innerHTML = `
+ `; const downloadUpdates = require(modulePath).downloadUpdates; @@ -535,6 +575,7 @@ describe('tests for "update" module', () => { type: 'GET', dataType: 'json', })); + expect($('#update-button').prop('disabled')).toBe(true); expect($('#file-size').html()).toBe('5000'); expect(modal).toBeCalledWith({ backdrop: 'static', @@ -1048,7 +1089,7 @@ describe('tests for "common" module', () => { version: '8.1.0' }; - const sendFeedback = require(modulePath); + const { sendFeedback } = require(modulePath); await sendFeedback(); expect(fetch).toBeCalledWith({ @@ -1064,4 +1105,27 @@ describe('tests for "common" module', () => { expect(fetch).toHaveBeenCalledTimes(1); expect(console.log).toHaveBeenCalledTimes(1); }); + + it('initialize data tables', () => { + $.fn.dataTable = { defaults: {} }; + const initUsersTable = jest.fn(); + const initPlayersTable = jest.fn(); + const initPluginsTable = jest.fn(); + window.initUsersTable = initUsersTable; + window.initPlayersTable = initPlayersTable; + window.initPluginsTable = initPluginsTable; + const { initTables } = require(modulePath); + + document.body.innerHTML = '
'; + initTables(); + expect(initUsersTable).toBeCalled(); + + document.body.innerHTML = '
'; + initTables(); + expect(initPlayersTable).toBeCalled(); + + document.body.innerHTML = '
'; + initTables(); + expect($.pluginsTable).not.toBeNull(); + }); }); diff --git a/resources/assets/src/js/__tests__/common.test.js b/resources/assets/src/js/__tests__/common.test.js index c9a93da1..1b5b2d79 100644 --- a/resources/assets/src/js/__tests__/common.test.js +++ b/resources/assets/src/js/__tests__/common.test.js @@ -192,6 +192,135 @@ describe('tests for "polyfill" module', () => { }); }); +describe('tests for "texture-preview" module', () => { + const TexturePreview = require('../common/texture-preview'); + + it('change 2d preview', () => { + const url = jest.fn(path => path); + window.url = url; + + document.body.innerHTML = ` + +
+ `; + + const instance = new TexturePreview('steve', 5, 'default'); + instance.change2dPreview(); + expect($('img').attr('src')).toBe('preview/200/5.png'); + expect($('a').attr('href')).toBe('skinlib/show/5'); + expect($('div').css('display')).toBe('none'); + }); + + it('change 3d preview', async () => { + const url = jest.fn(path => path); + const fetch = jest.fn() + .mockReturnValueOnce(Promise.resolve({ hash: '1' })) + .mockReturnValueOnce(Promise.resolve({ hash: '2' })) + .mockReturnValueOnce(Promise.reject()); + const showAjaxError = jest.fn(); + const MSP = { + changeSkin: jest.fn(), + changeCape: jest.fn() + }; + window.url = url; + window.fetch = fetch; + window.showAjaxError = showAjaxError; + window.MSP = MSP; + + let instance = new TexturePreview('alex', 5, 'default'); + instance.change3dPreview(); + expect(fetch).not.toBeCalled(); + + instance = new TexturePreview('steve', 5, 'default'); + await instance.change3dPreview(); + expect(fetch).toBeCalledWith({ + type: 'GET', + url: 'skinlib/info/5', + dataType: 'json' + }); + expect(MSP.changeSkin).toBeCalledWith('textures/1'); + + instance = new TexturePreview('cape', 5, 'default'); + await instance.change3dPreview(); + expect(fetch).toBeCalledWith({ + type: 'GET', + url: 'skinlib/info/5', + dataType: 'json' + }); + expect(MSP.changeCape).toBeCalledWith('textures/2'); + + await instance.change3dPreview(); + expect(showAjaxError).toBeCalled(); + }); + + it('show not uploaded', () => { + const MSP = { + changeSkin: jest.fn(), + changeCape: jest.fn() + }; + window.MSP = MSP; + document.body.innerHTML = ` + +
+ `; + + let instance = new TexturePreview('steve', 5, 'default'); + instance.showNotUploaded(); + expect($('img').css('display')).toBe('none'); + expect($('div').css('display')).not.toBe('none'); + + instance = new TexturePreview('cape', 7, 'cape'); + instance.showNotUploaded(); + expect(MSP.changeCape).toBeCalledWith(''); + }); + + it('show 3d preview', () => { + document.body.innerHTML = ` + + + +
+ `; + const trans = jest.fn(key => key); + const MSP = { + changeSkin: jest.fn(), + changeCape: jest.fn(), + get3dSkinCanvas: jest.fn(() => '') + }; + window.trans = trans; + window.MSP = MSP; + + TexturePreview.previewType = '3D'; + TexturePreview.show3dPreview(); + expect($('#preview-2d').css('display')).toBe('none'); + expect($('.operations').css('display')).not.toBe('none'); + expect($('#preview-switch').html()).toBe('user.switch2dPreview'); + expect(MSP.get3dSkinCanvas).toBeCalledWith(0, 0); + }); + + it('show 2d preview', () => { + document.body.innerHTML = ` +
+ + + + `; + const trans = jest.fn(key => key); + const MSP = { + changeSkin: jest.fn(), + changeCape: jest.fn() + }; + window.trans = trans; + window.MSP = MSP; + + TexturePreview.show2dPreview(); + expect(document.getElementById('canvas3d')).toBeNull(); + expect($('#preview-2d').css('display')).not.toBe('none'); + expect($('.operations').css('display')).toBe('none'); + expect($('#preview-switch').html()).toBe('user.switch3dPreview'); + }); +}); + describe('tests for "utils" module', () => { const modulePath = '../common/utils'; @@ -213,6 +342,7 @@ describe('tests for "utils" module', () => { expect(isEmpty([])).toBe(true); expect(isEmpty([1])).toBe(false); expect(isEmpty('something')).toBe(false); + expect(isEmpty(Symbol())).toBe(true); }); it('fake fetch', () => { @@ -249,4 +379,39 @@ describe('tests for "utils" module', () => { expect(url('test')).toBe('http://localhost/test'); expect(url('/test')).toBe('http://localhost/test'); }); + + it('get argument from query string', () => { + const { getQueryString } = require(modulePath); + + Object.defineProperty(window.location, 'search', { + value: '?key1=val1', + writable: true + }); + expect(getQueryString('key1')).toBe('val1'); + expect(getQueryString('key2')).toBeUndefined(); + expect(getQueryString('key2', 'val2')).toBe('val2'); + window.location.search = '?key1=val1&key2=val2'; + expect(getQueryString('key2', 'val2')).toBe('val2'); + }); + + it('test `isMobileBrowserScrolling` function', () => { + const { isMobileBrowserScrolling } = require(modulePath); + expect(isMobileBrowserScrolling()).toBe(false); + + $.cachedWindowWidth = 50; + $.cachedWindowHeight = 50; + expect(isMobileBrowserScrolling()).toBe(false); + + $.cachedWindowWidth = 60; + $.cachedWindowHeight = 0; + expect(isMobileBrowserScrolling()).toBe(false); + + $.cachedWindowWidth = 0; + $.cachedWindowHeight = 50; + expect(isMobileBrowserScrolling()).toBe(true); + expect(isMobileBrowserScrolling()).toBe(true); // For the second condition + + $.lastWindowHeight = 60; + expect(isMobileBrowserScrolling()).toBe(false); + }); }); diff --git a/resources/assets/src/js/__tests__/skinlib.test.js b/resources/assets/src/js/__tests__/skinlib.test.js index bd349636..9d48cc19 100644 --- a/resources/assets/src/js/__tests__/skinlib.test.js +++ b/resources/assets/src/js/__tests__/skinlib.test.js @@ -1,5 +1,6 @@ /* eslint no-unused-vars: "off" */ +jest.dontMock('jquery'); const $ = require('jquery'); window.$ = window.jQuery = $; @@ -184,6 +185,9 @@ describe('tests for "index" module', () => { keyword: '%20q' } })); + + await reloadSkinlib(); + expect(showAjaxError).toBeCalled(); }); it('update query string', () => { @@ -208,6 +212,32 @@ describe('tests for "index" module', () => { expect($('li[data-code=zh_CN] > a').prop('href')).toBe(`?lang=zh_CN&${query}`); expect($('li[data-code=en] > a').prop('href')).toBe(`?lang=en&${query}`); }); + + it('change page', () => { + document.body.innerHTML = ` + + `; + const { onPageChange } = require(modulePath); + onPageChange(1, 'init'); + expect($.skinlib.page).toBe(1); + + onPageChange(2); + expect($('div').css('display')).not.toBe('none'); + expect($.skinlib.page).toBe(2); + }); + + it('update filter', () => { + document.body.innerHTML = ` +
+ `; + const { updateFilter } = require(modulePath); + updateFilter.call($('div'), new Event('click')); + expect($.skinlib.filter).toBe('skin'); + + $('div').data('filter', 'uploader').data('uid', 4); + updateFilter.call($('div'), new Event('click')); + expect($.skinlib.uploader).toBe(4); + }); }); describe('tests for "operations" module', () => { diff --git a/resources/assets/src/js/__tests__/user.test.js b/resources/assets/src/js/__tests__/user.test.js index 8e4d5e47..a060f4c2 100644 --- a/resources/assets/src/js/__tests__/user.test.js +++ b/resources/assets/src/js/__tests__/user.test.js @@ -363,6 +363,43 @@ describe('tests for "closet" module', () => { await setAsAvatar(1); expect(showAjaxError).toBeCalled(); }); + + it('initialize closet', async () => { + const fetch = jest.fn() + .mockReturnValueOnce(Promise.reject()) + .mockReturnValueOnce(Promise.resolve({ items: [], category: 'skin', total_pages: 0 })); + const trans = jest.fn(key => key); + const url = jest.fn(path => path); + const showAjaxError = jest.fn(); + const debounce = jest.fn((func, timer) => func()); + window.fetch = fetch; + window.trans = trans; + window.url = url; + window.showAjaxError = showAjaxError; + window.debounce = debounce; + $.defaultPaginatorConfig = {}; + $.fn.jqPaginator = jest.fn(({ onPageChange }) => onPageChange(0)); + + const { initCloset } = require(modulePath); + await initCloset(); + expect(fetch).not.toBeCalled(); + + document.body.innerHTML = ` +
+
+ `; + await initCloset(); + expect(showAjaxError).toBeCalled(); + + await initCloset(); + expect(debounce.mock.calls[0][1]).toBe(350); + expect(fetch).toBeCalledWith({ + type: 'GET', + url: '/user/closet-data', + dataType: 'json' + }); + expect($.fn.jqPaginator).toBeCalled(); + }); }); describe('tests for "player" module', () => { diff --git a/resources/assets/src/js/admin/common.js b/resources/assets/src/js/admin/common.js index cbe87688..0b326dc3 100644 --- a/resources/assets/src/js/admin/common.js +++ b/resources/assets/src/js/admin/common.js @@ -4,7 +4,9 @@ $.pluginsTable = null; -$(document).ready(() => { +$(document).ready(initTables); + +function initTables() { $.extend(true, $.fn.dataTable.defaults, { language: trans('vendor.datatables'), scrollX: true, @@ -14,14 +16,14 @@ $(document).ready(() => { serverSide: true }); - if (window.location.pathname.includes('admin/users')) { + if ($('#user-table').length === 1) { initUsersTable(); - } else if (window.location.pathname.includes('admin/players')) { + } else if ($('#player-table').length === 1) { initPlayersTable(); - } else if (window.location.pathname.includes('admin/plugins/manage')) { + } else if ($('#plugin-table').length === 1) { $.pluginsTable = initPluginsTable(); } -}); +} async function sendFeedback() { if (docCookies.getItem('feedback_sent') !== null) @@ -50,5 +52,8 @@ async function sendFeedback() { } if (typeof require !== 'undefined' && typeof module !== 'undefined') { - module.exports = sendFeedback; + module.exports = { + sendFeedback, + initTables + }; } diff --git a/resources/assets/src/js/admin/players.js b/resources/assets/src/js/admin/players.js index 7e42dc74..edd86c43 100644 --- a/resources/assets/src/js/admin/players.js +++ b/resources/assets/src/js/admin/players.js @@ -133,31 +133,33 @@ function changeOwner(pid) { } }); - $('.swal2-input').on('input', debounce(async () => { - let uid = $('.swal2-input').val(); + $('.swal2-input').on('input', debounce(showNicknameInSwal, 350)); +} - if (isNaN(uid) || uid <= 0) - return; +async function showNicknameInSwal() { + let uid = $('.swal2-input').val(); - try { - const { user } = await fetch({ - type: 'GET', - url: url(`admin/user/${uid}`), - dataType: 'json' - }); - $('.swal2-content').html( - trans('admin.changePlayerOwner') + - '' + - trans('admin.targetUser', { nickname: user.nickname }) + - '' - ); - } catch (error) { - $('.swal2-content').html(` - ${trans('admin.changePlayerOwner')}
- ${trans('admin.noSuchUser')} - `); - } - }, 350)); + if (isNaN(uid) || uid <= 0) + return; + + try { + const { user } = await fetch({ + type: 'GET', + url: url(`admin/user/${uid}`), + dataType: 'json' + }); + $('.swal2-content').html( + trans('admin.changePlayerOwner') + + '' + + trans('admin.targetUser', { nickname: user.nickname }) + + '' + ); + } catch (error) { + $('.swal2-content').html(` + ${trans('admin.changePlayerOwner')}
+ ${trans('admin.noSuchUser')} + `); + } } async function deletePlayer(pid) { @@ -192,6 +194,7 @@ async function deletePlayer(pid) { if (typeof require !== 'undefined' && typeof module !== 'undefined') { module.exports = { changeOwner, + showNicknameInSwal, deletePlayer, changeTexture, changePlayerName, diff --git a/resources/assets/src/js/admin/update.js b/resources/assets/src/js/admin/update.js index 31da29d5..844fce6d 100644 --- a/resources/assets/src/js/admin/update.js +++ b/resources/assets/src/js/admin/update.js @@ -42,7 +42,7 @@ function downloadUpdates() { }).catch(showAjaxError); // Downloading progress polling - let interval_id = window.setInterval(() => { + let interval_id = setInterval(() => { $('#imported-progress').html(progress); $('.progress-bar').css('width', progress+'%').attr('aria-valuenow', progress); diff --git a/resources/assets/src/js/common/logout.js b/resources/assets/src/js/common/logout.js index b099894a..efba28cf 100644 --- a/resources/assets/src/js/common/logout.js +++ b/resources/assets/src/js/common/logout.js @@ -33,4 +33,4 @@ function logout() { }); } -$('#logout-button').click(() => confirmLogout()); +$('#logout-button').click(confirmLogout); diff --git a/resources/assets/src/js/common/texture-preview.js b/resources/assets/src/js/common/texture-preview.js index 007b7f90..6cb9d897 100644 --- a/resources/assets/src/js/common/texture-preview.js +++ b/resources/assets/src/js/common/texture-preview.js @@ -1,10 +1,23 @@ /* global MSP */ class TexturePreview { + + /** + * + * @param {'steve'|'alex'|'cape'} type + * @param {number} tid + * @param {'default'|'slim'} preference + */ constructor(type, tid, preference) { this.tid = tid; this.type = type; + /** + * @type {JQuery} + */ this.selector = $(`#${type}`); + /** + * @type {'default'|'slim'} + */ this.preference = (type == 'steve') ? 'default' : 'slim'; this.playerPreference = preference; } @@ -19,13 +32,15 @@ class TexturePreview { return this; } - change3dPreview() { + async change3dPreview() { if (this.playerPreference == this.preference || this.type == 'cape') { - fetch({ - type: 'GET', - url: url(`skinlib/info/${this.tid}`), - dataType: 'json' - }).then(({ hash }) => { + try { + const { hash } = await fetch({ + type: 'GET', + url: url(`skinlib/info/${this.tid}`), + dataType: 'json' + }); + let textureUrl = url(`textures/${hash}`); if (this.type == 'cape') { @@ -33,7 +48,9 @@ class TexturePreview { } else { MSP.changeSkin(textureUrl); } - }).catch(showAjaxError); + } catch (error) { + showAjaxError(error); + } } return this; @@ -49,44 +66,44 @@ class TexturePreview { return this; } + + static init3dPreview() { + if (TexturePreview.previewType == '2D') return; + + $('#preview-2d').hide(); + + let canvas = null; + + if ($(window).width() < 800) { + canvas = MSP.get3dSkinCanvas($('#skinpreview').width(), $('#skinpreview').width()); + $('#skinpreview').append($(canvas).prop('id', 'canvas3d')); + } else { + canvas = MSP.get3dSkinCanvas(350, 350); + $('#skinpreview').append($(canvas).prop('id', 'canvas3d')); + } + } + + static show3dPreview() { + TexturePreview.previewType = '3D'; + + TexturePreview.init3dPreview(); + $('#preview-2d').hide(); + $('.operations').show(); + $('#preview-switch').html(trans('user.switch2dPreview')); + } + + static show2dPreview() { + TexturePreview.previewType = '2D'; + + $('#canvas3d').remove(); + $('.operations').hide(); + $('#preview-2d').show(); + $('#preview-switch').html(trans('user.switch3dPreview')).attr('onclick', 'show3dPreview();'); + } } TexturePreview.previewType = '3D'; -TexturePreview.init3dPreview = () => { - if (TexturePreview.previewType == '2D') return; - - $('#preview-2d').hide(); - - let canvas = null; - - if ($(window).width() < 800) { - canvas = MSP.get3dSkinCanvas($('#skinpreview').width(), $('#skinpreview').width()); - $('#skinpreview').append($(canvas).prop('id', 'canvas3d')); - } else { - canvas = MSP.get3dSkinCanvas(350, 350); - $('#skinpreview').append($(canvas).prop('id', 'canvas3d')); - } -}; - -TexturePreview.show3dPreview = () => { - TexturePreview.previewType = '3D'; - - TexturePreview.init3dPreview(); - $('#preview-2d').hide(); - $('.operations').show(); - $('#preview-switch').html(trans('user.switch2dPreview')); -}; - -TexturePreview.show2dPreview = () => { - TexturePreview.previewType = '2D'; - - $('#canvas3d').remove(); - $('.operations').hide(); - $('#preview-2d').show(); - $('#preview-switch').html(trans('user.switch3dPreview')).attr('onclick', 'show3dPreview();'); -}; - // change 3D preview status $('.fa-pause').click(function () { MSP.setStatus('rotation', ! MSP.getStatus('rotation')); diff --git a/resources/assets/src/js/skinlib/index.js b/resources/assets/src/js/skinlib/index.js index b29ac0b7..341cc52d 100644 --- a/resources/assets/src/js/skinlib/index.js +++ b/resources/assets/src/js/skinlib/index.js @@ -26,20 +26,7 @@ $('select.pagination').on('change', function () { onPageChange(parseInt($(this).val())); }); -$('.filter').click(function (e) { - e.preventDefault(); - let selectedFilter = $(this).data('filter'); - - if (selectedFilter == 'uploader') { - $.skinlib.uploader = $(this).data('uid'); - console.log('Show items uploaded by uid ' + $.skinlib.uploader); - } else { - $.skinlib.filter = selectedFilter; - console.log('Filter by ' + $.skinlib.filter); - } - - reloadSkinlib(); -}); +$('.filter').click(updateFilter); $('.sort').click(function (e) { e.preventDefault(); @@ -174,6 +161,21 @@ function updatePaginator(currentPage, totalPages) { } } +function updateFilter(e) { + e.preventDefault(); + let selectedFilter = $(this).data('filter'); + + if (selectedFilter == 'uploader') { + $.skinlib.uploader = $(this).data('uid'); + console.log('Show items uploaded by uid ' + $.skinlib.uploader); + } else { + $.skinlib.filter = selectedFilter; + console.log('Filter by ' + $.skinlib.filter); + } + + reloadSkinlib(); +} + function onPageChange(page, type) { $.skinlib.page = page; updateBreadCrumb(); @@ -231,5 +233,7 @@ if (typeof require !== 'undefined' && typeof module !== 'undefined') { updatePaginator, updateBreadCrumb, updateUrlQueryString, + onPageChange, + updateFilter, }; } diff --git a/resources/assets/src/js/user/closet.js b/resources/assets/src/js/user/closet.js index 812ba12e..94d006ad 100644 --- a/resources/assets/src/js/user/closet.js +++ b/resources/assets/src/js/user/closet.js @@ -4,35 +4,7 @@ var selectedTextures = []; -$(document).ready(async function () { - if (! window.location.pathname.includes('/user/closet')) - return; - - $('input[name=q]').on('input', debounce(() => { - let category = $('#skin-category').hasClass('active') ? 'skin' : 'cape'; - reloadCloset(category, 1, $('input[name=q]').val()); - }, 350)); - - try { - const { items, category, total_pages } = await fetch({ - type: 'GET', - url: url('/user/closet-data'), - dataType: 'json' - }); - - renderCloset(items, category); - - $('#closet-paginator').jqPaginator($.extend({}, $.defaultPaginatorConfig, { - totalPages: total_pages, - onPageChange: page => reloadCloset( - $('#skin-category').hasClass('active') ? 'skin' : 'cape', - page, $('input[name=q]').val() - ) - })); - } catch (error) { - showAjaxError(error); - } -}); +$(document).ready(initCloset); $('body').on('click', '.item-body', async function () { $('.item-selected').parent().removeClass('item-selected'); @@ -82,6 +54,36 @@ $('body').on('click', '.category-switch', () => { reloadCloset(category, page, search); }); +async function initCloset() { + if ($('#closet-container').length !== 1) + return; + + $('input[name=q]').on('input', debounce(() => { + let category = $('#skin-category').hasClass('active') ? 'skin' : 'cape'; + reloadCloset(category, 1, $('input[name=q]').val()); + }, 350)); + + try { + const { items, category, total_pages } = await fetch({ + type: 'GET', + url: url('/user/closet-data'), + dataType: 'json' + }); + + renderCloset(items, category); + + $('#closet-paginator').jqPaginator($.extend({}, $.defaultPaginatorConfig, { + totalPages: total_pages, + onPageChange: page => reloadCloset( + $('#skin-category').hasClass('active') ? 'skin' : 'cape', + page, $('input[name=q]').val() + ) + })); + } catch (error) { + showAjaxError(error); + } +} + function renderClosetItemComponent(item) { return `
@@ -305,5 +307,6 @@ if (typeof require !== 'undefined' && typeof module !== 'undefined') { getCapacityOfCloset, renameClosetItem, removeFromCloset, + initCloset, }; } diff --git a/resources/views/user/closet.tpl b/resources/views/user/closet.tpl index c8e221d0..0e73e0d4 100644 --- a/resources/views/user/closet.tpl +++ b/resources/views/user/closet.tpl @@ -23,7 +23,7 @@